From 995b24fc1f07f020c488b3d323aea71ffa1317e8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:14:05 +0900 Subject: [PATCH 001/101] Split MCP transport orchestration from server core --- src/CodeIndex/Mcp/McpServer.Transport.cs | 1680 ++++++++++++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 1644 --------------------- 2 files changed, 1680 insertions(+), 1644 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.Transport.cs diff --git a/src/CodeIndex/Mcp/McpServer.Transport.cs b/src/CodeIndex/Mcp/McpServer.Transport.cs new file mode 100644 index 000000000..b48628f17 --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.Transport.cs @@ -0,0 +1,1680 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Mcp; + +/// +/// MCP (Model Context Protocol) server speaking JSON-RPC 2.0 over a pluggable transport. The +/// default preserves the historic stdin/stdout wire path, and +/// exposes the same JSON-RPC catalog over POST so AI clients can +/// share a warm server across sessions (issue #1558). +/// プラガブルな 上で JSON-RPC 2.0 を話す MCP サーバー。既定の +/// は従来通り stdin/stdout を使い、 +/// は同じ JSON-RPC カタログを POST で公開して、複数クライアントから暖機済みサーバーを共有できるようにする +/// (issue #1558)。 +/// Supported protocol versions: see (negotiated per +/// `initialize` request, #1554). +/// 対応プロトコルバージョン: 参照(`initialize` ごとに交渉, #1554)。 +/// +public partial class McpServer : IDisposable +{ + + public async Task RunAsync() + { + await using var transport = new StdioMcpTransport(StdioBufferSize); + using var cts = new CancellationTokenSource(); + using (RegisterShutdownHandlers(cts)) + { + await RunAsync(transport, cts.Token).ConfigureAwait(false); + } + } + + /// + /// Register cross-platform SIGINT (Ctrl+C) and SIGTERM handlers that cancel + /// so orchestrator-driven shutdowns drain the loop cleanly instead of leaving the MCP process + /// hung on stdin or force-killed mid-iteration (#1573). The returned IDisposable removes the + /// handlers; dispose it before disposing the CTS to avoid races between a late signal and CTS + /// teardown. + /// SIGINT (Ctrl+C) と SIGTERM を `cts` のキャンセルに変換するクロスプラットフォームハンドラを登録する + /// (#1573)。返り値の IDisposable でハンドラを解除する。late signal と CTS 破棄の競合を避けるため、 + /// CTS の Dispose より先にこれを Dispose する。 + /// + internal static IDisposable RegisterShutdownHandlers(CancellationTokenSource cts) + { + ArgumentNullException.ThrowIfNull(cts); + + ConsoleCancelEventHandler cancelHandler = (_, e) => + { + if (cts.IsCancellationRequested) + return; + // Honour the signal without letting the .NET runtime terminate the process before + // the loop has a chance to drain and dispose the shared DbContext. + // .NET runtime の即時終了を抑え、ループが DbContext を片付ける猶予を確保する。 + e.Cancel = true; + try { cts.Cancel(); } + catch (ObjectDisposedException) { /* signal raced disposal — nothing to cancel. */ } + }; + Console.CancelKeyPress += cancelHandler; + + PosixSignalRegistration? sigtermRegistration = null; + try + { + sigtermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => + { + if (cts.IsCancellationRequested) + return; + ctx.Cancel = true; + try { cts.Cancel(); } + catch (ObjectDisposedException) { /* see CancelKeyPress branch. */ } + }); + } + catch (PlatformNotSupportedException) + { + // PosixSignal.SIGTERM is supported on net8.0 across Windows/Linux/macOS, but a future + // niche runtime might not implement it. Console.CancelKeyPress still covers Ctrl+C + // everywhere, so degrade silently rather than refusing to start. + // .NET 8 では SIGTERM がクロスプラットフォーム対応だが、将来の特殊ランタイムで未対応の + // 可能性に備え、Console.CancelKeyPress による Ctrl+C カバレッジを残してサイレントに縮退する。 + } + + return new ShutdownHandlerRegistration(cancelHandler, sigtermRegistration); + } + + private sealed class ShutdownHandlerRegistration : IDisposable + { + private ConsoleCancelEventHandler? _cancelHandler; + private PosixSignalRegistration? _sigterm; + + public ShutdownHandlerRegistration(ConsoleCancelEventHandler cancelHandler, PosixSignalRegistration? sigterm) + { + _cancelHandler = cancelHandler; + _sigterm = sigterm; + } + + public void Dispose() + { + var handler = Interlocked.Exchange(ref _cancelHandler, null); + if (handler != null) + Console.CancelKeyPress -= handler; + var sigterm = Interlocked.Exchange(ref _sigterm, null); + sigterm?.Dispose(); + } + } + + /// + /// Run the MCP server loop on the supplied transport (issue #1558). Base transports use one + /// read followed by one write; concurrent-capable transports bind a response writer to each + /// frame. Notifications write null and end-of-stream terminates the loop. + /// 指定トランスポート上で MCP ループを動かす (issue #1558)。基本 transport は「読み 1 回 → + /// 書き 1 回」、並行対応 transport は frame ごとに response writer を紐付ける。通知は null を + /// 書き、EOS でループを終える。 + /// + internal async Task RunAsync(IMcpTransport transport, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(transport); + _enforceInitializationLifecycle = true; + Volatile.Write( + ref _activeTransportMaxResponseBytes, + transport is IMcpResponseSizeLimitProvider responseLimitProvider + ? responseLimitProvider.MaxResponseFrameBytes + : 0); + + // Link the caller-supplied token (Ctrl+C / HTTP listener stop) with the server-internal + // shutdown signal so `notifications/shutdown` also wakes any pending `ReadFrameAsync`. + // The MCP spec leaves shutdown to the transport, but real deployments need a wire-level + // way to drain in-flight work without killing the process (#1567). + // Ctrl+C 等の外部 token と内部 shutdown signal をリンクし、`notifications/shutdown` でも + // pending な `ReadFrameAsync` を unblock できるようにする (#1567)。 + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdownCts.Token); + var loopToken = linkedCts.Token; + + // Use stderr for logging so stdout stays clean for JSON-RPC + // stdoutをJSON-RPC用にクリーンに保つため、ログはstderrに出力 + ConsoleUi.TryWriteErrorLine($"[cdidx-mcp] Starting MCP server v{_version} (db: {FormatDbPathForLog(_dbPath)}, transport: {transport.Name} @ {transport.Endpoint}, max in-flight: {MaxConcurrency})"); + + if (transport is HttpMcpTransport httpTransport) + { + httpTransport.OutOfBandFrameHandler = (frame, _) => ProcessFrameAsync(frame); + httpTransport.HealthJsonProvider = () => BuildHealthJson(httpTransport); + httpTransport.KeepAliveInterval = _keepAliveInterval; + httpTransport.KeepAliveFrameProvider = BuildKeepAliveNotificationJson; + } + + try + { + if (string.Equals(transport.Name, "stdio", StringComparison.OrdinalIgnoreCase) + || transport is IConcurrentMcpTransport) + { + await RunConcurrentFrameLoopAsync(transport, loopToken, cancellationToken).ConfigureAwait(false); + return; + } + + Task? terminalTransportWriteTask = null; + try + { + while (_running) + { + // The full read/process/write iteration is wrapped in the same cancellation guard so + // a Ctrl+C that lands mid-iteration (e.g. while WriteFrameAsync is flushing) still + // exits the loop cleanly instead of bubbling OperationCanceledException out of the + // server and past ProgramRunner.RunMcpHttp's graceful-shutdown handler. + // Ctrl+C が WriteFrameAsync flush 中に来ても OperationCanceledException を呼び元に + // 漏らさず正常終了するよう、read/process/write 全体を同じ cancellation guard で囲む。 + try + { + var frame = await transport.ReadFrameAsync(loopToken).ConfigureAwait(false); + if (frame == null) + break; // transport closed / トランスポートが閉じられた + + string? response; + try + { + // Hand the per-request token to `WithDbReader` so SQLite work the tool kicks + // off can observe shutdown / client-disconnect cancellation through + // `DbReader.Cancellation` (#1567). + // ツールが起動する SQLite 作業が shutdown / 切断を観測できるよう per-request + // token を `WithDbReader` に渡す (#1567)。 + _currentRequestToken.Value = loopToken; + _currentOutOfBandFrameWriter.Value = transport is IOutOfBandMcpTransport outOfBandTransport + ? (frameToWrite, writeToken) => outOfBandTransport.WriteOutOfBandFrameAsync(frameToWrite, writeToken) + : null; + _canAwaitClientResponses.Value = transport is IOutOfBandMcpTransport + && (transport is not HttpMcpTransport httpResponseTransport || httpResponseTransport.HasEventStreams); + BeginDeferredFrameLogs(); + response = await ProcessFrameAsync(frame).ConfigureAwait(false); + } + finally + { + _currentRequestToken.Value = CancellationToken.None; + _currentOutOfBandFrameWriter.Value = null; + _canAwaitClientResponses.Value = false; + } + + // Internal shutdown cancels `loopToken` to stop reads and request actions, but + // the initiating notification still owns one transport completion (HTTP 204). + // Use only the caller token for that completion; bounded teardown below still + // limits a writer that does not finish (#4543). + // internal shutdown では read/action 用 loopToken を cancel するが、起点の + // notification に対応する transport completion (HTTP 204) は完了させる。 + // write は caller token のみを使い、停止しない writer は下の bounded teardown + // で制限する (#4543)。 + var responseWriteTask = WriteFrameSafelyAsync(transport, response, cancellationToken); + if (!_running) + { + // Do not await an uncooperative base-transport shutdown completion inline: + // the common finally must own its bounded deadline (#4543). + // 応答しない base transport の shutdown completion を inline await せず、 + // common finally の bounded deadline に委ねる (#4543)。 + terminalTransportWriteTask = responseWriteTask; + break; + } + + await responseWriteTask.ConfigureAwait(false); + FlushDeferredFrameLogs(); + + // `notifications/shutdown` flips `_running` inside `HandleMessage`; exit the loop + // immediately so a subsequent slow `ReadFrameAsync` does not extend the lifetime + // of a server that has been asked to stop. + // `notifications/shutdown` が `_running` を倒した直後にループを抜ける (#1567)。 + if (!_running) + break; + } + catch (OperationCanceledException) when (loopToken.IsCancellationRequested) + { + break; + } + catch (DecoderFallbackException ex) + { + BeginDeferredFrameLogs(); + terminalTransportWriteTask = WriteTerminalProtocolErrorAsync( + writeGate: null, + transport, + BuildInvalidUtf8ParseErrorResponse(ex), + cancellationToken); + break; + } + catch (BoundedLineLengthException ex) + { + BeginDeferredFrameLogs(); + terminalTransportWriteTask = WriteTerminalProtocolErrorAsync( + writeGate: null, + transport, + BuildOversizedLineErrorResponse(ex), + cancellationToken); + break; + } + } + } + finally + { + // Base transports have no detached request list, but shutdown cancellation + // callbacks and malformed-input writes still participate in the same bounded + // teardown contract as concurrent transports (#4543). + // base transport に detached request list は無いが、shutdown callback と + // malformed-input write は concurrent transport と同じ bounded teardown + // 契約へ必ず流す (#4543)。 + await DrainInFlightTasksAsync( + [], + InFlightDrainGracePeriod, + InFlightPostCancelGracePeriod, + cancellationToken, + terminalTransportWriteTask).ConfigureAwait(false); + FlushDeferredFrameLogs(); + } + } + finally + { + Volatile.Write(ref _activeTransportMaxResponseBytes, 0); + if (transport is HttpMcpTransport httpTransportToClear) + { + httpTransportToClear.OutOfBandFrameHandler = null; + httpTransportToClear.HealthJsonProvider = null; + httpTransportToClear.KeepAliveInterval = null; + httpTransportToClear.KeepAliveFrameProvider = null; + } + } + + CommandErrorWriter.WriteStderr("[cdidx-mcp] Server stopped. Restart `cdidx mcp` when your client reconnects."); + } + + private async Task RunConcurrentFrameLoopAsync( + IMcpTransport transport, + CancellationToken loopToken, + CancellationToken externalCancellationToken) + { + var writeGate = new SemaphoreSlim(1, 1); + var admissionGate = new SemaphoreSlim(MaxAcceptedConcurrentFrames, MaxAcceptedConcurrentFrames); + var tasks = new List(); + Task protocolBarrier = Task.CompletedTask; + Task? terminalTransportWriteTask = null; + var hasRequestScopedWriters = transport is IConcurrentMcpTransport; + + async Task WriteTransportFrameResponseAsync( + Func writeResponseAsync, + string? response) + { + // Concurrent transports provide one writer per request, so serializing those writers + // behind the base-transport gate lets an unrelated stuck response retain later HTTP + // request resources. Base transports (notably stdio) still require the shared gate. + // concurrent transport は request ごとの writer を持つため、base transport 用 gate + // に直列化すると無関係な stuck response が後続 HTTP resource を保持してしまう。 + // stdio 等の base transport だけ shared gate を維持する (#4546)。 + if (hasRequestScopedWriters) + { + await WriteFrameSafelyAsync( + writeResponseAsync, + response, + externalCancellationToken).ConfigureAwait(false); + FlushDeferredFrameLogs(); + return; + } + + await writeGate.WaitAsync(externalCancellationToken).ConfigureAwait(false); + try + { + await WriteFrameSafelyAsync( + writeResponseAsync, + response, + externalCancellationToken).ConfigureAwait(false); + FlushDeferredFrameLogs(); + } + finally + { + writeGate.Release(); + } + } + + try + { + while (_running) + { + PruneCompletedRequestTasks(tasks); + McpTransportFrame? transportFrame; + try + { + if (transport is IConcurrentMcpTransport concurrentTransport) + { + transportFrame = await concurrentTransport.ReadConcurrentFrameAsync(loopToken).ConfigureAwait(false); + } + else + { + var readFrame = await transport.ReadFrameAsync(loopToken).ConfigureAwait(false); + transportFrame = readFrame is null + ? null + : new McpTransportFrame(readFrame, transport.WriteFrameAsync); + } + } + catch (OperationCanceledException) when (loopToken.IsCancellationRequested) + { + break; + } + catch (DecoderFallbackException ex) + { + BeginDeferredFrameLogs(); + terminalTransportWriteTask = WriteTerminalProtocolErrorAsync( + writeGate, + transport, + BuildInvalidUtf8ParseErrorResponse(ex), + externalCancellationToken); + break; + } + catch (BoundedLineLengthException ex) + { + BeginDeferredFrameLogs(); + terminalTransportWriteTask = WriteTerminalProtocolErrorAsync( + writeGate, + transport, + BuildOversizedLineErrorResponse(ex), + externalCancellationToken); + break; + } + if (transportFrame is null) + break; + var frame = transportFrame.Frame; + var writeResponseAsync = transportFrame.WriteResponseAsync; + var transportRequestToken = transportFrame.RequestCancellationToken; + + if (IsCancellationFrame(frame)) + { + try + { + BeginDeferredFrameLogs(); + var response = await ProcessFrameAsync(frame).ConfigureAwait(false); + await WriteTransportFrameResponseAsync(writeResponseAsync, response).ConfigureAwait(false); + } + finally + { + transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); + } + continue; + } + + if (IsServerResponseFrame(frame)) + { + try + { + BeginDeferredFrameLogs(); + var response = await ProcessFrameAsync(frame).ConfigureAwait(false); + await WriteTransportFrameResponseAsync(writeResponseAsync, response).ConfigureAwait(false); + } + finally + { + transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); + } + continue; + } + + // Admission is deliberately non-blocking: waiting here would prevent a later + // cancellation/client-response frame from being read while execution is saturated. + // Excess ordinary work receives a retry-safe JSON-RPC overload response instead of + // retaining another frame/task/HTTP context without bound (#4536). + // admission は non-blocking にする。ここで待つと execution 飽和中に後続の + // cancellation/client-response frame を読めなくなるため。上限超過 work は task や + // HTTP context を保持し続けず、retry-safe overload response を返す (#4536)。 + if (!admissionGate.Wait(0)) + { + try + { + // Keep every response-bearing id registered until its retry-safe overload + // response has reached the transport. A cancellation before or during that + // write then belongs to this rejected occurrence instead of poisoning a later + // same-id retry (#4536, #4545). + // retry-safe overload 応答が transport へ届くまで response-bearing id を登録する。 + // reject 前または write 中の cancel をこの occurrence に束縛し、同じ id の後続 + // retry へ持ち越さない (#4536, #4545)。 + using var capacityRejectedRegistrations = new CapacityRejectedFrameRegistrations(this); + BeginDeferredFrameLogs(); + var response = await ProcessFrameAsync( + frame, + beforeDispatchAsync: null, + rejectForCapacity: true, + capacityRejectedRegistrations: capacityRejectedRegistrations).ConfigureAwait(false); + await WriteTransportFrameResponseAsync(writeResponseAsync, response).ConfigureAwait(false); + } + finally + { + transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); + } + continue; + } + Interlocked.Increment(ref _acceptedConcurrentFrameCount); + + var requestTaskStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var isProtocolBarrier = IsProtocolOrderingBarrierFrame(frame); + var precedingBarrier = protocolBarrier; + var tasksAcceptedBeforeBarrier = isProtocolBarrier ? tasks.ToArray() : []; + Func awaitPredecessorsAsync = isProtocolBarrier + ? token => AwaitProtocolPredecessorsAsync(tasksAcceptedBeforeBarrier, token) + : token => AwaitProtocolPredecessorsAsync([precedingBarrier], token); + var predecessorTask = new Lazy( + () => awaitPredecessorsAsync(loopToken), + LazyThreadSafetyMode.ExecutionAndPublication); + Task BeforeDispatchAsync(CancellationToken token) + => predecessorTask.Value.WaitAsync(token); + // Accepted frames are bounded independently from executing operations. The request + // registers its id/cancellation state before awaiting protocol predecessors and the + // execution gate, so a cancellation cannot expire while queued (#4536). + // accepted frame と executing operation は別々に上限化する。request は protocol + // predecessor / execution gate を待つ前に id と cancellation state を登録するため、 + // queue 中に cancellation が失効しない (#4536)。 + Task requestTask; + try + { + requestTask = Task.Run(async () => + { + var detachedIsolatedActions = new ConcurrentQueue(); + var previousDetachedIsolatedActions = _currentDetachedIsolatedActions.Value; + try + { + requestTaskStarted.TrySetResult(); + using var frameCts = transportRequestToken.CanBeCanceled + ? CancellationTokenSource.CreateLinkedTokenSource(loopToken, transportRequestToken) + : null; + var frameToken = frameCts?.Token ?? loopToken; + string? response = null; + try + { + _currentDetachedIsolatedActions.Value = detachedIsolatedActions; + _currentRequestToken.Value = frameToken; + _currentOutOfBandFrameWriter.Value = transport is IOutOfBandMcpTransport outOfBandTransport + ? (frameToWrite, writeToken) => outOfBandTransport.WriteOutOfBandFrameAsync(frameToWrite, writeToken) + : string.Equals(transport.Name, "stdio", StringComparison.OrdinalIgnoreCase) + ? async (frameToWrite, writeToken) => + { + await writeGate.WaitAsync(writeToken).ConfigureAwait(false); + try + { + await transport.WriteFrameAsync(frameToWrite, writeToken).ConfigureAwait(false); + } + finally + { + writeGate.Release(); + } + } + : null; + _canAwaitClientResponses.Value = _currentOutOfBandFrameWriter.Value is not null + && (transport is not HttpMcpTransport httpResponseTransport || httpResponseTransport.HasEventStreams); + BeginDeferredFrameLogs(); + response = await ProcessFrameAsync( + frame, + BeforeDispatchAsync, + rejectForCapacity: false).ConfigureAwait(false); + } + catch (OperationCanceledException) when (frameToken.IsCancellationRequested) + { + // Keep the transport's strict one-frame/one-writer contract. HTTP + // observes its own terminal reason and aborts/finalizes the response + // when the per-request lifetime expires (#4546). + // transport の frame/writer 対応を維持する。request lifetime 期限切れ時は + // HTTP 側が terminal reason を観測して response を abort/finalize する。 + response = null; + } + finally + { + _currentDetachedIsolatedActions.Value = previousDetachedIsolatedActions; + _currentRequestToken.Value = CancellationToken.None; + _canAwaitClientResponses.Value = false; + _currentOutOfBandFrameWriter.Value = null; + } + + // Malformed/unauthorized frames can return before normal dispatch. Start their + // predecessor wait here so such a frame cannot collapse a protocol barrier. + // malformed / unauthorized frame が dispatch 前に return しても protocol + // barrier を消してしまわないよう、未開始ならここで predecessor を待つ。 + if (!predecessorTask.IsValueCreated) + { + try + { + await predecessorTask.Value.WaitAsync(frameToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (frameToken.IsCancellationRequested) + { + // A canceled frame no longer needs protocol ordering, but its + // request-scoped writer still owns mandatory response cleanup. + // cancel 済み frame は protocol ordering を待たず、対応 writer + // による必須 cleanup だけを完了させる (#4546)。 + response = null; + } + } + + await WriteTransportFrameResponseAsync(writeResponseAsync, response).ConfigureAwait(false); + } + finally + { + var retainedWork = detachedIsolatedActions.IsEmpty + ? Task.CompletedTask + : ObserveDetachedIsolatedActionsAsync(detachedIsolatedActions.ToArray()); + transportFrame.CompleteResourceRetentionWhen(retainedWork); + Interlocked.Decrement(ref _acceptedConcurrentFrameCount); + admissionGate.Release(); + + // A canceled or timed-out isolated action may still be unwinding + // durable writer cleanup after its response has been sent. Release + // frame admission and the transport resource callback first, then keep + // the outer request task attached to that cleanup so EOF's bounded + // drain cannot return while the action is restoring database state. + // cancel / timeout 応答後も isolated action が永続 writer cleanup を + // unwind 中の場合がある。frame admission と transport resource callback + // を先に解放し、その後 outer request task を cleanup に接続して、EOF の + // bounded drain が database 復元中に戻らないようにする。 + await retainedWork.ConfigureAwait(false); + } + }, CancellationToken.None); + } + catch + { + transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); + Interlocked.Decrement(ref _acceptedConcurrentFrameCount); + admissionGate.Release(); + throw; + } + tasks.Add(requestTask); + if (isProtocolBarrier) + protocolBarrier = requestTask; + await requestTaskStarted.Task.ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (loopToken.IsCancellationRequested) + { + // Every loop exit, including cancellation during inline control/overload writes, + // reaches the bounded drain in finally (#4543). + } + finally + { + try + { + await DrainInFlightTasksAsync( + tasks, + InFlightDrainGracePeriod, + InFlightPostCancelGracePeriod, + externalCancellationToken, + terminalTransportWriteTask).ConfigureAwait(false); + } + finally + { + // The bounded EOF drain can intentionally leave late request tasks running. Those + // tasks can still own the write gate or reach the stdio writer until their finally + // blocks run. Publish that aggregate even if draining itself exits unexpectedly, + // then clean up the gates only after every accepted task is done (#3999, #4543). + // bounded EOF drain は late request task を残すことがある。finally が走るまで gate や + // stdio writer を使い得るため、drain 自体が異常終了しても aggregate を公開し、全 + // accepted task 完了後に gate を dispose する (#3999, #4543)。 + var transportWork = BuildDrainOperationsTask(tasks, terminalTransportWriteTask); + if (transport is StdioMcpTransport stdioTransport) + stdioTransport.DeferDisposalUntil(transportWork); + _ = DisposeConcurrentLoopGatesAfterAsync(transportWork, writeGate, admissionGate); + } + } + CommandErrorWriter.WriteStderr("[cdidx-mcp] Server stopped. Restart `cdidx mcp` when your client reconnects."); + } + + private static async Task DisposeConcurrentLoopGatesAfterAsync( + Task transportWork, + SemaphoreSlim writeGate, + SemaphoreSlim admissionGate) + { + try + { + await transportWork.ConfigureAwait(false); + } + catch + { + // Request faults are reported by the bounded drain; gate cleanup must still run. + } + finally + { + writeGate.Dispose(); + admissionGate.Dispose(); + } + } + + private static async Task ObserveDetachedIsolatedActionsAsync(Task[] actions) + { + try + { + await Task.WhenAll(actions).ConfigureAwait(false); + } + catch + { + // Dispatch cleanup observes each action and owns its diagnostics. This aggregate is + // only a transport resource-lifetime signal and must always settle successfully. + // 各 action の例外と診断は dispatch cleanup が所有する。この aggregate は transport + // resource lifetime の signal に限るため、常に正常完了させる。 + foreach (var action in actions) + { + if (action.IsFaulted) + _ = action.Exception; + } + } + } + + internal static int PruneCompletedRequestTasks(List tasks) + { + var removed = 0; + for (var i = tasks.Count - 1; i >= 0; i--) + { + var task = tasks[i]; + if (!task.IsCompleted) + continue; + + ObserveCompletedRequestTask(task); + tasks.RemoveAt(i); + removed++; + } + + return removed; + } + + private static void ObserveCompletedRequestTask(Task task) + { + if (!task.IsFaulted) + return; + + try + { + task.GetAwaiter().GetResult(); + } + catch (Exception ex) + { + CommandErrorWriter.WriteStderr($"[cdidx-mcp] In-flight request ended during transport teardown ({ex.GetType().Name})."); + } + } + + private static async Task AwaitProtocolPredecessorsAsync( + IReadOnlyCollection predecessors, + CancellationToken cancellationToken) + { + if (predecessors.Count == 0) + return; + + try + { + await Task.WhenAll(predecessors).WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // A predecessor owns its own wire response and is observed by task pruning. An + // unrelated fault must not permanently wedge the ordered session lane (#4536). + // predecessor の fault は個別 response と task pruning で観測する。無関係な fault + // により ordered session lane を永続停止させない (#4536)。 + } + } + + private async Task WriteTerminalProtocolErrorAsync( + SemaphoreSlim? writeGate, + IMcpTransport transport, + string response, + CancellationToken cancellationToken) + { + var gateAcquired = false; + try + { + if (writeGate is not null) + { + await writeGate.WaitAsync(cancellationToken).ConfigureAwait(false); + gateAcquired = true; + } + + await WriteFrameSafelyAsync(transport, response, cancellationToken).ConfigureAwait(false); + FlushDeferredFrameLogs(); + } + finally + { + if (gateAcquired) + writeGate!.Release(); + } + } + + internal async Task DrainInFlightTasksAsync( + List tasks, + TimeSpan gracePeriod, + TimeSpan postCancelGracePeriod, + CancellationToken externalCancellationToken = default, + Task? terminalTransportWriteTask = null) + { + PruneCompletedRequestTasks(tasks); + var shutdownCancellationTask = GetShutdownCancellationTask(); + var drainOperations = BuildDrainOperationsTask(tasks, terminalTransportWriteTask); + + // A shutdown notification may already have started cancellation before EOF reached this + // method. In that case the post-cancel deadline begins immediately and includes callback + // completion; running another pre-cancel grace window would extend teardown incorrectly. + // shutdown notification が EOF より先に cancellation を開始済みなら、callback 完了も + // post-cancel deadline に含め、pre-cancel grace を重ねない (#4543)。 + if (shutdownCancellationTask is not null) + { + await AwaitPostCancellationDrainAsync( + tasks, + drainOperations, + terminalTransportWriteTask, + shutdownCancellationTask, + postCancelGracePeriod, + externalCancellationToken).ConfigureAwait(false); + return; + } + + if (drainOperations.IsCompleted) + { + await ObserveCompletedDrainAndShutdownAsync( + tasks, + drainOperations, + terminalTransportWriteTask, + postCancelGracePeriod, + externalCancellationToken).ConfigureAwait(false); + return; + } + + var graceDelay = Task.Delay(gracePeriod, externalCancellationToken); + var completed = await Task.WhenAny(drainOperations, graceDelay).ConfigureAwait(false); + if (completed == drainOperations) + { + await ObserveCompletedDrainAndShutdownAsync( + tasks, + drainOperations, + terminalTransportWriteTask, + postCancelGracePeriod, + externalCancellationToken).ConfigureAwait(false); + return; + } + if (graceDelay.IsCanceled) + { + ObserveLateInFlightTasks(drainOperations); + return; + } + + PruneCompletedRequestTasks(tasks); + if (tasks.Count > 0) + { + CommandErrorWriter.WriteStderr( + $"[cdidx-mcp] Transport teardown has {tasks.Count} in-flight request(s); cancelling after {gracePeriod.TotalMilliseconds:0}ms grace period."); + } + if (terminalTransportWriteTask is { IsCompleted: false }) + { + CommandErrorWriter.WriteStderr( + $"[cdidx-mcp] Transport response/completion write is still pending after {gracePeriod.TotalMilliseconds:0}ms grace period; cancelling transport teardown."); + } + + shutdownCancellationTask = RequestShutdownCancellation(); + await AwaitPostCancellationDrainAsync( + tasks, + drainOperations, + terminalTransportWriteTask, + shutdownCancellationTask, + postCancelGracePeriod, + externalCancellationToken).ConfigureAwait(false); + } + + private async Task ObserveCompletedDrainAndShutdownAsync( + List tasks, + Task drainOperations, + Task? terminalTransportWriteTask, + TimeSpan postCancelGracePeriod, + CancellationToken externalCancellationToken) + { + await ObserveInFlightTasksAsync(drainOperations).ConfigureAwait(false); + + // A queued shutdown frame can start cancellation while the request drain is completing. + // Re-read the task after all accepted work has finished; the original snapshot may have + // been null even though a slow cancellation callback is now running (#4543). + // queued shutdown frame は request drain 完了直前に cancellation を開始できるため、accepted + // work 完了後に task を再取得する。初回 snapshot が null でも slow callback が実行中の + // race を bounded post-cancel deadline へ含める (#4543)。 + var shutdownCancellationTask = GetShutdownCancellationTask(); + if (shutdownCancellationTask is null) + return; + + await AwaitPostCancellationDrainAsync( + tasks, + drainOperations, + terminalTransportWriteTask, + shutdownCancellationTask, + postCancelGracePeriod, + externalCancellationToken).ConfigureAwait(false); + } + + private static Task BuildDrainOperationsTask(IReadOnlyCollection tasks, Task? terminalTransportWriteTask) + { + if (terminalTransportWriteTask is null) + return tasks.Count == 0 ? Task.CompletedTask : Task.WhenAll(tasks); + + var operations = new Task[tasks.Count + 1]; + var operationIndex = 0; + foreach (var task in tasks) + operations[operationIndex++] = task; + operations[^1] = terminalTransportWriteTask; + return Task.WhenAll(operations); + } + + private async Task AwaitPostCancellationDrainAsync( + List tasks, + Task drainOperations, + Task? terminalTransportWriteTask, + Task shutdownCancellationTask, + TimeSpan postCancelGracePeriod, + CancellationToken externalCancellationToken) + { + // Internal shutdown cancels the linked loop token. Use the original caller token so it + // cannot collapse this deadline, while Ctrl+C/SIGTERM/transport cancellation can still + // interrupt it (#3400, #4543). + // internal shutdown では post-cancel deadline を潰さず、外部 cancellation では中断可能にする。 + var postCancelWork = Task.WhenAll(drainOperations, shutdownCancellationTask); + var postCancelDelay = Task.Delay(postCancelGracePeriod, externalCancellationToken); + var completed = await Task.WhenAny(postCancelWork, postCancelDelay).ConfigureAwait(false); + if (completed == postCancelWork) + { + await ObserveInFlightTasksAsync(drainOperations).ConfigureAwait(false); + _ = shutdownCancellationTask.Exception; + return; + } + if (postCancelDelay.IsCanceled) + { + ObserveLateInFlightTasks(postCancelWork); + return; + } + + PruneCompletedRequestTasks(tasks); + if (tasks.Count > 0) + { + CommandErrorWriter.WriteStderr( + $"[cdidx-mcp] Transport teardown final deadline expired with {tasks.Count} in-flight request(s) remaining after {postCancelGracePeriod.TotalMilliseconds:0}ms post-cancel grace period."); + } + if (terminalTransportWriteTask is { IsCompleted: false }) + { + CommandErrorWriter.WriteStderr( + $"[cdidx-mcp] Transport response/completion write is still pending after {postCancelGracePeriod.TotalMilliseconds:0}ms post-cancel grace period."); + } + if (!shutdownCancellationTask.IsCompleted) + { + CommandErrorWriter.WriteStderr( + $"[cdidx-mcp] Shutdown cancellation callbacks are still running after {postCancelGracePeriod.TotalMilliseconds:0}ms post-cancel grace period."); + } + + // Use an uncancelled observer so late faults are still observed after the bounded, + // client-visible drain window (#3774, #4543). + // bounded drain window 後の late fault も未キャンセル observer で観測する。 + ObserveLateInFlightTasks(postCancelWork); + } + + private Task? GetShutdownCancellationTask() + { + lock (_shutdownCancellationGate) + return _shutdownCancellationTask; + } + + private Task RequestShutdownCancellation() + { + TaskCompletionSource completion; + lock (_shutdownCancellationGate) + { + if (_shutdownCancellationTask is not null) + return _shutdownCancellationTask; + + completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _shutdownCancellationTask = completion.Task; + } + + _ = CompleteShutdownCancellationAsync(completion); + return completion.Task; + } + + private async Task CompleteShutdownCancellationAsync(TaskCompletionSource completion) + { + try + { + await _shutdownCts.CancelAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // Disposal won the race; cancellation can no longer be requested. + } + catch (Exception ex) + { + try + { + CommandErrorWriter.WriteStderr( + $"[cdidx-mcp] Shutdown cancellation callback failed during transport teardown ({ex.GetType().Name})."); + } + catch + { + // Diagnostics must never abort bounded teardown. + } + } + finally + { + completion.TrySetResult(); + } + } + + private static void ObserveLateInFlightTasks(Task tasks) + => _ = tasks.ContinueWith(task => + { + _ = task.Exception; + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + + private static async Task ObserveInFlightTasksAsync(Task tasks) + { + try + { + await tasks.ConfigureAwait(false); + } + catch (Exception ex) + { + CommandErrorWriter.WriteStderr($"[cdidx-mcp] In-flight request ended during transport teardown ({ex.GetType().Name})."); + } + } + + /// + /// Process one MCP JSON-RPC line and write any response to the provided writer. Kept as a + /// thin wrapper around so existing tests that drive a + /// directly stay source-compatible after the #1558 transport refactor. + /// 1 行分の MCP JSON-RPC を処理して writer に書き込む薄いラッパ。#1558 のトランスポート抽象化後も + /// 既存テストがソース互換となるよう、 をそのまま呼び出す。 + /// + internal async Task ProcessLineAsync(string line, TextWriter writer) + { + BeginDeferredFrameLogs(); + var response = await ProcessFrameAsync(line).ConfigureAwait(false); + if (response != null) + { + try + { + await _textWriterGate.WaitAsync().ConfigureAwait(false); + try + { + await WriteJsonLineAsync(writer, response).ConfigureAwait(false); + FlushDeferredFrameLogs(); + } + finally + { + _textWriterGate.Release(); + } + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) + { + WriteMcpLogLine(BuildResponseWriteErrorLog(DiagnosticRedactor.FormatExceptionMessage(ex))); + FlushDeferredFrameLogs(); + } + } + } + + private static async Task WriteJsonLineAsync(TextWriter writer, string response) + { + await writer.WriteAsync(response).ConfigureAwait(false); + await writer.WriteAsync('\n').ConfigureAwait(false); + await writer.FlushAsync().ConfigureAwait(false); + } + + private static async Task WriteFrameSafelyAsync(IMcpTransport transport, string? response, CancellationToken cancellationToken) + => await WriteFrameSafelyAsync(transport.WriteFrameAsync, response, cancellationToken).ConfigureAwait(false); + + private static async Task WriteFrameSafelyAsync( + Func writeFrameAsync, + string? response, + CancellationToken cancellationToken) + { + try + { + await writeFrameAsync(response, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + WriteMcpLogLine(BuildResponseWriteErrorLog("write operation was canceled")); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or TimeoutException) + { + WriteMcpLogLine(BuildResponseWriteErrorLog(DiagnosticRedactor.FormatExceptionMessage(ex))); + } + } + + private static bool IsServerResponseFrame(string frame) + { + if (!JsonFrameParser.TryParseNode(frame, MaxJsonDepth, out var node, out _)) + return false; + + return node is JsonObject obj + && obj.ContainsKey("id") + && obj["method"] is null + && (obj.ContainsKey("result") || obj.ContainsKey("error")); + } + + private string BuildInvalidUtf8ParseErrorResponse(DecoderFallbackException ex) + { + DeferFrameLog(BuildInvalidUtf8ErrorLog(DiagnosticRedactor.FormatExceptionMessage(ex))); + var errorResponse = CreateErrorResponse(hasId: true, id: null, code: -32700, message: "Parse error: invalid UTF-8 input", + category: McpErrorEnvelope.CategoryParseError, + suggestion: "Send one JSON-RPC 2.0 object per line encoded as valid UTF-8. Reject or re-encode malformed bytes before retrying.", + retrySafe: false); + return errorResponse.ToJsonString(_jsonOptions); + } + + internal static string BuildInvalidUtf8ErrorLog(string detail) + => $"[cdidx-mcp] JSON parse error: invalid UTF-8 input ({detail}). Send one UTF-8 JSON-RPC object per line; reject or re-encode malformed bytes before retrying."; + + private string BuildOversizedLineErrorResponse(BoundedLineLengthException ex) + => BuildOversizedLineErrorResponse(ex.CharactersRead, ex.Utf8BytesRead); + + private string BuildOversizedLineErrorResponse(int charactersRead, int utf8BytesRead) + { + DeferFrameLog(BuildOversizedMessageLog(charactersRead, utf8BytesRead)); + var errorResponse = CreateErrorResponse(hasId: true, id: null, code: -32700, message: "Message too large", + category: McpErrorEnvelope.CategoryMessageTooLarge, + suggestion: $"JSON-RPC frame exceeds the {MaxLineCharacterCount} character or {MaxLineByteLength} byte cap. Split the request into smaller calls or use `batch_query` with smaller slots.", + retrySafe: false); + return errorResponse.ToJsonString(_jsonOptions); + } + + /// + /// Process one MCP JSON-RPC frame and return the wire-ready response string (or null when + /// the request was a notification or otherwise yields no response). This synchronous wrapper + /// is retained for compatibility tests and legacy in-process callers only; transports and + /// request loops should call so cancellation and shutdown can + /// flow without sync-over-async blocking (#3770). + /// 1 フレーム分の MCP JSON-RPC を処理し、ワイヤー応答文字列を返す(通知などで応答なしの場合は null)。 + /// この同期ラッパは互換テストと legacy in-process 呼び出し専用に残す。transport と request loop は + /// sync-over-async blocking を避けるため を await する (#3770)。 + /// + internal string? ProcessFrame(string line) + // Synchronous callers are compatibility entry points for tests and non-async hosts; + // transport loops use ProcessFrameAsync directly so request handling stays async. + => ProcessFrameAsync(line).GetAwaiter().GetResult(); + + internal Task ProcessFrameAsync(string line) + => ProcessFrameAsync(line, beforeDispatchAsync: null, rejectForCapacity: false); + + private async Task ProcessFrameAsync( + string line, + Func? beforeDispatchAsync, + bool rejectForCapacity, + CapacityRejectedFrameRegistrations? capacityRejectedRegistrations = null) + { + if (string.IsNullOrWhiteSpace(line)) + return null; + + // Reject oversized messages to prevent memory exhaustion + // メモリ枯渇を防ぐため巨大メッセージを拒否 + var byteLength = Encoding.UTF8.GetByteCount(line); + if (line.Length > MaxLineCharacterCount || byteLength > MaxLineByteLength) + return BuildOversizedLineErrorResponse(line.Length, byteLength); + + JsonNode? request = null; + var responseHasId = true; + JsonNode? responseId = null; + IDisposable? frameCorrelationScope = null; + var deferredInitializeCommits = new DeferredInitializeCommits(); + try + { + request = JsonFrameParser.ParseNode(line, MaxJsonDepth); + if (request == null) + return CreateExpectedJsonObjectErrorResponse().ToJsonString(_jsonOptions); + + if (TryCompletePendingClientRequest(request)) + return null; + + capacityRejectedRegistrations?.Register(request); + ExtractResponseId(request, out responseHasId, out responseId); + // A batch frame has no single JSON-RPC id. Invalid ids and malformed scalar frames + // also use id:null only for the JSON-RPC error response; that wire fallback must not + // be mistaken for an explicit null request id in telemetry. Batch items establish + // their own valid-id contexts in HandleMessageAsync. + // batch frame 自体には単一の JSON-RPC id がない。invalid id や scalar frame の + // id:null は error response 専用で、telemetry 上の明示 null id と混同しない。 + // batch item は HandleMessageAsync で valid id ごとの context を作る。 + var frameHasRequestId = request is JsonObject requestObject + && TryGetRequestId(requestObject, out var requestObjectHasId, out _) + && requestObjectHasId; + var frameHasCorrelation = responseHasId && request is not JsonArray; + if (frameHasCorrelation && CurrentCorrelationContext.Value is null) + frameCorrelationScope = BeginRequestCorrelation(responseId, frameHasRequestId); + using var activity = StartMcpActivity(request, frameHasRequestId, responseId); + var response = await HandleMessageAsync( + request, + isolateRequestDb: true, + beforeDispatchAsync, + rejectForCapacity, + queuedBatchRegistration: null, + deferredInitializeCommits).ConfigureAwait(false); + activity?.SetTag("rpc.result", response is null ? "notification" : "response"); + if (response is null) + return null; + + var serialized = SerializeResponseOrFallback( + response, + responseHasId, + responseId, + out var serializedOriginalResponse); + if (serializedOriginalResponse) + { + foreach (var state in deferredInitializeCommits.GetIncludedStates(response)) + CommitInitializeState(state); + } + + return serialized; + } + catch (JsonException ex) + { + // Parse error / パースエラー + DeferFrameLog(BuildJsonParseErrorLog(JsonFrameParser.FormatExceptionDetail(ex))); + var errorResponse = CreateErrorResponse(hasId: true, id: null, code: -32700, message: "Parse error", + category: McpErrorEnvelope.CategoryParseError, + suggestion: $"For MCP stdio, send one UTF-8 JSON-RPC 2.0 object per LF-delimited line with nesting depth <= {MaxJsonDepth}. Do not send LSP Content-Length framing.", + retrySafe: false); + return errorResponse.ToJsonString(_jsonOptions); + } + catch (Exception ex) + { + // Stderr keeps the full message for local diagnostics, but the + // wire response only carries the exception type so SQLite-style + // "near 'foo': syntax error" detail or other content-bearing + // strings cannot leak to the JSON-RPC client (#1530). + // stderr には診断用に詳細を残すが、ネットワークに出るレスポンスには + // 例外型のみを返し、SQLite の "near 'foo': syntax error" などを通じた + // 内容漏れを防ぐ(#1530)。 + DeferFrameLog(BuildUnhandledLoopErrorLog(DiagnosticRedactor.FormatExceptionMessage(ex))); + var classification = McpErrorEnvelope.ClassifyException(ex); + var errorResponse = CreateErrorResponse(responseHasId, responseId, classification.JsonRpcCode, + BuildSanitizedLoopErrorMessage(ex), + category: classification.Category, + suggestion: classification.Suggestion, + retrySafe: classification.RetrySafe); + return SerializeResponseOrFallback( + errorResponse, + responseHasId, + responseId, + out _); + } + finally + { + frameCorrelationScope?.Dispose(); + } + } + + private static Activity? StartMcpActivity(JsonNode request, bool responseHasId, JsonNode? responseId) + { + var method = request is JsonObject obj ? TryGetStringMember(obj, "method") : null; + var traceParent = TryGetMcpTraceParent(request); + ActivityContext parentContext = default; + if (traceParent != null) + ActivityContext.TryParse(traceParent, traceState: null, out parentContext); + + var activity = parentContext != default + ? CodeIndexTelemetry.ActivitySource.StartActivity("mcp.request", ActivityKind.Server, parentContext) + : CodeIndexTelemetry.ActivitySource.StartActivity("mcp.request", ActivityKind.Server); + activity?.SetTag("rpc.system", "jsonrpc"); + activity?.SetTag("rpc.service", "mcp"); + if (!string.IsNullOrWhiteSpace(method)) + activity?.SetTag("rpc.method", method); + if (responseHasId) + { + var requestId = McpRequestIdTelemetry.Create(responseId); + activity?.SetTag("rpc.request_id", requestId.Token); + activity?.SetTag("rpc.request_id_type", requestId.Type); + activity?.SetTag("rpc.request_id_length", requestId.Length); + } + return activity; + } + + private bool TryCompletePendingClientRequest(JsonNode request) + { + if (request is not JsonObject obj + || !obj.TryGetPropertyValue("id", out var id) + || obj["method"] is not null) + return false; + + if (!TrySerializeRequestId(id, out var serializedId, out _)) + return false; + + var key = serializedId ?? "null"; + if (!_pendingClientRequests.TryRemove(key, out var pending)) + return false; + + if (obj.TryGetPropertyValue("error", out var error) && error is not null) + { + if (!TrySerializeClientResponseError(error, out var serializedError, out var errorBytes)) + { + DeferFrameLog(BuildClientResponseTooLargeLog("error", errorBytes)); + pending.TrySetException(new InvalidOperationException(BuildClientResponseTooLargeMessage(errorBytes))); + } + else + { + pending.TrySetException(new InvalidOperationException(serializedError)); + } + } + else if (!TryCloneClientResponsePayload(obj["result"], out var resultClone, out var resultBytes)) + { + DeferFrameLog(BuildClientResponseTooLargeLog("result", resultBytes)); + pending.TrySetException(new InvalidOperationException(BuildClientResponseTooLargeMessage(resultBytes))); + } + else + { + pending.TrySetResult(resultClone); + } + return true; + } + + internal Task RegisterPendingClientRequestForTests(string id) + { + var key = JsonSerializer.Serialize(id); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_pendingClientRequests.TryAdd(key, pending)) + throw new InvalidOperationException($"Pending MCP client request already registered: {id}"); + return pending.Task; + } + + private async Task SendClientRequestAsync(string method, JsonObject? @params, CancellationToken cancellationToken) + { + if (ClientRequestHandlerForTests is { } handler) + { + if (!TryCloneClientResponsePayload(handler(method, @params), out var handlerClone, out var handlerBytes)) + { + DeferFrameLog(BuildClientResponseTooLargeLog("result", handlerBytes)); + return null; + } + return handlerClone; + } + + var writer = _currentOutOfBandFrameWriter.Value; + if (writer is null || !_canAwaitClientResponses.Value) + return null; + + var id = "cdidx-" + Interlocked.Increment(ref s_nextClientRequestId).ToString(System.Globalization.CultureInfo.InvariantCulture); + var key = JsonSerializer.Serialize(id); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_pendingClientRequests.TryAdd(key, pending)) + return null; + + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["method"] = method, + }; + if (@params is not null) + request["params"] = @params; + + using var timeoutScope = OperationTimeoutScope.Create( + OperationTimeoutCategories.McpClientRequest, + TimeSpan.FromSeconds(10), + cancellationToken); + using var cancellationRegistration = timeoutScope.Token.Register(static state => + { + var tuple = ((McpServer server, string key, TaskCompletionSource pending))state!; + if (tuple.server._pendingClientRequests.TryRemove(tuple.key, out var _)) + tuple.pending.TrySetCanceled(); + }, (this, key, pending)); + + try + { + await writer(request.ToJsonString(_jsonOptions), timeoutScope.Token).ConfigureAwait(false); + return await pending.Task.ConfigureAwait(false); + } + catch (InvalidOperationException) + { + return null; + } + catch (OperationCanceledException) + { + return null; + } + finally + { + _pendingClientRequests.TryRemove(key, out var _); + } + } + + internal bool TryCloneClientResponsePayloadForTests(JsonNode? payload, out JsonNode? clone, out int bytesWritten) + => TryCloneClientResponsePayload(payload, out clone, out bytesWritten); + + internal bool TrySerializeClientResponseErrorForTests(JsonNode error, out string? serialized, out int bytesWritten) + => TrySerializeClientResponseError(error, out serialized, out bytesWritten); + + private bool TryCloneClientResponsePayload(JsonNode? payload, out JsonNode? clone, out int bytesWritten) + { + clone = null; + bytesWritten = 0; + if (payload is null) + return true; + + if (!TryMeasureJsonUtf8BytesWithinLimit(payload, _jsonOptions, MaxClientResponseJsonBytes, out bytesWritten)) + return false; + + clone = McpJsonNode.Clone(payload); + return true; + } + + private bool TrySerializeClientResponseError(JsonNode error, out string? serialized, out int bytesWritten) + => TrySerializeJsonNodeWithinByteLimit(error, _jsonOptions, MaxClientResponseJsonBytes, captureSerialized: true, out serialized, out bytesWritten); + + private static string? TryGetMcpTraceParent(JsonNode request) + { + if (request is not JsonObject obj || + obj["params"] is not JsonObject parameters || + parameters["_meta"] is not JsonObject meta) + return null; + + if (meta["traceparent"] is not JsonValue valueNode || + !valueNode.TryGetValue(out var value)) + return null; + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private string SerializeResponseOrFallback( + JsonNode response, + bool hasId, + JsonNode? id, + out bool serializedOriginalResponse) + { + serializedOriginalResponse = false; + try + { + var responseLimit = GetMaxResponseBytes(); + if (_usesDefaultResponseSerializer) + { + if (!TrySerializeJsonNodeWithinByteLimit(response, _jsonOptions, responseLimit, captureSerialized: true, out var boundedSerialized, out var boundedResponseBytes)) + return CreateResponseTooLargeError(hasId, id, boundedResponseBytes, responseLimit, actualBytesExact: false).ToJsonString(_jsonOptions); + + serializedOriginalResponse = true; + return boundedSerialized!; + } + + var serialized = _serializeResponse(response); + var responseBytes = Encoding.UTF8.GetByteCount(serialized); + if (responseBytes <= responseLimit) + { + serializedOriginalResponse = true; + return serialized; + } + + return CreateResponseTooLargeError(hasId, id, responseBytes, responseLimit).ToJsonString(_jsonOptions); + } + catch (Exception ex) + { + DeferFrameLog(BuildResponseSerializationErrorLog(DiagnosticRedactor.FormatExceptionMessage(ex))); + return BuildMinimalInternalErrorResponse(hasId, id, ex); + } + } + + private void DeferFrameLog(string message) + => DeferFrameLog(() => WriteMcpLogLine(message)); + + private void DeferFrameLog(Action writeLog) + { + var context = CurrentCorrelationContext.Value; + var logs = _deferredFrameLogs.Value; + if (logs is null) + { + WriteWithCorrelationContext(context, writeLog); + return; + } + + logs.Add(() => WriteWithCorrelationContext(context, writeLog)); + } + + private static void WriteWithCorrelationContext(RequestCorrelationContext? context, Action writeLog) + { + var previous = CurrentCorrelationContext.Value; + try + { + CurrentCorrelationContext.Value = context; + writeLog(); + } + finally + { + CurrentCorrelationContext.Value = previous; + } + } + + private void BeginDeferredFrameLogs() + => _deferredFrameLogs.Value = new DeferredFrameLogBuffer(); + + private void FlushDeferredFrameLogs() + { + var logs = _deferredFrameLogs.Value; + if (logs is null) + return; + + _deferredFrameLogs.Value = null; + logs.ForwardTo(static log => log()); + } + + private sealed class DeferredFrameLogBuffer + { + private readonly object _gate = new(); + private List? _logs = []; + private Action? _lateLogForwarder; + + public void Add(Action log) + { + Action? lateLogForwarder; + lock (_gate) + { + if (_logs is not null) + { + _logs.Add(log); + return; + } + + lateLogForwarder = _lateLogForwarder; + } + + (lateLogForwarder ?? (static lateLog => lateLog()))(log); + } + + public void ForwardTo(Action lateLogForwarder) + { + lock (_gate) + { + if (_logs is null) + return; + + foreach (var log in _logs) + lateLogForwarder(log); + _logs = null; + _lateLogForwarder = lateLogForwarder; + } + } + } + + private sealed class CapacityRejectedFrameRegistrations : IDisposable + { + private readonly McpServer _owner; + private readonly HashSet _requestKeys = new(StringComparer.Ordinal); + private readonly List _registrations = []; + private bool _disposed; + + internal CapacityRejectedFrameRegistrations(McpServer owner) + { + _owner = owner; + } + + internal void Register(JsonNode request) + { + if (request is JsonArray batch) + { + foreach (var item in batch) + RegisterItem(item); + return; + } + + RegisterItem(request); + } + + private void RegisterItem(JsonNode? item) + { + if (!BatchItemRequiresResponse(item, out var responseId) + || SerializeRequestId(responseId) is not { } requestKey + || !_requestKeys.Add(requestKey)) + { + return; + } + + if (_owner.TryRegisterQueuedBatchRequest(requestKey) is { } registration) + _registrations.Add(registration); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + foreach (var registration in _registrations) + registration.DisposeIfUnclaimed(); + } + } + + private sealed class QueuedBatchRequestRegistration + { + private readonly McpServer _owner; + private readonly string _requestKey; + private readonly CancellationTokenSource _cancellation; + // 0 = queued, 1 = claimed by normal dispatch, 2 = cleaned before dispatch. + private int _state; + + internal QueuedBatchRequestRegistration( + McpServer owner, + string requestKey, + CancellationTokenSource cancellation) + { + _owner = owner; + _requestKey = requestKey; + _cancellation = cancellation; + } + + internal CancellationToken Token => _cancellation.Token; + + internal bool TryCancel() + { + try + { + _cancellation.Cancel(); + return true; + } + catch (ObjectDisposedException) + { + // Dispatch won the move into `_activeRequests`; the caller will retry there. + return false; + } + } + + internal bool TryClaim() + { + if (Interlocked.CompareExchange(ref _state, 1, 0) != 0) + return false; + RemoveAndDispose(); + return true; + } + + internal void DisposeIfUnclaimed() + { + if (Interlocked.CompareExchange(ref _state, 2, 0) != 0) + return; + RemoveAndDispose(); + } + + private void RemoveAndDispose() + { + if (_owner._queuedBatchRequests.TryGetValue(_requestKey, out var current) + && ReferenceEquals(current, this)) + { + _owner._queuedBatchRequests.TryRemove(_requestKey, out _); + } + _cancellation.Dispose(); + } + } + + private static void WriteMcpLogLine(string message) + { + var line = AddCorrelationPrefix(message); + try + { + CommandErrorWriter.WriteStderr(line); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) + { + // Best-effort diagnostics: a closed redirected stderr must not break the MCP request. + } + GlobalToolLog.Info(line); + } + + private static string AddCorrelationPrefix(string message) + { + var context = CurrentCorrelationContext.Value; + if (context is null) + return message; + + var requestId = context.TelemetryRequestId; + var prefix = requestId is { } presentRequestId + ? $"[rid={presentRequestId.Token} rid_type={presentRequestId.Type} rid_length={presentRequestId.Length.ToString(CultureInfo.InvariantCulture)} cid={context.CorrelationId}] " + : $"[cid={context.CorrelationId}] "; + return message.StartsWith("[cdidx-mcp] ", StringComparison.Ordinal) + ? "[cdidx-mcp] " + prefix + message["[cdidx-mcp] ".Length..] + : prefix + message; + } + + private static void ExtractResponseId(JsonNode request, out bool hasId, out JsonNode? id) + { + if (request is JsonObject obj) + { + if (TryGetRequestId(obj, out hasId, out var requestId)) + id = McpJsonNode.Clone(requestId); + else + id = null; + return; + } + + // For malformed non-object JSON values, JSON-RPC error responses should still carry + // id:null instead of disappearing when handling or serialization fails. + hasId = true; + id = null; + } + + private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id, Exception ex) + { + var message = $"Internal error while serializing MCP response ({ex.GetType().Name}). See cdidx server stderr for details."; + var builder = new StringBuilder("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":"); + builder.Append(JsonSerializer.Serialize(message)); + AppendMinimalCorrelationData(builder); + builder.Append('}'); + if (hasId) + { + builder.Append(",\"id\":"); + builder.Append(id is null ? "null" : id.ToJsonString()); + } + builder.Append('}'); + return builder.ToString(); + } + + private static void AppendMinimalCorrelationData(StringBuilder builder) + { + var context = CurrentCorrelationContext.Value; + if (context is null) + return; + + builder.Append(",\"data\":{\"correlation_id\":"); + builder.Append(JsonSerializer.Serialize(context.CorrelationId)); + if (context.WireRequestId != null) + { + builder.Append(",\"request_id\":"); + builder.Append(JsonSerializer.Serialize(context.WireRequestId)); + } + builder.Append('}'); + } + +} diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 2fcef3836..7fbbac798 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -480,1650 +480,6 @@ internal TimeSpan InFlightPostCancelGracePeriod /// のラッパとして残す。SIGINT (Ctrl+C) と SIGTERM をループキャンセルに変換し、stdin が閉じる /// まで固まる旧挙動を解消する(systemd / launchd / supervisord から graceful shutdown 可能に, #1573)。 /// - public async Task RunAsync() - { - await using var transport = new StdioMcpTransport(StdioBufferSize); - using var cts = new CancellationTokenSource(); - using (RegisterShutdownHandlers(cts)) - { - await RunAsync(transport, cts.Token).ConfigureAwait(false); - } - } - - /// - /// Register cross-platform SIGINT (Ctrl+C) and SIGTERM handlers that cancel - /// so orchestrator-driven shutdowns drain the loop cleanly instead of leaving the MCP process - /// hung on stdin or force-killed mid-iteration (#1573). The returned IDisposable removes the - /// handlers; dispose it before disposing the CTS to avoid races between a late signal and CTS - /// teardown. - /// SIGINT (Ctrl+C) と SIGTERM を `cts` のキャンセルに変換するクロスプラットフォームハンドラを登録する - /// (#1573)。返り値の IDisposable でハンドラを解除する。late signal と CTS 破棄の競合を避けるため、 - /// CTS の Dispose より先にこれを Dispose する。 - /// - internal static IDisposable RegisterShutdownHandlers(CancellationTokenSource cts) - { - ArgumentNullException.ThrowIfNull(cts); - - ConsoleCancelEventHandler cancelHandler = (_, e) => - { - if (cts.IsCancellationRequested) - return; - // Honour the signal without letting the .NET runtime terminate the process before - // the loop has a chance to drain and dispose the shared DbContext. - // .NET runtime の即時終了を抑え、ループが DbContext を片付ける猶予を確保する。 - e.Cancel = true; - try { cts.Cancel(); } - catch (ObjectDisposedException) { /* signal raced disposal — nothing to cancel. */ } - }; - Console.CancelKeyPress += cancelHandler; - - PosixSignalRegistration? sigtermRegistration = null; - try - { - sigtermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => - { - if (cts.IsCancellationRequested) - return; - ctx.Cancel = true; - try { cts.Cancel(); } - catch (ObjectDisposedException) { /* see CancelKeyPress branch. */ } - }); - } - catch (PlatformNotSupportedException) - { - // PosixSignal.SIGTERM is supported on net8.0 across Windows/Linux/macOS, but a future - // niche runtime might not implement it. Console.CancelKeyPress still covers Ctrl+C - // everywhere, so degrade silently rather than refusing to start. - // .NET 8 では SIGTERM がクロスプラットフォーム対応だが、将来の特殊ランタイムで未対応の - // 可能性に備え、Console.CancelKeyPress による Ctrl+C カバレッジを残してサイレントに縮退する。 - } - - return new ShutdownHandlerRegistration(cancelHandler, sigtermRegistration); - } - - private sealed class ShutdownHandlerRegistration : IDisposable - { - private ConsoleCancelEventHandler? _cancelHandler; - private PosixSignalRegistration? _sigterm; - - public ShutdownHandlerRegistration(ConsoleCancelEventHandler cancelHandler, PosixSignalRegistration? sigterm) - { - _cancelHandler = cancelHandler; - _sigterm = sigterm; - } - - public void Dispose() - { - var handler = Interlocked.Exchange(ref _cancelHandler, null); - if (handler != null) - Console.CancelKeyPress -= handler; - var sigterm = Interlocked.Exchange(ref _sigterm, null); - sigterm?.Dispose(); - } - } - - /// - /// Run the MCP server loop on the supplied transport (issue #1558). Base transports use one - /// read followed by one write; concurrent-capable transports bind a response writer to each - /// frame. Notifications write null and end-of-stream terminates the loop. - /// 指定トランスポート上で MCP ループを動かす (issue #1558)。基本 transport は「読み 1 回 → - /// 書き 1 回」、並行対応 transport は frame ごとに response writer を紐付ける。通知は null を - /// 書き、EOS でループを終える。 - /// - internal async Task RunAsync(IMcpTransport transport, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(transport); - _enforceInitializationLifecycle = true; - Volatile.Write( - ref _activeTransportMaxResponseBytes, - transport is IMcpResponseSizeLimitProvider responseLimitProvider - ? responseLimitProvider.MaxResponseFrameBytes - : 0); - - // Link the caller-supplied token (Ctrl+C / HTTP listener stop) with the server-internal - // shutdown signal so `notifications/shutdown` also wakes any pending `ReadFrameAsync`. - // The MCP spec leaves shutdown to the transport, but real deployments need a wire-level - // way to drain in-flight work without killing the process (#1567). - // Ctrl+C 等の外部 token と内部 shutdown signal をリンクし、`notifications/shutdown` でも - // pending な `ReadFrameAsync` を unblock できるようにする (#1567)。 - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdownCts.Token); - var loopToken = linkedCts.Token; - - // Use stderr for logging so stdout stays clean for JSON-RPC - // stdoutをJSON-RPC用にクリーンに保つため、ログはstderrに出力 - ConsoleUi.TryWriteErrorLine($"[cdidx-mcp] Starting MCP server v{_version} (db: {FormatDbPathForLog(_dbPath)}, transport: {transport.Name} @ {transport.Endpoint}, max in-flight: {MaxConcurrency})"); - - if (transport is HttpMcpTransport httpTransport) - { - httpTransport.OutOfBandFrameHandler = (frame, _) => ProcessFrameAsync(frame); - httpTransport.HealthJsonProvider = () => BuildHealthJson(httpTransport); - httpTransport.KeepAliveInterval = _keepAliveInterval; - httpTransport.KeepAliveFrameProvider = BuildKeepAliveNotificationJson; - } - - try - { - if (string.Equals(transport.Name, "stdio", StringComparison.OrdinalIgnoreCase) - || transport is IConcurrentMcpTransport) - { - await RunConcurrentFrameLoopAsync(transport, loopToken, cancellationToken).ConfigureAwait(false); - return; - } - - Task? terminalTransportWriteTask = null; - try - { - while (_running) - { - // The full read/process/write iteration is wrapped in the same cancellation guard so - // a Ctrl+C that lands mid-iteration (e.g. while WriteFrameAsync is flushing) still - // exits the loop cleanly instead of bubbling OperationCanceledException out of the - // server and past ProgramRunner.RunMcpHttp's graceful-shutdown handler. - // Ctrl+C が WriteFrameAsync flush 中に来ても OperationCanceledException を呼び元に - // 漏らさず正常終了するよう、read/process/write 全体を同じ cancellation guard で囲む。 - try - { - var frame = await transport.ReadFrameAsync(loopToken).ConfigureAwait(false); - if (frame == null) - break; // transport closed / トランスポートが閉じられた - - string? response; - try - { - // Hand the per-request token to `WithDbReader` so SQLite work the tool kicks - // off can observe shutdown / client-disconnect cancellation through - // `DbReader.Cancellation` (#1567). - // ツールが起動する SQLite 作業が shutdown / 切断を観測できるよう per-request - // token を `WithDbReader` に渡す (#1567)。 - _currentRequestToken.Value = loopToken; - _currentOutOfBandFrameWriter.Value = transport is IOutOfBandMcpTransport outOfBandTransport - ? (frameToWrite, writeToken) => outOfBandTransport.WriteOutOfBandFrameAsync(frameToWrite, writeToken) - : null; - _canAwaitClientResponses.Value = transport is IOutOfBandMcpTransport - && (transport is not HttpMcpTransport httpResponseTransport || httpResponseTransport.HasEventStreams); - BeginDeferredFrameLogs(); - response = await ProcessFrameAsync(frame).ConfigureAwait(false); - } - finally - { - _currentRequestToken.Value = CancellationToken.None; - _currentOutOfBandFrameWriter.Value = null; - _canAwaitClientResponses.Value = false; - } - - // Internal shutdown cancels `loopToken` to stop reads and request actions, but - // the initiating notification still owns one transport completion (HTTP 204). - // Use only the caller token for that completion; bounded teardown below still - // limits a writer that does not finish (#4543). - // internal shutdown では read/action 用 loopToken を cancel するが、起点の - // notification に対応する transport completion (HTTP 204) は完了させる。 - // write は caller token のみを使い、停止しない writer は下の bounded teardown - // で制限する (#4543)。 - var responseWriteTask = WriteFrameSafelyAsync(transport, response, cancellationToken); - if (!_running) - { - // Do not await an uncooperative base-transport shutdown completion inline: - // the common finally must own its bounded deadline (#4543). - // 応答しない base transport の shutdown completion を inline await せず、 - // common finally の bounded deadline に委ねる (#4543)。 - terminalTransportWriteTask = responseWriteTask; - break; - } - - await responseWriteTask.ConfigureAwait(false); - FlushDeferredFrameLogs(); - - // `notifications/shutdown` flips `_running` inside `HandleMessage`; exit the loop - // immediately so a subsequent slow `ReadFrameAsync` does not extend the lifetime - // of a server that has been asked to stop. - // `notifications/shutdown` が `_running` を倒した直後にループを抜ける (#1567)。 - if (!_running) - break; - } - catch (OperationCanceledException) when (loopToken.IsCancellationRequested) - { - break; - } - catch (DecoderFallbackException ex) - { - BeginDeferredFrameLogs(); - terminalTransportWriteTask = WriteTerminalProtocolErrorAsync( - writeGate: null, - transport, - BuildInvalidUtf8ParseErrorResponse(ex), - cancellationToken); - break; - } - catch (BoundedLineLengthException ex) - { - BeginDeferredFrameLogs(); - terminalTransportWriteTask = WriteTerminalProtocolErrorAsync( - writeGate: null, - transport, - BuildOversizedLineErrorResponse(ex), - cancellationToken); - break; - } - } - } - finally - { - // Base transports have no detached request list, but shutdown cancellation - // callbacks and malformed-input writes still participate in the same bounded - // teardown contract as concurrent transports (#4543). - // base transport に detached request list は無いが、shutdown callback と - // malformed-input write は concurrent transport と同じ bounded teardown - // 契約へ必ず流す (#4543)。 - await DrainInFlightTasksAsync( - [], - InFlightDrainGracePeriod, - InFlightPostCancelGracePeriod, - cancellationToken, - terminalTransportWriteTask).ConfigureAwait(false); - FlushDeferredFrameLogs(); - } - } - finally - { - Volatile.Write(ref _activeTransportMaxResponseBytes, 0); - if (transport is HttpMcpTransport httpTransportToClear) - { - httpTransportToClear.OutOfBandFrameHandler = null; - httpTransportToClear.HealthJsonProvider = null; - httpTransportToClear.KeepAliveInterval = null; - httpTransportToClear.KeepAliveFrameProvider = null; - } - } - - CommandErrorWriter.WriteStderr("[cdidx-mcp] Server stopped. Restart `cdidx mcp` when your client reconnects."); - } - - private async Task RunConcurrentFrameLoopAsync( - IMcpTransport transport, - CancellationToken loopToken, - CancellationToken externalCancellationToken) - { - var writeGate = new SemaphoreSlim(1, 1); - var admissionGate = new SemaphoreSlim(MaxAcceptedConcurrentFrames, MaxAcceptedConcurrentFrames); - var tasks = new List(); - Task protocolBarrier = Task.CompletedTask; - Task? terminalTransportWriteTask = null; - var hasRequestScopedWriters = transport is IConcurrentMcpTransport; - - async Task WriteTransportFrameResponseAsync( - Func writeResponseAsync, - string? response) - { - // Concurrent transports provide one writer per request, so serializing those writers - // behind the base-transport gate lets an unrelated stuck response retain later HTTP - // request resources. Base transports (notably stdio) still require the shared gate. - // concurrent transport は request ごとの writer を持つため、base transport 用 gate - // に直列化すると無関係な stuck response が後続 HTTP resource を保持してしまう。 - // stdio 等の base transport だけ shared gate を維持する (#4546)。 - if (hasRequestScopedWriters) - { - await WriteFrameSafelyAsync( - writeResponseAsync, - response, - externalCancellationToken).ConfigureAwait(false); - FlushDeferredFrameLogs(); - return; - } - - await writeGate.WaitAsync(externalCancellationToken).ConfigureAwait(false); - try - { - await WriteFrameSafelyAsync( - writeResponseAsync, - response, - externalCancellationToken).ConfigureAwait(false); - FlushDeferredFrameLogs(); - } - finally - { - writeGate.Release(); - } - } - - try - { - while (_running) - { - PruneCompletedRequestTasks(tasks); - McpTransportFrame? transportFrame; - try - { - if (transport is IConcurrentMcpTransport concurrentTransport) - { - transportFrame = await concurrentTransport.ReadConcurrentFrameAsync(loopToken).ConfigureAwait(false); - } - else - { - var readFrame = await transport.ReadFrameAsync(loopToken).ConfigureAwait(false); - transportFrame = readFrame is null - ? null - : new McpTransportFrame(readFrame, transport.WriteFrameAsync); - } - } - catch (OperationCanceledException) when (loopToken.IsCancellationRequested) - { - break; - } - catch (DecoderFallbackException ex) - { - BeginDeferredFrameLogs(); - terminalTransportWriteTask = WriteTerminalProtocolErrorAsync( - writeGate, - transport, - BuildInvalidUtf8ParseErrorResponse(ex), - externalCancellationToken); - break; - } - catch (BoundedLineLengthException ex) - { - BeginDeferredFrameLogs(); - terminalTransportWriteTask = WriteTerminalProtocolErrorAsync( - writeGate, - transport, - BuildOversizedLineErrorResponse(ex), - externalCancellationToken); - break; - } - if (transportFrame is null) - break; - var frame = transportFrame.Frame; - var writeResponseAsync = transportFrame.WriteResponseAsync; - var transportRequestToken = transportFrame.RequestCancellationToken; - - if (IsCancellationFrame(frame)) - { - try - { - BeginDeferredFrameLogs(); - var response = await ProcessFrameAsync(frame).ConfigureAwait(false); - await WriteTransportFrameResponseAsync(writeResponseAsync, response).ConfigureAwait(false); - } - finally - { - transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); - } - continue; - } - - if (IsServerResponseFrame(frame)) - { - try - { - BeginDeferredFrameLogs(); - var response = await ProcessFrameAsync(frame).ConfigureAwait(false); - await WriteTransportFrameResponseAsync(writeResponseAsync, response).ConfigureAwait(false); - } - finally - { - transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); - } - continue; - } - - // Admission is deliberately non-blocking: waiting here would prevent a later - // cancellation/client-response frame from being read while execution is saturated. - // Excess ordinary work receives a retry-safe JSON-RPC overload response instead of - // retaining another frame/task/HTTP context without bound (#4536). - // admission は non-blocking にする。ここで待つと execution 飽和中に後続の - // cancellation/client-response frame を読めなくなるため。上限超過 work は task や - // HTTP context を保持し続けず、retry-safe overload response を返す (#4536)。 - if (!admissionGate.Wait(0)) - { - try - { - // Keep every response-bearing id registered until its retry-safe overload - // response has reached the transport. A cancellation before or during that - // write then belongs to this rejected occurrence instead of poisoning a later - // same-id retry (#4536, #4545). - // retry-safe overload 応答が transport へ届くまで response-bearing id を登録する。 - // reject 前または write 中の cancel をこの occurrence に束縛し、同じ id の後続 - // retry へ持ち越さない (#4536, #4545)。 - using var capacityRejectedRegistrations = new CapacityRejectedFrameRegistrations(this); - BeginDeferredFrameLogs(); - var response = await ProcessFrameAsync( - frame, - beforeDispatchAsync: null, - rejectForCapacity: true, - capacityRejectedRegistrations: capacityRejectedRegistrations).ConfigureAwait(false); - await WriteTransportFrameResponseAsync(writeResponseAsync, response).ConfigureAwait(false); - } - finally - { - transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); - } - continue; - } - Interlocked.Increment(ref _acceptedConcurrentFrameCount); - - var requestTaskStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var isProtocolBarrier = IsProtocolOrderingBarrierFrame(frame); - var precedingBarrier = protocolBarrier; - var tasksAcceptedBeforeBarrier = isProtocolBarrier ? tasks.ToArray() : []; - Func awaitPredecessorsAsync = isProtocolBarrier - ? token => AwaitProtocolPredecessorsAsync(tasksAcceptedBeforeBarrier, token) - : token => AwaitProtocolPredecessorsAsync([precedingBarrier], token); - var predecessorTask = new Lazy( - () => awaitPredecessorsAsync(loopToken), - LazyThreadSafetyMode.ExecutionAndPublication); - Task BeforeDispatchAsync(CancellationToken token) - => predecessorTask.Value.WaitAsync(token); - // Accepted frames are bounded independently from executing operations. The request - // registers its id/cancellation state before awaiting protocol predecessors and the - // execution gate, so a cancellation cannot expire while queued (#4536). - // accepted frame と executing operation は別々に上限化する。request は protocol - // predecessor / execution gate を待つ前に id と cancellation state を登録するため、 - // queue 中に cancellation が失効しない (#4536)。 - Task requestTask; - try - { - requestTask = Task.Run(async () => - { - var detachedIsolatedActions = new ConcurrentQueue(); - var previousDetachedIsolatedActions = _currentDetachedIsolatedActions.Value; - try - { - requestTaskStarted.TrySetResult(); - using var frameCts = transportRequestToken.CanBeCanceled - ? CancellationTokenSource.CreateLinkedTokenSource(loopToken, transportRequestToken) - : null; - var frameToken = frameCts?.Token ?? loopToken; - string? response = null; - try - { - _currentDetachedIsolatedActions.Value = detachedIsolatedActions; - _currentRequestToken.Value = frameToken; - _currentOutOfBandFrameWriter.Value = transport is IOutOfBandMcpTransport outOfBandTransport - ? (frameToWrite, writeToken) => outOfBandTransport.WriteOutOfBandFrameAsync(frameToWrite, writeToken) - : string.Equals(transport.Name, "stdio", StringComparison.OrdinalIgnoreCase) - ? async (frameToWrite, writeToken) => - { - await writeGate.WaitAsync(writeToken).ConfigureAwait(false); - try - { - await transport.WriteFrameAsync(frameToWrite, writeToken).ConfigureAwait(false); - } - finally - { - writeGate.Release(); - } - } - : null; - _canAwaitClientResponses.Value = _currentOutOfBandFrameWriter.Value is not null - && (transport is not HttpMcpTransport httpResponseTransport || httpResponseTransport.HasEventStreams); - BeginDeferredFrameLogs(); - response = await ProcessFrameAsync( - frame, - BeforeDispatchAsync, - rejectForCapacity: false).ConfigureAwait(false); - } - catch (OperationCanceledException) when (frameToken.IsCancellationRequested) - { - // Keep the transport's strict one-frame/one-writer contract. HTTP - // observes its own terminal reason and aborts/finalizes the response - // when the per-request lifetime expires (#4546). - // transport の frame/writer 対応を維持する。request lifetime 期限切れ時は - // HTTP 側が terminal reason を観測して response を abort/finalize する。 - response = null; - } - finally - { - _currentDetachedIsolatedActions.Value = previousDetachedIsolatedActions; - _currentRequestToken.Value = CancellationToken.None; - _canAwaitClientResponses.Value = false; - _currentOutOfBandFrameWriter.Value = null; - } - - // Malformed/unauthorized frames can return before normal dispatch. Start their - // predecessor wait here so such a frame cannot collapse a protocol barrier. - // malformed / unauthorized frame が dispatch 前に return しても protocol - // barrier を消してしまわないよう、未開始ならここで predecessor を待つ。 - if (!predecessorTask.IsValueCreated) - { - try - { - await predecessorTask.Value.WaitAsync(frameToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (frameToken.IsCancellationRequested) - { - // A canceled frame no longer needs protocol ordering, but its - // request-scoped writer still owns mandatory response cleanup. - // cancel 済み frame は protocol ordering を待たず、対応 writer - // による必須 cleanup だけを完了させる (#4546)。 - response = null; - } - } - - await WriteTransportFrameResponseAsync(writeResponseAsync, response).ConfigureAwait(false); - } - finally - { - var retainedWork = detachedIsolatedActions.IsEmpty - ? Task.CompletedTask - : ObserveDetachedIsolatedActionsAsync(detachedIsolatedActions.ToArray()); - transportFrame.CompleteResourceRetentionWhen(retainedWork); - Interlocked.Decrement(ref _acceptedConcurrentFrameCount); - admissionGate.Release(); - - // A canceled or timed-out isolated action may still be unwinding - // durable writer cleanup after its response has been sent. Release - // frame admission and the transport resource callback first, then keep - // the outer request task attached to that cleanup so EOF's bounded - // drain cannot return while the action is restoring database state. - // cancel / timeout 応答後も isolated action が永続 writer cleanup を - // unwind 中の場合がある。frame admission と transport resource callback - // を先に解放し、その後 outer request task を cleanup に接続して、EOF の - // bounded drain が database 復元中に戻らないようにする。 - await retainedWork.ConfigureAwait(false); - } - }, CancellationToken.None); - } - catch - { - transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); - Interlocked.Decrement(ref _acceptedConcurrentFrameCount); - admissionGate.Release(); - throw; - } - tasks.Add(requestTask); - if (isProtocolBarrier) - protocolBarrier = requestTask; - await requestTaskStarted.Task.ConfigureAwait(false); - } - } - catch (OperationCanceledException) when (loopToken.IsCancellationRequested) - { - // Every loop exit, including cancellation during inline control/overload writes, - // reaches the bounded drain in finally (#4543). - } - finally - { - try - { - await DrainInFlightTasksAsync( - tasks, - InFlightDrainGracePeriod, - InFlightPostCancelGracePeriod, - externalCancellationToken, - terminalTransportWriteTask).ConfigureAwait(false); - } - finally - { - // The bounded EOF drain can intentionally leave late request tasks running. Those - // tasks can still own the write gate or reach the stdio writer until their finally - // blocks run. Publish that aggregate even if draining itself exits unexpectedly, - // then clean up the gates only after every accepted task is done (#3999, #4543). - // bounded EOF drain は late request task を残すことがある。finally が走るまで gate や - // stdio writer を使い得るため、drain 自体が異常終了しても aggregate を公開し、全 - // accepted task 完了後に gate を dispose する (#3999, #4543)。 - var transportWork = BuildDrainOperationsTask(tasks, terminalTransportWriteTask); - if (transport is StdioMcpTransport stdioTransport) - stdioTransport.DeferDisposalUntil(transportWork); - _ = DisposeConcurrentLoopGatesAfterAsync(transportWork, writeGate, admissionGate); - } - } - CommandErrorWriter.WriteStderr("[cdidx-mcp] Server stopped. Restart `cdidx mcp` when your client reconnects."); - } - - private static async Task DisposeConcurrentLoopGatesAfterAsync( - Task transportWork, - SemaphoreSlim writeGate, - SemaphoreSlim admissionGate) - { - try - { - await transportWork.ConfigureAwait(false); - } - catch - { - // Request faults are reported by the bounded drain; gate cleanup must still run. - } - finally - { - writeGate.Dispose(); - admissionGate.Dispose(); - } - } - - private static async Task ObserveDetachedIsolatedActionsAsync(Task[] actions) - { - try - { - await Task.WhenAll(actions).ConfigureAwait(false); - } - catch - { - // Dispatch cleanup observes each action and owns its diagnostics. This aggregate is - // only a transport resource-lifetime signal and must always settle successfully. - // 各 action の例外と診断は dispatch cleanup が所有する。この aggregate は transport - // resource lifetime の signal に限るため、常に正常完了させる。 - foreach (var action in actions) - { - if (action.IsFaulted) - _ = action.Exception; - } - } - } - - internal static int PruneCompletedRequestTasks(List tasks) - { - var removed = 0; - for (var i = tasks.Count - 1; i >= 0; i--) - { - var task = tasks[i]; - if (!task.IsCompleted) - continue; - - ObserveCompletedRequestTask(task); - tasks.RemoveAt(i); - removed++; - } - - return removed; - } - - private static void ObserveCompletedRequestTask(Task task) - { - if (!task.IsFaulted) - return; - - try - { - task.GetAwaiter().GetResult(); - } - catch (Exception ex) - { - CommandErrorWriter.WriteStderr($"[cdidx-mcp] In-flight request ended during transport teardown ({ex.GetType().Name})."); - } - } - - private static async Task AwaitProtocolPredecessorsAsync( - IReadOnlyCollection predecessors, - CancellationToken cancellationToken) - { - if (predecessors.Count == 0) - return; - - try - { - await Task.WhenAll(predecessors).WaitAsync(cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - // A predecessor owns its own wire response and is observed by task pruning. An - // unrelated fault must not permanently wedge the ordered session lane (#4536). - // predecessor の fault は個別 response と task pruning で観測する。無関係な fault - // により ordered session lane を永続停止させない (#4536)。 - } - } - - private async Task WriteTerminalProtocolErrorAsync( - SemaphoreSlim? writeGate, - IMcpTransport transport, - string response, - CancellationToken cancellationToken) - { - var gateAcquired = false; - try - { - if (writeGate is not null) - { - await writeGate.WaitAsync(cancellationToken).ConfigureAwait(false); - gateAcquired = true; - } - - await WriteFrameSafelyAsync(transport, response, cancellationToken).ConfigureAwait(false); - FlushDeferredFrameLogs(); - } - finally - { - if (gateAcquired) - writeGate!.Release(); - } - } - - internal async Task DrainInFlightTasksAsync( - List tasks, - TimeSpan gracePeriod, - TimeSpan postCancelGracePeriod, - CancellationToken externalCancellationToken = default, - Task? terminalTransportWriteTask = null) - { - PruneCompletedRequestTasks(tasks); - var shutdownCancellationTask = GetShutdownCancellationTask(); - var drainOperations = BuildDrainOperationsTask(tasks, terminalTransportWriteTask); - - // A shutdown notification may already have started cancellation before EOF reached this - // method. In that case the post-cancel deadline begins immediately and includes callback - // completion; running another pre-cancel grace window would extend teardown incorrectly. - // shutdown notification が EOF より先に cancellation を開始済みなら、callback 完了も - // post-cancel deadline に含め、pre-cancel grace を重ねない (#4543)。 - if (shutdownCancellationTask is not null) - { - await AwaitPostCancellationDrainAsync( - tasks, - drainOperations, - terminalTransportWriteTask, - shutdownCancellationTask, - postCancelGracePeriod, - externalCancellationToken).ConfigureAwait(false); - return; - } - - if (drainOperations.IsCompleted) - { - await ObserveCompletedDrainAndShutdownAsync( - tasks, - drainOperations, - terminalTransportWriteTask, - postCancelGracePeriod, - externalCancellationToken).ConfigureAwait(false); - return; - } - - var graceDelay = Task.Delay(gracePeriod, externalCancellationToken); - var completed = await Task.WhenAny(drainOperations, graceDelay).ConfigureAwait(false); - if (completed == drainOperations) - { - await ObserveCompletedDrainAndShutdownAsync( - tasks, - drainOperations, - terminalTransportWriteTask, - postCancelGracePeriod, - externalCancellationToken).ConfigureAwait(false); - return; - } - if (graceDelay.IsCanceled) - { - ObserveLateInFlightTasks(drainOperations); - return; - } - - PruneCompletedRequestTasks(tasks); - if (tasks.Count > 0) - { - CommandErrorWriter.WriteStderr( - $"[cdidx-mcp] Transport teardown has {tasks.Count} in-flight request(s); cancelling after {gracePeriod.TotalMilliseconds:0}ms grace period."); - } - if (terminalTransportWriteTask is { IsCompleted: false }) - { - CommandErrorWriter.WriteStderr( - $"[cdidx-mcp] Transport response/completion write is still pending after {gracePeriod.TotalMilliseconds:0}ms grace period; cancelling transport teardown."); - } - - shutdownCancellationTask = RequestShutdownCancellation(); - await AwaitPostCancellationDrainAsync( - tasks, - drainOperations, - terminalTransportWriteTask, - shutdownCancellationTask, - postCancelGracePeriod, - externalCancellationToken).ConfigureAwait(false); - } - - private async Task ObserveCompletedDrainAndShutdownAsync( - List tasks, - Task drainOperations, - Task? terminalTransportWriteTask, - TimeSpan postCancelGracePeriod, - CancellationToken externalCancellationToken) - { - await ObserveInFlightTasksAsync(drainOperations).ConfigureAwait(false); - - // A queued shutdown frame can start cancellation while the request drain is completing. - // Re-read the task after all accepted work has finished; the original snapshot may have - // been null even though a slow cancellation callback is now running (#4543). - // queued shutdown frame は request drain 完了直前に cancellation を開始できるため、accepted - // work 完了後に task を再取得する。初回 snapshot が null でも slow callback が実行中の - // race を bounded post-cancel deadline へ含める (#4543)。 - var shutdownCancellationTask = GetShutdownCancellationTask(); - if (shutdownCancellationTask is null) - return; - - await AwaitPostCancellationDrainAsync( - tasks, - drainOperations, - terminalTransportWriteTask, - shutdownCancellationTask, - postCancelGracePeriod, - externalCancellationToken).ConfigureAwait(false); - } - - private static Task BuildDrainOperationsTask(IReadOnlyCollection tasks, Task? terminalTransportWriteTask) - { - if (terminalTransportWriteTask is null) - return tasks.Count == 0 ? Task.CompletedTask : Task.WhenAll(tasks); - - var operations = new Task[tasks.Count + 1]; - var operationIndex = 0; - foreach (var task in tasks) - operations[operationIndex++] = task; - operations[^1] = terminalTransportWriteTask; - return Task.WhenAll(operations); - } - - private async Task AwaitPostCancellationDrainAsync( - List tasks, - Task drainOperations, - Task? terminalTransportWriteTask, - Task shutdownCancellationTask, - TimeSpan postCancelGracePeriod, - CancellationToken externalCancellationToken) - { - // Internal shutdown cancels the linked loop token. Use the original caller token so it - // cannot collapse this deadline, while Ctrl+C/SIGTERM/transport cancellation can still - // interrupt it (#3400, #4543). - // internal shutdown では post-cancel deadline を潰さず、外部 cancellation では中断可能にする。 - var postCancelWork = Task.WhenAll(drainOperations, shutdownCancellationTask); - var postCancelDelay = Task.Delay(postCancelGracePeriod, externalCancellationToken); - var completed = await Task.WhenAny(postCancelWork, postCancelDelay).ConfigureAwait(false); - if (completed == postCancelWork) - { - await ObserveInFlightTasksAsync(drainOperations).ConfigureAwait(false); - _ = shutdownCancellationTask.Exception; - return; - } - if (postCancelDelay.IsCanceled) - { - ObserveLateInFlightTasks(postCancelWork); - return; - } - - PruneCompletedRequestTasks(tasks); - if (tasks.Count > 0) - { - CommandErrorWriter.WriteStderr( - $"[cdidx-mcp] Transport teardown final deadline expired with {tasks.Count} in-flight request(s) remaining after {postCancelGracePeriod.TotalMilliseconds:0}ms post-cancel grace period."); - } - if (terminalTransportWriteTask is { IsCompleted: false }) - { - CommandErrorWriter.WriteStderr( - $"[cdidx-mcp] Transport response/completion write is still pending after {postCancelGracePeriod.TotalMilliseconds:0}ms post-cancel grace period."); - } - if (!shutdownCancellationTask.IsCompleted) - { - CommandErrorWriter.WriteStderr( - $"[cdidx-mcp] Shutdown cancellation callbacks are still running after {postCancelGracePeriod.TotalMilliseconds:0}ms post-cancel grace period."); - } - - // Use an uncancelled observer so late faults are still observed after the bounded, - // client-visible drain window (#3774, #4543). - // bounded drain window 後の late fault も未キャンセル observer で観測する。 - ObserveLateInFlightTasks(postCancelWork); - } - - private Task? GetShutdownCancellationTask() - { - lock (_shutdownCancellationGate) - return _shutdownCancellationTask; - } - - private Task RequestShutdownCancellation() - { - TaskCompletionSource completion; - lock (_shutdownCancellationGate) - { - if (_shutdownCancellationTask is not null) - return _shutdownCancellationTask; - - completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _shutdownCancellationTask = completion.Task; - } - - _ = CompleteShutdownCancellationAsync(completion); - return completion.Task; - } - - private async Task CompleteShutdownCancellationAsync(TaskCompletionSource completion) - { - try - { - await _shutdownCts.CancelAsync().ConfigureAwait(false); - } - catch (ObjectDisposedException) - { - // Disposal won the race; cancellation can no longer be requested. - } - catch (Exception ex) - { - try - { - CommandErrorWriter.WriteStderr( - $"[cdidx-mcp] Shutdown cancellation callback failed during transport teardown ({ex.GetType().Name})."); - } - catch - { - // Diagnostics must never abort bounded teardown. - } - } - finally - { - completion.TrySetResult(); - } - } - - private static void ObserveLateInFlightTasks(Task tasks) - => _ = tasks.ContinueWith(task => - { - _ = task.Exception; - }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - - private static async Task ObserveInFlightTasksAsync(Task tasks) - { - try - { - await tasks.ConfigureAwait(false); - } - catch (Exception ex) - { - CommandErrorWriter.WriteStderr($"[cdidx-mcp] In-flight request ended during transport teardown ({ex.GetType().Name})."); - } - } - - /// - /// Process one MCP JSON-RPC line and write any response to the provided writer. Kept as a - /// thin wrapper around so existing tests that drive a - /// directly stay source-compatible after the #1558 transport refactor. - /// 1 行分の MCP JSON-RPC を処理して writer に書き込む薄いラッパ。#1558 のトランスポート抽象化後も - /// 既存テストがソース互換となるよう、 をそのまま呼び出す。 - /// - internal async Task ProcessLineAsync(string line, TextWriter writer) - { - BeginDeferredFrameLogs(); - var response = await ProcessFrameAsync(line).ConfigureAwait(false); - if (response != null) - { - try - { - await _textWriterGate.WaitAsync().ConfigureAwait(false); - try - { - await WriteJsonLineAsync(writer, response).ConfigureAwait(false); - FlushDeferredFrameLogs(); - } - finally - { - _textWriterGate.Release(); - } - } - catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) - { - WriteMcpLogLine(BuildResponseWriteErrorLog(DiagnosticRedactor.FormatExceptionMessage(ex))); - FlushDeferredFrameLogs(); - } - } - } - - private static async Task WriteJsonLineAsync(TextWriter writer, string response) - { - await writer.WriteAsync(response).ConfigureAwait(false); - await writer.WriteAsync('\n').ConfigureAwait(false); - await writer.FlushAsync().ConfigureAwait(false); - } - - private static async Task WriteFrameSafelyAsync(IMcpTransport transport, string? response, CancellationToken cancellationToken) - => await WriteFrameSafelyAsync(transport.WriteFrameAsync, response, cancellationToken).ConfigureAwait(false); - - private static async Task WriteFrameSafelyAsync( - Func writeFrameAsync, - string? response, - CancellationToken cancellationToken) - { - try - { - await writeFrameAsync(response, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - WriteMcpLogLine(BuildResponseWriteErrorLog("write operation was canceled")); - } - catch (Exception ex) when (ex is IOException or ObjectDisposedException or TimeoutException) - { - WriteMcpLogLine(BuildResponseWriteErrorLog(DiagnosticRedactor.FormatExceptionMessage(ex))); - } - } - - private static bool IsServerResponseFrame(string frame) - { - if (!JsonFrameParser.TryParseNode(frame, MaxJsonDepth, out var node, out _)) - return false; - - return node is JsonObject obj - && obj.ContainsKey("id") - && obj["method"] is null - && (obj.ContainsKey("result") || obj.ContainsKey("error")); - } - - private string BuildInvalidUtf8ParseErrorResponse(DecoderFallbackException ex) - { - DeferFrameLog(BuildInvalidUtf8ErrorLog(DiagnosticRedactor.FormatExceptionMessage(ex))); - var errorResponse = CreateErrorResponse(hasId: true, id: null, code: -32700, message: "Parse error: invalid UTF-8 input", - category: McpErrorEnvelope.CategoryParseError, - suggestion: "Send one JSON-RPC 2.0 object per line encoded as valid UTF-8. Reject or re-encode malformed bytes before retrying.", - retrySafe: false); - return errorResponse.ToJsonString(_jsonOptions); - } - - internal static string BuildInvalidUtf8ErrorLog(string detail) - => $"[cdidx-mcp] JSON parse error: invalid UTF-8 input ({detail}). Send one UTF-8 JSON-RPC object per line; reject or re-encode malformed bytes before retrying."; - - private string BuildOversizedLineErrorResponse(BoundedLineLengthException ex) - => BuildOversizedLineErrorResponse(ex.CharactersRead, ex.Utf8BytesRead); - - private string BuildOversizedLineErrorResponse(int charactersRead, int utf8BytesRead) - { - DeferFrameLog(BuildOversizedMessageLog(charactersRead, utf8BytesRead)); - var errorResponse = CreateErrorResponse(hasId: true, id: null, code: -32700, message: "Message too large", - category: McpErrorEnvelope.CategoryMessageTooLarge, - suggestion: $"JSON-RPC frame exceeds the {MaxLineCharacterCount} character or {MaxLineByteLength} byte cap. Split the request into smaller calls or use `batch_query` with smaller slots.", - retrySafe: false); - return errorResponse.ToJsonString(_jsonOptions); - } - - /// - /// Process one MCP JSON-RPC frame and return the wire-ready response string (or null when - /// the request was a notification or otherwise yields no response). This synchronous wrapper - /// is retained for compatibility tests and legacy in-process callers only; transports and - /// request loops should call so cancellation and shutdown can - /// flow without sync-over-async blocking (#3770). - /// 1 フレーム分の MCP JSON-RPC を処理し、ワイヤー応答文字列を返す(通知などで応答なしの場合は null)。 - /// この同期ラッパは互換テストと legacy in-process 呼び出し専用に残す。transport と request loop は - /// sync-over-async blocking を避けるため を await する (#3770)。 - /// - internal string? ProcessFrame(string line) - // Synchronous callers are compatibility entry points for tests and non-async hosts; - // transport loops use ProcessFrameAsync directly so request handling stays async. - => ProcessFrameAsync(line).GetAwaiter().GetResult(); - - internal Task ProcessFrameAsync(string line) - => ProcessFrameAsync(line, beforeDispatchAsync: null, rejectForCapacity: false); - - private async Task ProcessFrameAsync( - string line, - Func? beforeDispatchAsync, - bool rejectForCapacity, - CapacityRejectedFrameRegistrations? capacityRejectedRegistrations = null) - { - if (string.IsNullOrWhiteSpace(line)) - return null; - - // Reject oversized messages to prevent memory exhaustion - // メモリ枯渇を防ぐため巨大メッセージを拒否 - var byteLength = Encoding.UTF8.GetByteCount(line); - if (line.Length > MaxLineCharacterCount || byteLength > MaxLineByteLength) - return BuildOversizedLineErrorResponse(line.Length, byteLength); - - JsonNode? request = null; - var responseHasId = true; - JsonNode? responseId = null; - IDisposable? frameCorrelationScope = null; - var deferredInitializeCommits = new DeferredInitializeCommits(); - try - { - request = JsonFrameParser.ParseNode(line, MaxJsonDepth); - if (request == null) - return CreateExpectedJsonObjectErrorResponse().ToJsonString(_jsonOptions); - - if (TryCompletePendingClientRequest(request)) - return null; - - capacityRejectedRegistrations?.Register(request); - ExtractResponseId(request, out responseHasId, out responseId); - // A batch frame has no single JSON-RPC id. Invalid ids and malformed scalar frames - // also use id:null only for the JSON-RPC error response; that wire fallback must not - // be mistaken for an explicit null request id in telemetry. Batch items establish - // their own valid-id contexts in HandleMessageAsync. - // batch frame 自体には単一の JSON-RPC id がない。invalid id や scalar frame の - // id:null は error response 専用で、telemetry 上の明示 null id と混同しない。 - // batch item は HandleMessageAsync で valid id ごとの context を作る。 - var frameHasRequestId = request is JsonObject requestObject - && TryGetRequestId(requestObject, out var requestObjectHasId, out _) - && requestObjectHasId; - var frameHasCorrelation = responseHasId && request is not JsonArray; - if (frameHasCorrelation && CurrentCorrelationContext.Value is null) - frameCorrelationScope = BeginRequestCorrelation(responseId, frameHasRequestId); - using var activity = StartMcpActivity(request, frameHasRequestId, responseId); - var response = await HandleMessageAsync( - request, - isolateRequestDb: true, - beforeDispatchAsync, - rejectForCapacity, - queuedBatchRegistration: null, - deferredInitializeCommits).ConfigureAwait(false); - activity?.SetTag("rpc.result", response is null ? "notification" : "response"); - if (response is null) - return null; - - var serialized = SerializeResponseOrFallback( - response, - responseHasId, - responseId, - out var serializedOriginalResponse); - if (serializedOriginalResponse) - { - foreach (var state in deferredInitializeCommits.GetIncludedStates(response)) - CommitInitializeState(state); - } - - return serialized; - } - catch (JsonException ex) - { - // Parse error / パースエラー - DeferFrameLog(BuildJsonParseErrorLog(JsonFrameParser.FormatExceptionDetail(ex))); - var errorResponse = CreateErrorResponse(hasId: true, id: null, code: -32700, message: "Parse error", - category: McpErrorEnvelope.CategoryParseError, - suggestion: $"For MCP stdio, send one UTF-8 JSON-RPC 2.0 object per LF-delimited line with nesting depth <= {MaxJsonDepth}. Do not send LSP Content-Length framing.", - retrySafe: false); - return errorResponse.ToJsonString(_jsonOptions); - } - catch (Exception ex) - { - // Stderr keeps the full message for local diagnostics, but the - // wire response only carries the exception type so SQLite-style - // "near 'foo': syntax error" detail or other content-bearing - // strings cannot leak to the JSON-RPC client (#1530). - // stderr には診断用に詳細を残すが、ネットワークに出るレスポンスには - // 例外型のみを返し、SQLite の "near 'foo': syntax error" などを通じた - // 内容漏れを防ぐ(#1530)。 - DeferFrameLog(BuildUnhandledLoopErrorLog(DiagnosticRedactor.FormatExceptionMessage(ex))); - var classification = McpErrorEnvelope.ClassifyException(ex); - var errorResponse = CreateErrorResponse(responseHasId, responseId, classification.JsonRpcCode, - BuildSanitizedLoopErrorMessage(ex), - category: classification.Category, - suggestion: classification.Suggestion, - retrySafe: classification.RetrySafe); - return SerializeResponseOrFallback( - errorResponse, - responseHasId, - responseId, - out _); - } - finally - { - frameCorrelationScope?.Dispose(); - } - } - - private static Activity? StartMcpActivity(JsonNode request, bool responseHasId, JsonNode? responseId) - { - var method = request is JsonObject obj ? TryGetStringMember(obj, "method") : null; - var traceParent = TryGetMcpTraceParent(request); - ActivityContext parentContext = default; - if (traceParent != null) - ActivityContext.TryParse(traceParent, traceState: null, out parentContext); - - var activity = parentContext != default - ? CodeIndexTelemetry.ActivitySource.StartActivity("mcp.request", ActivityKind.Server, parentContext) - : CodeIndexTelemetry.ActivitySource.StartActivity("mcp.request", ActivityKind.Server); - activity?.SetTag("rpc.system", "jsonrpc"); - activity?.SetTag("rpc.service", "mcp"); - if (!string.IsNullOrWhiteSpace(method)) - activity?.SetTag("rpc.method", method); - if (responseHasId) - { - var requestId = McpRequestIdTelemetry.Create(responseId); - activity?.SetTag("rpc.request_id", requestId.Token); - activity?.SetTag("rpc.request_id_type", requestId.Type); - activity?.SetTag("rpc.request_id_length", requestId.Length); - } - return activity; - } - - private bool TryCompletePendingClientRequest(JsonNode request) - { - if (request is not JsonObject obj - || !obj.TryGetPropertyValue("id", out var id) - || obj["method"] is not null) - return false; - - if (!TrySerializeRequestId(id, out var serializedId, out _)) - return false; - - var key = serializedId ?? "null"; - if (!_pendingClientRequests.TryRemove(key, out var pending)) - return false; - - if (obj.TryGetPropertyValue("error", out var error) && error is not null) - { - if (!TrySerializeClientResponseError(error, out var serializedError, out var errorBytes)) - { - DeferFrameLog(BuildClientResponseTooLargeLog("error", errorBytes)); - pending.TrySetException(new InvalidOperationException(BuildClientResponseTooLargeMessage(errorBytes))); - } - else - { - pending.TrySetException(new InvalidOperationException(serializedError)); - } - } - else if (!TryCloneClientResponsePayload(obj["result"], out var resultClone, out var resultBytes)) - { - DeferFrameLog(BuildClientResponseTooLargeLog("result", resultBytes)); - pending.TrySetException(new InvalidOperationException(BuildClientResponseTooLargeMessage(resultBytes))); - } - else - { - pending.TrySetResult(resultClone); - } - return true; - } - - internal Task RegisterPendingClientRequestForTests(string id) - { - var key = JsonSerializer.Serialize(id); - var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - if (!_pendingClientRequests.TryAdd(key, pending)) - throw new InvalidOperationException($"Pending MCP client request already registered: {id}"); - return pending.Task; - } - - private async Task SendClientRequestAsync(string method, JsonObject? @params, CancellationToken cancellationToken) - { - if (ClientRequestHandlerForTests is { } handler) - { - if (!TryCloneClientResponsePayload(handler(method, @params), out var handlerClone, out var handlerBytes)) - { - DeferFrameLog(BuildClientResponseTooLargeLog("result", handlerBytes)); - return null; - } - return handlerClone; - } - - var writer = _currentOutOfBandFrameWriter.Value; - if (writer is null || !_canAwaitClientResponses.Value) - return null; - - var id = "cdidx-" + Interlocked.Increment(ref s_nextClientRequestId).ToString(System.Globalization.CultureInfo.InvariantCulture); - var key = JsonSerializer.Serialize(id); - var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - if (!_pendingClientRequests.TryAdd(key, pending)) - return null; - - var request = new JsonObject - { - ["jsonrpc"] = "2.0", - ["id"] = id, - ["method"] = method, - }; - if (@params is not null) - request["params"] = @params; - - using var timeoutScope = OperationTimeoutScope.Create( - OperationTimeoutCategories.McpClientRequest, - TimeSpan.FromSeconds(10), - cancellationToken); - using var cancellationRegistration = timeoutScope.Token.Register(static state => - { - var tuple = ((McpServer server, string key, TaskCompletionSource pending))state!; - if (tuple.server._pendingClientRequests.TryRemove(tuple.key, out var _)) - tuple.pending.TrySetCanceled(); - }, (this, key, pending)); - - try - { - await writer(request.ToJsonString(_jsonOptions), timeoutScope.Token).ConfigureAwait(false); - return await pending.Task.ConfigureAwait(false); - } - catch (InvalidOperationException) - { - return null; - } - catch (OperationCanceledException) - { - return null; - } - finally - { - _pendingClientRequests.TryRemove(key, out var _); - } - } - - internal bool TryCloneClientResponsePayloadForTests(JsonNode? payload, out JsonNode? clone, out int bytesWritten) - => TryCloneClientResponsePayload(payload, out clone, out bytesWritten); - - internal bool TrySerializeClientResponseErrorForTests(JsonNode error, out string? serialized, out int bytesWritten) - => TrySerializeClientResponseError(error, out serialized, out bytesWritten); - - private bool TryCloneClientResponsePayload(JsonNode? payload, out JsonNode? clone, out int bytesWritten) - { - clone = null; - bytesWritten = 0; - if (payload is null) - return true; - - if (!TryMeasureJsonUtf8BytesWithinLimit(payload, _jsonOptions, MaxClientResponseJsonBytes, out bytesWritten)) - return false; - - clone = McpJsonNode.Clone(payload); - return true; - } - - private bool TrySerializeClientResponseError(JsonNode error, out string? serialized, out int bytesWritten) - => TrySerializeJsonNodeWithinByteLimit(error, _jsonOptions, MaxClientResponseJsonBytes, captureSerialized: true, out serialized, out bytesWritten); - - private static string? TryGetMcpTraceParent(JsonNode request) - { - if (request is not JsonObject obj || - obj["params"] is not JsonObject parameters || - parameters["_meta"] is not JsonObject meta) - return null; - - if (meta["traceparent"] is not JsonValue valueNode || - !valueNode.TryGetValue(out var value)) - return null; - return string.IsNullOrWhiteSpace(value) ? null : value; - } - - private string SerializeResponseOrFallback( - JsonNode response, - bool hasId, - JsonNode? id, - out bool serializedOriginalResponse) - { - serializedOriginalResponse = false; - try - { - var responseLimit = GetMaxResponseBytes(); - if (_usesDefaultResponseSerializer) - { - if (!TrySerializeJsonNodeWithinByteLimit(response, _jsonOptions, responseLimit, captureSerialized: true, out var boundedSerialized, out var boundedResponseBytes)) - return CreateResponseTooLargeError(hasId, id, boundedResponseBytes, responseLimit, actualBytesExact: false).ToJsonString(_jsonOptions); - - serializedOriginalResponse = true; - return boundedSerialized!; - } - - var serialized = _serializeResponse(response); - var responseBytes = Encoding.UTF8.GetByteCount(serialized); - if (responseBytes <= responseLimit) - { - serializedOriginalResponse = true; - return serialized; - } - - return CreateResponseTooLargeError(hasId, id, responseBytes, responseLimit).ToJsonString(_jsonOptions); - } - catch (Exception ex) - { - DeferFrameLog(BuildResponseSerializationErrorLog(DiagnosticRedactor.FormatExceptionMessage(ex))); - return BuildMinimalInternalErrorResponse(hasId, id, ex); - } - } - - private void DeferFrameLog(string message) - => DeferFrameLog(() => WriteMcpLogLine(message)); - - private void DeferFrameLog(Action writeLog) - { - var context = CurrentCorrelationContext.Value; - var logs = _deferredFrameLogs.Value; - if (logs is null) - { - WriteWithCorrelationContext(context, writeLog); - return; - } - - logs.Add(() => WriteWithCorrelationContext(context, writeLog)); - } - - private static void WriteWithCorrelationContext(RequestCorrelationContext? context, Action writeLog) - { - var previous = CurrentCorrelationContext.Value; - try - { - CurrentCorrelationContext.Value = context; - writeLog(); - } - finally - { - CurrentCorrelationContext.Value = previous; - } - } - - private void BeginDeferredFrameLogs() - => _deferredFrameLogs.Value = new DeferredFrameLogBuffer(); - - private void FlushDeferredFrameLogs() - { - var logs = _deferredFrameLogs.Value; - if (logs is null) - return; - - _deferredFrameLogs.Value = null; - logs.ForwardTo(static log => log()); - } - - private sealed class DeferredFrameLogBuffer - { - private readonly object _gate = new(); - private List? _logs = []; - private Action? _lateLogForwarder; - - public void Add(Action log) - { - Action? lateLogForwarder; - lock (_gate) - { - if (_logs is not null) - { - _logs.Add(log); - return; - } - - lateLogForwarder = _lateLogForwarder; - } - - (lateLogForwarder ?? (static lateLog => lateLog()))(log); - } - - public void ForwardTo(Action lateLogForwarder) - { - lock (_gate) - { - if (_logs is null) - return; - - foreach (var log in _logs) - lateLogForwarder(log); - _logs = null; - _lateLogForwarder = lateLogForwarder; - } - } - } - - private sealed class CapacityRejectedFrameRegistrations : IDisposable - { - private readonly McpServer _owner; - private readonly HashSet _requestKeys = new(StringComparer.Ordinal); - private readonly List _registrations = []; - private bool _disposed; - - internal CapacityRejectedFrameRegistrations(McpServer owner) - { - _owner = owner; - } - - internal void Register(JsonNode request) - { - if (request is JsonArray batch) - { - foreach (var item in batch) - RegisterItem(item); - return; - } - - RegisterItem(request); - } - - private void RegisterItem(JsonNode? item) - { - if (!BatchItemRequiresResponse(item, out var responseId) - || SerializeRequestId(responseId) is not { } requestKey - || !_requestKeys.Add(requestKey)) - { - return; - } - - if (_owner.TryRegisterQueuedBatchRequest(requestKey) is { } registration) - _registrations.Add(registration); - } - - public void Dispose() - { - if (_disposed) - return; - _disposed = true; - - foreach (var registration in _registrations) - registration.DisposeIfUnclaimed(); - } - } - - private sealed class QueuedBatchRequestRegistration - { - private readonly McpServer _owner; - private readonly string _requestKey; - private readonly CancellationTokenSource _cancellation; - // 0 = queued, 1 = claimed by normal dispatch, 2 = cleaned before dispatch. - private int _state; - - internal QueuedBatchRequestRegistration( - McpServer owner, - string requestKey, - CancellationTokenSource cancellation) - { - _owner = owner; - _requestKey = requestKey; - _cancellation = cancellation; - } - - internal CancellationToken Token => _cancellation.Token; - - internal bool TryCancel() - { - try - { - _cancellation.Cancel(); - return true; - } - catch (ObjectDisposedException) - { - // Dispatch won the move into `_activeRequests`; the caller will retry there. - return false; - } - } - - internal bool TryClaim() - { - if (Interlocked.CompareExchange(ref _state, 1, 0) != 0) - return false; - RemoveAndDispose(); - return true; - } - - internal void DisposeIfUnclaimed() - { - if (Interlocked.CompareExchange(ref _state, 2, 0) != 0) - return; - RemoveAndDispose(); - } - - private void RemoveAndDispose() - { - if (_owner._queuedBatchRequests.TryGetValue(_requestKey, out var current) - && ReferenceEquals(current, this)) - { - _owner._queuedBatchRequests.TryRemove(_requestKey, out _); - } - _cancellation.Dispose(); - } - } - - private static void WriteMcpLogLine(string message) - { - var line = AddCorrelationPrefix(message); - try - { - CommandErrorWriter.WriteStderr(line); - } - catch (Exception ex) when (ex is IOException or ObjectDisposedException) - { - // Best-effort diagnostics: a closed redirected stderr must not break the MCP request. - } - GlobalToolLog.Info(line); - } - - private static string AddCorrelationPrefix(string message) - { - var context = CurrentCorrelationContext.Value; - if (context is null) - return message; - - var requestId = context.TelemetryRequestId; - var prefix = requestId is { } presentRequestId - ? $"[rid={presentRequestId.Token} rid_type={presentRequestId.Type} rid_length={presentRequestId.Length.ToString(CultureInfo.InvariantCulture)} cid={context.CorrelationId}] " - : $"[cid={context.CorrelationId}] "; - return message.StartsWith("[cdidx-mcp] ", StringComparison.Ordinal) - ? "[cdidx-mcp] " + prefix + message["[cdidx-mcp] ".Length..] - : prefix + message; - } - - private static void ExtractResponseId(JsonNode request, out bool hasId, out JsonNode? id) - { - if (request is JsonObject obj) - { - if (TryGetRequestId(obj, out hasId, out var requestId)) - id = McpJsonNode.Clone(requestId); - else - id = null; - return; - } - - // For malformed non-object JSON values, JSON-RPC error responses should still carry - // id:null instead of disappearing when handling or serialization fails. - hasId = true; - id = null; - } - - private static string BuildMinimalInternalErrorResponse(bool hasId, JsonNode? id, Exception ex) - { - var message = $"Internal error while serializing MCP response ({ex.GetType().Name}). See cdidx server stderr for details."; - var builder = new StringBuilder("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":"); - builder.Append(JsonSerializer.Serialize(message)); - AppendMinimalCorrelationData(builder); - builder.Append('}'); - if (hasId) - { - builder.Append(",\"id\":"); - builder.Append(id is null ? "null" : id.ToJsonString()); - } - builder.Append('}'); - return builder.ToString(); - } - - private static void AppendMinimalCorrelationData(StringBuilder builder) - { - var context = CurrentCorrelationContext.Value; - if (context is null) - return; - - builder.Append(",\"data\":{\"correlation_id\":"); - builder.Append(JsonSerializer.Serialize(context.CorrelationId)); - if (context.WireRequestId != null) - { - builder.Append(",\"request_id\":"); - builder.Append(JsonSerializer.Serialize(context.WireRequestId)); - } - builder.Append('}'); - } /// /// Route a JSON-RPC message to the appropriate handler. This synchronous wrapper is retained From f575f79decf1ba4fa5a94345fa77ac410389bfd6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:18:51 +0900 Subject: [PATCH 002/101] Separate MCP message dispatch and batch lifecycle --- .../Mcp/McpServer.MessageDispatch.cs | 1618 +++++++++++++++++ src/CodeIndex/Mcp/McpServer.Transport.cs | 25 +- src/CodeIndex/Mcp/McpServer.cs | 1608 ---------------- 3 files changed, 1629 insertions(+), 1622 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.MessageDispatch.cs diff --git a/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs b/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs new file mode 100644 index 000000000..893d979cf --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs @@ -0,0 +1,1618 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Mcp; + +public partial class McpServer : IDisposable +{ + + /// + /// Route a JSON-RPC message to the appropriate handler. This synchronous wrapper is retained + /// for compatibility tests and legacy in-process callers only; transports should prefer + /// to avoid sync-over-async dispatch (#3770). + /// JSON-RPCメッセージを適切なハンドラにルーティング。この同期ラッパは互換テストと legacy + /// in-process 呼び出し専用に残し、transport は sync-over-async dispatch を避けるため + /// を優先する (#3770)。 + /// + internal JsonNode? HandleMessage(JsonNode request) + // Keep this sync wrapper for existing in-process callers; async transports call + // HandleMessageAsync so server loops do not need a sync-over-async bridge. + => HandleMessageAsync( + request, + isolateRequestDb: false, + beforeDispatchAsync: null, + rejectForCapacity: false, + queuedBatchRegistration: null, + deferredInitializeCommits: null).GetAwaiter().GetResult(); + + internal Task HandleMessageAsync(JsonNode request) + => HandleMessageAsync( + request, + isolateRequestDb: false, + beforeDispatchAsync: null, + rejectForCapacity: false, + queuedBatchRegistration: null, + deferredInitializeCommits: null); + + private async Task HandleMessageAsync( + JsonNode request, + bool isolateRequestDb, + Func? beforeDispatchAsync, + bool rejectForCapacity, + QueuedBatchRequestRegistration? queuedBatchRegistration, + DeferredInitializeCommits? deferredInitializeCommits) + { + if (request is JsonArray batch) + { + if (deferredInitializeCommits is null) + { + return await HandleBatchMessageAsync( + batch, + isolateRequestDb, + beforeDispatchAsync, + rejectForCapacity, + deferredInitializeCommits).ConfigureAwait(false); + } + + var previousFrameInitializeState = _frameInitializeState.Value; + var initialFrameInitializeState = CurrentInitializeState; + var frameInitializeState = new FrameInitializeState( + initialFrameInitializeState, + isProvisionalGeneration: false); + _frameInitializeState.Value = frameInitializeState; + var batchBeforeDispatchAsync = beforeDispatchAsync; + if (beforeDispatchAsync is not null) + { + batchBeforeDispatchAsync = async cancellationToken => + { + await beforeDispatchAsync(cancellationToken).ConfigureAwait(false); + // The concurrent loop accepts and pre-registers a batch before its protocol + // predecessor finishes. Advance only this batch's original generation after + // that predecessor commits; timed-out older frames retain their own holders, + // and an in-batch initialize replaces this holder instead of being overwritten. + // concurrent loop は protocol predecessor 完了前に batch を受理・事前登録する。 + // predecessor の commit 後、この batch の元 generation だけを進める。timeout + // 後の旧 frame は別 holder を保持し、batch 内 initialize は holder 自体を置換する。 + frameInitializeState.TryAdvanceToPublishedGeneration( + initialFrameInitializeState, + PublishedInitializeState); + }; + } + try + { + return await HandleBatchMessageAsync( + batch, + isolateRequestDb, + batchBeforeDispatchAsync, + rejectForCapacity, + deferredInitializeCommits).ConfigureAwait(false); + } + finally + { + _frameInitializeState.Value = previousFrameInitializeState; + } + } + + if (request is not JsonObject obj) + return CreateExpectedJsonObjectErrorResponse(); + + lock (_healthStateGate) + _lastRequestAt = _timeProvider.GetUtcNow(); + + // Extract `method` defensively: a non-string `method` (e.g. `"method":42`) must not + // throw before the auth gate runs, otherwise a token-protected server would surface + // `-32603 "Internal error"` to an unauthenticated caller instead of `-32001 + // "Unauthorized"`, leaking that the request reached dispatch internals (#1559). + // `method` は防御的に取り出す。`"method":42` のような非文字列が GetValue() + // で例外を投げると、認証ゲート前に -32603 が返ってしまい、未認証呼び出し元に dispatch + // 内部まで届いた事実が漏れる (#1559)。 + var method = TryGetStringMember(obj, "method"); + if (!TryGetRequestId(obj, out var hasId, out var id, out var idError)) + return CreateErrorResponse(hasId: true, id: null, code: -32600, message: BuildInvalidRequestIdMessage(idError), + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: BuildInvalidRequestIdSuggestion(idError), + retrySafe: false, + extraData: BuildInvalidRequestIdData(idError)); + + if (TryGetStringMember(obj, "jsonrpc") != "2.0") + return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: jsonrpc must be exactly \"2.0\"", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "Set the top-level `jsonrpc` member to the string `2.0`.", + retrySafe: false); + + using var correlationScope = hasId && CurrentCorrelationContext.Value is null ? BeginRequestCorrelation(id) : null; + + // A JSON-RPC notification cannot carry an error response, but that does not make it + // safe to bypass authentication when handling it mutates server state. Authenticate + // every state-changing notification before cancellation, roots, or lifecycle state is + // touched; on denial, emit only the bounded local diagnostic and preserve the required + // no-response wire contract (#4537). + // JSON-RPC notification はエラー応答を持てないが、server state を変更する通知まで認証を + // 省略してよいことにはならない。cancellation / roots / lifecycle state に触れる前に認証し、 + // 拒否時は bounded なローカル診断だけを残して no-response 契約を維持する (#4537)。 + if (IsStateChangingNotification(method)) + { + var notificationAuth = _authenticator.Authenticate(request); + if (!notificationAuth.IsAuthenticated) + { + WriteMcpLogLine(BuildAuthFailureLog(method, notificationAuth.FailureReason)); + return null; + } + } + + if (method == "$/cancelRequest" || method == "notifications/cancelled") + { + TryCancelRequest(request["params"]); + return null; + } + + if (rejectForCapacity && IsStateChangingNotification(method)) + { + // Eager cancellation is handled above. Other state notifications are dropped on + // admission overflow regardless of a malformed id, matching the normal no-id + // overload contract without mutating roots or lifecycle state (#4536, #4545). + // eager cancellation は上で処理済み。それ以外の state notification は malformed + // id の有無に関係なく admission overflow 時に drop し、roots/lifecycle を変更しない。 + return null; + } + + var protocolPredecessorAwaited = false; + if (IsStateChangingNotification(method) && beforeDispatchAsync is not null) + { + // Cancellation controls intentionally bypass protocol barriers, but roots/lifecycle + // notifications must not mutate state before an earlier initialize commits. Apply the + // method semantic even when a malformed client attaches an id to the notification. + // cancellation control は protocol barrier を bypass する一方、roots/lifecycle + // notification は先行 initialize の commit 前に state を変更してはならない。 + // malformed client が id を付けた場合も method semantics に基づいて待機する。 + await beforeDispatchAsync(_currentRequestToken.Value).ConfigureAwait(false); + protocolPredecessorAwaited = true; + } + + if (!hasId) + { + if (rejectForCapacity) + return null; + if (!protocolPredecessorAwaited && beforeDispatchAsync is not null) + await beforeDispatchAsync(_currentRequestToken.Value).ConfigureAwait(false); + } + + // Notifications (no id) don't get a response / 通知(idなし)にはレスポンスなし + if (method == "notifications/initialized") + return null; + + if (method == "notifications/roots/list_changed") + { + MarkClientRootsStale(); + _frameInitializeState.Value?.MarkRootsChangeAccepted(); + return null; + } + + // Graceful shutdown via JSON-RPC notification (#1567). Without this, the only way to + // stop a long-lived `cdidx mcp` server was to close the transport (stdin EOF / HTTP + // listener stop), which races with in-flight work and forces clients to send SIGINT. + // Treating both `notifications/shutdown` (the MCP spec-aligned name) and the legacy + // LSP-style `notifications/exit` alias as graceful-stop signals lets clients drain the + // current request and exit cleanly. Asynchronous cancellation unblocks any pending + // `ReadFrameAsync` without letting a slow user callback hold the dispatch thread (#4543). + // JSON-RPC 通知による graceful shutdown (#1567)。非同期 cancellation で slow callback に + // dispatch thread を塞がせず `ReadFrameAsync` を unblock する (#4543)。 + if (string.Equals(method, "notifications/shutdown", StringComparison.Ordinal) + || string.Equals(method, "notifications/exit", StringComparison.Ordinal)) + { + WriteMcpLogLine($"[cdidx-mcp] Received {method}; draining in-flight work and shutting down."); + _running = false; + _ = RequestShutdownCancellation(); + return null; + } + + if (!hasId) + { + if (method != null && method.StartsWith("notifications/", StringComparison.OrdinalIgnoreCase)) + WriteMcpLogLine(BuildUnknownNotificationLog(method)); + return null; + } + + // Authenticate every responded request before dispatch so the auth contract is + // uniform across `initialize`, `tools/list`, `tools/call`, and `ping`. Run auth even + // when `method` is missing or malformed so a token-protected server cannot be probed + // for method-shape errors without credentials (#1559). State-changing notifications + // pass through their own auth gate above; side-effect-free notifications short-circuit + // without authentication because they produce no response. + // すべての応答対象リクエストを dispatch 前に認証する。`method` が欠落・不正でも + // 認証は走らせ、トークン保護下のサーバーで未認証呼び出し元に method 形式エラーを + // 漏らさない (#1559)。state-changing notification は上の専用ゲートで認証し、 + // 副作用のない notification だけを応答なしで short-circuit する。 + var authResult = _authenticator.Authenticate(request); + if (!authResult.IsAuthenticated) + { + DeferFrameLog(BuildAuthFailureLog(method, authResult.FailureReason)); + return CreateErrorResponse(hasId: true, id: id, code: McpErrorEnvelope.CodeUnauthorized, message: "Unauthorized", + category: McpErrorEnvelope.CategoryPermissionDenied, + suggestion: "Set CDIDX_MCP_AUTH_TOKEN on the server and include a matching params.auth.token (or an `Authorization: Bearer ` header for HTTP) on each request.", + retrySafe: false); + } + + if (rejectForCapacity) + return CreateServerBusyResponse(id); + + if (method == null) + { + return CreateErrorResponse(hasId: true, id: id, code: -32600, message: "Invalid request: missing method", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "JSON-RPC 2.0 requires a string `method` field.", + retrySafe: false); + } + + return await DispatchWithRequestCancellationAsync(id, isolateRequestDb, beforeDispatchAsync, queuedBatchRegistration, () => + { + if (_enforceInitializationLifecycle && !CurrentInitializeState.Initialized && method != "initialize") + { + return Task.FromResult(CreateErrorResponse(hasId: true, id: id, code: -32002, message: "Server not initialized", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "Send a successful `initialize` request before calling other MCP methods.", + retrySafe: true)); + } + + return method switch + { + "initialize" => Task.FromResult(HandleInitialize( + id, + request["params"], + deferredInitializeCommits)), + "tools/list" => Task.FromResult(HandleToolsList(id, request["params"])), + "tools/call" => HandleToolsCallAsync(hasId, id, request["params"]), + "resources/list" => Task.FromResult(HandleResourcesList(id, request["params"])), + "resources/templates/list" => Task.FromResult(HandleResourceTemplatesList(id, request["params"])), + "resources/read" => Task.FromResult(HandleResourcesRead(id, request["params"])), + "prompts/list" => Task.FromResult(HandlePromptsList(id)), + "prompts/get" => Task.FromResult(HandlePromptsGet(id, request["params"])), + "logging/setLevel" => HandleLoggingSetLevelAsync(id, request["params"]), + "ping" => Task.FromResult(CreateSuccessResponse(hasId, id, BuildHealthResult())), + _ => Task.FromResult(CreateErrorResponse(hasId: true, id: id, code: -32601, message: $"Method not found: {method}", + category: McpErrorEnvelope.CategoryMethodNotFound, + suggestion: "Supported methods: initialize, tools/list, tools/call, resources/list, resources/templates/list, resources/read, prompts/list, prompts/get, logging/setLevel, ping, notifications/initialized, notifications/cancelled, notifications/shutdown.", + retrySafe: false)), + }; + }).ConfigureAwait(false); + } + + private static JsonObject CreateExpectedJsonObjectErrorResponse() + => CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: expected JSON object", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "Send a JSON-RPC 2.0 object (e.g. {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}).", + retrySafe: false); + + private static bool IsStateChangingNotification(string? method) + => method is "$/cancelRequest" + or "notifications/cancelled" + or "notifications/roots/list_changed" + or "notifications/shutdown" + or "notifications/exit"; + + private static JsonObject CreateServerBusyResponse(JsonNode? id) + => CreateErrorResponse( + hasId: true, + id, + McpErrorEnvelope.CodeServerBusy, + "Server busy: MCP request backlog is full", + category: McpErrorEnvelope.CategoryServerBusy, + suggestion: "Retry after one or more in-flight MCP requests complete.", + retrySafe: true, + extraData: new JsonObject { ["retry_after_ms"] = 1000 }); + + private string BuildHealthJson(HttpMcpTransport? httpTransport = null) + => BuildHealthResult(httpTransport).ToJsonString(_jsonOptions); + + private string BuildKeepAliveNotificationJson() + { + var now = _timeProvider.GetUtcNow(); + var notification = new JsonObject + { + ["jsonrpc"] = "2.0", + ["method"] = "notifications/keep_alive", + ["params"] = new JsonObject + { + ["server_time"] = now.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + ["uptime_s"] = Math.Max(0, (long)Math.Floor((now - _startedAt).TotalSeconds)), + } + }; + return notification.ToJsonString(_jsonOptions); + } + + private static TimeSpan? ReadKeepAliveIntervalFromEnvironment() + { + var raw = global::CodeIndex.EnvironmentAccess.GetProcessEnvironmentVariable(KeepAliveIntervalEnvironmentVariable); + if (string.IsNullOrWhiteSpace(raw)) + return null; + if (!double.TryParse(raw, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds) + || !double.IsFinite(seconds) + || seconds < MinKeepAliveIntervalSeconds + || seconds > MaxKeepAliveIntervalSeconds) + { + var displayValue = DiagnosticRedactor.FormatEnvironmentValue(KeepAliveIntervalEnvironmentVariable, raw); + CommandErrorWriter.WriteStderr( + $"[cdidx-mcp] Ignoring invalid {KeepAliveIntervalEnvironmentVariable}='{displayValue}'. Expected a finite value between {MinKeepAliveIntervalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} and {MaxKeepAliveIntervalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} seconds. Keep-alive notifications stay disabled."); + return null; + } + return TimeSpan.FromSeconds(seconds); + } + + private JsonObject BuildHealthResult(HttpMcpTransport? httpTransport = null) + { + var now = _timeProvider.GetUtcNow(); + var dbOpen = ProbeDbHealth(out var dbError); + var httpResponseCleanupDegraded = httpTransport?.ResponseCleanupDegraded ?? false; + var httpRequestLogDegraded = httpTransport?.RequestLogDegraded ?? false; + var auditLogDiagnostics = _auditLog?.SnapshotDiagnostics(); + var auditLogDegraded = IsAuditLogDegraded(auditLogDiagnostics); + DateTimeOffset lastRequestAt; + lock (_healthStateGate) + lastRequestAt = _lastRequestAt; + var result = new JsonObject + { + ["status"] = dbOpen && !httpResponseCleanupDegraded && !httpRequestLogDegraded && !auditLogDegraded ? "ok" : "degraded", + ["uptime_s"] = Math.Max(0, (long)Math.Floor((now - _startedAt).TotalSeconds)), + ["last_request_at"] = lastRequestAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + ["db_open"] = dbOpen, + ["last_db_check_at"] = now.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + ["transport_ready"] = _running, + }; + if (httpTransport is not null) + { + result["http_max_request_body_bytes"] = httpTransport.MaxRequestBodyBytes; + result["http_request_body_idle_timeout_ms"] = (long)httpTransport.RequestBodyIdleTimeout.TotalMilliseconds; + result["http_request_lifetime_timeout_ms"] = (long)httpTransport.RequestLifetimeTimeout.TotalMilliseconds; + result["http_request_body_budget_limit_bytes"] = httpTransport.MaxInFlightRequestBodyBytes; + result["http_request_body_bytes_in_flight"] = httpTransport.InFlightRequestBodyBytes; + result["http_request_body_process_bytes_in_flight"] = httpTransport.ProcessInFlightRequestBodyBytes; + result["http_request_body_peak_bytes"] = httpTransport.PeakInFlightRequestBodyBytes; + result["http_request_body_budget_scope"] = "process"; + result["http_request_body_budget_rejection_count"] = httpTransport.RequestBodyBudgetLimitRejectionCount; + result["http_request_body_idle_timeout_count"] = httpTransport.RequestBodyIdleTimeoutCount; + result["http_request_lifetime_timeout_count"] = httpTransport.RequestLifetimeTimeoutCount; + result["http_client_disconnect_count"] = httpTransport.ClientDisconnectCount; + result["http_queued_request_cancellation_count"] = httpTransport.QueuedRequestCancellationCount; + result["http_event_stream_count"] = httpTransport.EventStreamCount; + result["http_event_stream_limit"] = httpTransport.MaxEventStreams; + result["http_max_concurrent_handlers"] = httpTransport.MaxConcurrentHandlers; + result["http_post_handler_capacity"] = httpTransport.PostHandlerCapacity; + result["http_event_stream_handler_capacity"] = httpTransport.EventStreamHandlerCapacity; + result["http_separate_event_stream_handlers"] = httpTransport.UsesSeparateEventStreamHandlers; + result["http_queued_request_count"] = httpTransport.QueuedRequestCount; + result["http_request_queue_limit"] = httpTransport.MaxQueuedRequests; + result["http_request_log_queue_depth"] = httpTransport.RequestLogQueueDepth; + result["http_request_log_queue_capacity"] = httpTransport.RequestLogQueueCapacity; + result["http_request_log_dropped_count"] = httpTransport.RequestLogDroppedCount; + result["http_request_log_queue_full_drop_count"] = httpTransport.RequestLogQueueFullDropCount; + result["http_request_log_callback_failure_count"] = httpTransport.RequestLogCallbackFailureCount; + result["http_request_log_degraded"] = httpRequestLogDegraded; + if (!string.IsNullOrWhiteSpace(httpTransport.LastRequestLogDropReason)) + result["http_request_log_last_drop_reason"] = httpTransport.LastRequestLogDropReason; + result["http_concurrent_handler_rejection_count"] = httpTransport.ConcurrentHandlerLimitRejectionCount; + result["http_request_queue_rejection_count"] = httpTransport.RequestQueueLimitRejectionCount; + result["http_event_stream_rejection_count"] = httpTransport.EventStreamLimitRejectionCount; + result["http_event_stream_drop_count"] = httpTransport.EventStreamDropCount; + result["http_event_stream_write_failure_drop_count"] = httpTransport.EventStreamWriteFailureDropCount; + if (!string.IsNullOrWhiteSpace(httpTransport.LastEventStreamDropReason)) + result["http_event_stream_last_drop_reason"] = httpTransport.LastEventStreamDropReason; + result["http_auth_denial_count"] = httpTransport.AuthDenialCount; + result["http_auth_denial_missing_count"] = httpTransport.AuthDenialMissingCount; + result["http_auth_denial_ambiguous_count"] = httpTransport.AuthDenialAmbiguousCount; + result["http_auth_denial_wrong_scheme_count"] = httpTransport.AuthDenialWrongSchemeCount; + result["http_auth_denial_malformed_token_count"] = httpTransport.AuthDenialMalformedTokenCount; + result["http_auth_denial_oversized_token_count"] = httpTransport.AuthDenialOversizedTokenCount; + result["http_auth_denial_wrong_token_count"] = httpTransport.AuthDenialWrongTokenCount; + if (!string.IsNullOrWhiteSpace(httpTransport.LastAuthDenialReason)) + result["http_auth_denial_last_reason"] = httpTransport.LastAuthDenialReason; + result["http_auth_required"] = httpTransport.RequiresBearerToken; + result["http_auth_disabled"] = httpTransport.AuthDisabled; + if (!string.IsNullOrWhiteSpace(httpTransport.AuthDisabledWarning)) + result["http_auth_disabled_warning"] = httpTransport.AuthDisabledWarning; + result["http_response_cleanup_degraded"] = httpResponseCleanupDegraded; + result["http_response_abort_cleanup_failure_count"] = httpTransport.ResponseAbortCleanupFailureCount; + result["http_response_close_cleanup_failure_count"] = httpTransport.ResponseCloseCleanupFailureCount; + if (!string.IsNullOrWhiteSpace(httpTransport.LastResponseAbortCleanupFailure)) + result["http_response_abort_cleanup_last_error"] = httpTransport.LastResponseAbortCleanupFailure; + if (!string.IsNullOrWhiteSpace(httpTransport.LastResponseCloseCleanupFailure)) + result["http_response_close_cleanup_last_error"] = httpTransport.LastResponseCloseCleanupFailure; + } + if (auditLogDiagnostics is not null) + result["audit_log"] = BuildAuditLogStatus(auditLogDiagnostics); + result["metrics"] = BuildMetricsStatus(MetricsSink.SnapshotDiagnostics()); + if (!string.IsNullOrWhiteSpace(dbError)) + result["db_error"] = dbError; + return result; + } + + private bool ProbeDbHealth(out string? error) + { + var ok = false; + string? probeError = null; + try + { + using var connection = DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( + _dbPath, + pooling: false, + out _, + out _); + connection.Open(); + using var command = SqliteConnectionPolicy.CreateCommand(connection); + command.CommandText = "SELECT 1;"; + _ = command.ExecuteScalar(); + ok = true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Microsoft.Data.Sqlite.SqliteException or InvalidOperationException) + { + probeError = ex.GetType().Name; + } + + error = probeError; + return ok; + } + + private async Task HandleBatchMessageAsync( + JsonArray batch, + bool isolateRequestDb, + Func? beforeDispatchAsync, + bool rejectForCapacity, + DeferredInitializeCommits? deferredInitializeCommits) + { + if (batch.Count == 0) + return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: empty batch", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "JSON-RPC 2.0 batch requests must contain at least one request object.", + retrySafe: false); + + if (batch.Count > MaxBatchRequestCount) + return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: batch too large", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: $"JSON-RPC batch requests are limited to {MaxBatchRequestCount} items.", + retrySafe: false); + + // Client replies complete server-initiated requests and never produce a response item. + // Consume matched replies before reserving response bytes; unmatched response-shaped + // objects remain ordinary invalid requests and retain their budget slot. + // client reply は server 起点 request を完了し response item を生成しないため、response + // budget 予約前に matched reply を consume する。unmatched object は invalid request として残す。 + var completed = new bool[batch.Count]; + for (var index = 0; index < batch.Count; index++) + { + if (batch[index] is JsonObject itemObject + && TryCompletePendingClientRequest(itemObject)) + { + completed[index] = true; + } + } + + BatchResponseBudgetSlot?[]? budgetSlots = null; + int?[]? batchResponseItemLimits = null; + JsonObject? batchBudgetPreflightError = null; + var batchResponseLimit = 0; + var activeTransportMaxResponseBytes = Volatile.Read(ref _activeTransportMaxResponseBytes); + if (_usesDefaultResponseSerializer) + { + // The complete JSON array owns one response budget. Reserve brackets, commas, and a + // bounded error for every response-bearing item, then divide the remaining bytes + // deterministically before concurrent dispatch. JSON 配列全体で 1 つの response + // budget を共有する。bracket、comma、各 response item の bounded error を予約し、 + // 残りを concurrent dispatch 前に決定的に分配する。 + batchResponseLimit = GetMaxResponseBytes(); + if (activeTransportMaxResponseBytes > 0) + batchResponseLimit = Math.Min(activeTransportMaxResponseBytes, batchResponseLimit); + budgetSlots = new BatchResponseBudgetSlot?[batch.Count]; + batchResponseItemLimits = new int?[batch.Count]; + long reservedErrorBytes = 0; + var responseCount = 0; + for (var index = 0; index < batch.Count; index++) + { + if (completed[index]) + continue; + if (!TryCreateBatchResponseBudgetSlot(batch[index], out var slot)) + continue; + + budgetSlots[index] = slot; + reservedErrorBytes += slot.ErrorResponseBytes; + responseCount++; + } + + if (responseCount > 0) + { + var payloadBytes = batchResponseLimit - 2L - (responseCount - 1L); + if (payloadBytes < reservedErrorBytes) + { + // Defer the terminal budget error until request IDs are durably registered + // and cancellation controls have run. No ordinary or state-changing work is + // dispatched on this path (#4544, #4545). + // terminal budget error は request ID の durable 登録と cancellation control + // 実行後まで保留し、通常処理や他の state mutation は開始しない。 + batchBudgetPreflightError = CreateBatchEnvelopeBudgetError( + batchResponseLimit, + retrySafe: true); + } + else + { + var distributableBytes = payloadBytes - reservedErrorBytes; + var fairShareBytes = distributableBytes / responseCount; + var remainderBytes = distributableBytes % responseCount; + for (var index = 0; index < batch.Count; index++) + { + if (budgetSlots[index] is not { } slot) + continue; + + var itemExtraBytes = fairShareBytes; + if (remainderBytes > 0) + { + itemExtraBytes++; + remainderBytes--; + } + batchResponseItemLimits[index] = checked((int)(slot.ErrorResponseBytes + itemExtraBytes)); + } + + // Equal caps can strand the same resource-serialization fragment in every slot. + // Move one minimum page quantum from the first resources/list slot to the last so + // one concurrent page can consume that deterministic slack without exceeding the + // aggregate cap. 等分時に各 slot へ同じ serialization 断片が残るのを避けるため、 + // 最初の resources/list から最後へ最小 page 予算 1 単位を移す。 + var firstResourceIndex = -1; + var lastResourceIndex = -1; + for (var index = 0; index < batch.Count; index++) + { + if (budgetSlots[index]?.CanShapeResourcesListResponse != true) + continue; + if (firstResourceIndex < 0) + firstResourceIndex = index; + lastResourceIndex = index; + } + if (firstResourceIndex >= 0 && lastResourceIndex != firstResourceIndex) + { + var donorSlot = budgetSlots[firstResourceIndex]!.Value; + var donorLimit = batchResponseItemLimits[firstResourceIndex]!.Value; + var transferableBytes = Math.Min( + MinResourceListMaxBytes, + donorLimit - donorSlot.ErrorResponseBytes); + batchResponseItemLimits[firstResourceIndex] = donorLimit - transferableBytes; + batchResponseItemLimits[lastResourceIndex] = checked( + batchResponseItemLimits[lastResourceIndex]!.Value + transferableBytes); + } + } + } + } + + // A batch is one wire frame but each item is an independently bounded JSON-RPC + // operation (#4545). Invalid items are materialized immediately, cancellation controls + // run eagerly, and state-changing items split the remaining work into ordered segments. + // Response nodes are retained by input index so completion timing cannot reorder the wire + // response. バッチは 1 wire frame だが、各 item を独立した bounded operation として扱う。 + // 不正 item は即時確定し、cancel control は先行処理し、状態変更 item で順序 segment を区切る。 + var responsesByIndex = new JsonNode?[batch.Count]; + var logsByIndex = new DeferredFrameLogBuffer?[batch.Count]; + var orderingFences = new bool[batch.Count]; + var cancellationItems = new bool[batch.Count]; + var queuedRegistrations = new QueuedBatchRequestRegistration?[batch.Count]; + var seenRequestIds = new HashSet(StringComparer.Ordinal); + var isolateBatchItems = isolateRequestDb || batch.Count > 1; + + for (var index = 0; index < batch.Count; index++) + { + if (completed[index]) + continue; + + var item = batch[index]; + if (item is null || item is not JsonObject and not JsonArray) + { + using (BeginBatchItemCorrelation(id: null, index)) + responsesByIndex[index] = CreateInvalidBatchItemResponse(nestedBatch: false); + completed[index] = true; + continue; + } + if (item is JsonArray) + { + using (BeginBatchItemCorrelation(id: null, index)) + responsesByIndex[index] = CreateInvalidBatchItemResponse(nestedBatch: true); + completed[index] = true; + continue; + } + var itemObject = (JsonObject)item; + if (IsCancellationItem(itemObject)) + { + // Execute controls only after this pass has durably registered every unique + // request ID. This preserves eager cancellation even when the control precedes + // its target and the short tombstone cache is full (#4545). + // 全 unique request ID を durable 登録してから control を実行する。cancel が target + // より先でも、短命 tombstone cache が満杯でも eager cancellation を保つ。 + cancellationItems[index] = true; + continue; + } + + orderingFences[index] = IsProtocolOrderingBarrierItem(itemObject); + if (TryGetRequestId(itemObject, out var hasId, out var id) + && hasId + && SerializeRequestId(id) is { } requestKey) + { + if (!seenRequestIds.Add(requestKey)) + { + // Preserve the pre-concurrency behavior for duplicate ids in one batch: the + // later occurrence starts only after the earlier occurrence has completed. + // 同一 batch 内の重複 id は、後続を fence にして従来の逐次 semantics を保つ。 + orderingFences[index] = true; + } + else if (!rejectForCapacity) + { + queuedRegistrations[index] = TryRegisterQueuedBatchRequest(requestKey); + } + } + } + + for (var index = 0; index < batch.Count; index++) + { + if (!cancellationItems[index]) + continue; + + var cancellationResult = await ExecuteBatchItemAsync( + batch[index]!, + index, + isolateRequestDb: true, + beforeDispatchAsync: null, + rejectForCapacity: false, + queuedBatchRegistration: null, + responseItemMaxBytes: batchResponseItemLimits?[index], + deferredInitializeCommits).ConfigureAwait(false); + responsesByIndex[index] = cancellationResult.Response; + logsByIndex[index] = cancellationResult.Logs; + completed[index] = true; + } + + if (batchBudgetPreflightError is not null) + { + foreach (var registration in queuedRegistrations) + registration?.DisposeIfUnclaimed(); + MergeBatchItemLogs(logsByIndex); + return batchBudgetPreflightError; + } + + if (rejectForCapacity) + { + for (var index = 0; index < batch.Count; index++) + { + if (completed[index]) + continue; + var result = await ExecuteBatchItemAsync( + batch[index]!, + index, + isolateBatchItems, + beforeDispatchAsync: null, + rejectForCapacity: true, + queuedBatchRegistration: null, + responseItemMaxBytes: batchResponseItemLimits?[index], + deferredInitializeCommits).ConfigureAwait(false); + responsesByIndex[index] = result.Response; + logsByIndex[index] = result.Logs; + completed[index] = true; + } + + MergeBatchItemLogs(logsByIndex); + return BuildBatchResponse( + responsesByIndex, + budgetSlots, + batchResponseItemLimits, + batchResponseLimit); + } + + var independentSegment = new List(); + for (var index = 0; index < batch.Count; index++) + { + if (completed[index]) + continue; + + if (!orderingFences[index]) + { + independentSegment.Add(index); + continue; + } + + await ExecuteBatchSegmentAsync( + batch, + independentSegment, + isolateBatchItems, + responsesByIndex, + logsByIndex, + queuedRegistrations, + batchResponseItemLimits, + deferredInitializeCommits, + beforeDispatchAsync).ConfigureAwait(false); + independentSegment.Clear(); + await ExecuteBatchItemAsync( + batch[index]!, + index, + isolateBatchItems, + responsesByIndex, + logsByIndex, + beforeDispatchAsync, + queuedRegistrations[index], + batchResponseItemLimits?[index], + deferredInitializeCommits).ConfigureAwait(false); + + var fenceResponse = responsesByIndex[index]; + if (fenceResponse is not null + && deferredInitializeCommits?.TryGetRegisteredState(fenceResponse, out var initializeState) == true) + { + _frameInitializeState.Value = new FrameInitializeState( + BuildCommittedInitializeState(CurrentInitializeState, initializeState, logCallerSwap: false), + isProvisionalGeneration: true); + } + else if (_frameInitializeState.Value is { } currentFrameState + && currentFrameState.TryConsumeAcceptedRootsChange()) + { + var nextState = currentFrameState.IsProvisionalGeneration + ? currentFrameState.Current with { ClientRootsStale = true } + : PublishedInitializeState; + _frameInitializeState.Value = new FrameInitializeState( + nextState, + currentFrameState.IsProvisionalGeneration); + } + } + + await ExecuteBatchSegmentAsync( + batch, + independentSegment, + isolateBatchItems, + responsesByIndex, + logsByIndex, + queuedRegistrations, + batchResponseItemLimits, + deferredInitializeCommits, + beforeDispatchAsync).ConfigureAwait(false); + MergeBatchItemLogs(logsByIndex); + + return BuildBatchResponse( + responsesByIndex, + budgetSlots, + batchResponseItemLimits, + batchResponseLimit); + } + + private QueuedBatchRequestRegistration? TryRegisterQueuedBatchRequest(string requestKey) + { + var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + _currentRequestToken.Value, + _shutdownCts.Token); + var registration = new QueuedBatchRequestRegistration(this, requestKey, cancellation); + if (!_queuedBatchRequests.TryAdd(requestKey, registration)) + { + registration.DisposeIfUnclaimed(); + return null; + } + + if (TryConsumePendingRequestCancellation(requestKey)) + registration.TryCancel(); + return registration; + } + + private JsonNode? BuildBatchResponse( + IReadOnlyList responsesByIndex, + IReadOnlyList? budgetSlots, + IReadOnlyList? responseItemLimits, + int batchResponseLimit) + { + var responses = new JsonArray(); + for (var index = 0; index < responsesByIndex.Count; index++) + { + var response = responsesByIndex[index]; + if (response is not null + && budgetSlots?[index] is { } slot + && responseItemLimits?[index] is { } itemResponseLimit + && !TryMeasureJsonUtf8BytesWithinLimit( + response, + _jsonOptions, + itemResponseLimit, + out _) + && (slot.CanShapeResourcesReadResponse + || (slot.CanShapeResourcesListResponse + && IsResourcesListSuccessResponse(response)))) + { + response = slot.ErrorResponse; + } + + if (response is not null) + responses.Add(response); + } + + if (responses.Count == 0) + return null; + if (batchResponseLimit > 0 + && !TryMeasureJsonUtf8BytesWithinLimit(responses, _jsonOptions, batchResponseLimit, out _)) + { + // Generic and state-changing responses are never rewritten item-by-item. If their + // aggregate exceeds the cap, report an unknown completion state so clients do not + // retry effects unsafely. generic / state-changing response は item ごとに書き換えず、 + // aggregate 超過時は completion unknown を返して危険な retry を防ぐ。 + return CreateBatchEnvelopeBudgetError(batchResponseLimit, retrySafe: false); + } + return responses; + } + + private static JsonObject CreateInvalidBatchItemResponse(bool nestedBatch) + => CreateErrorResponse( + hasId: true, + id: null, + code: -32600, + message: nestedBatch ? "Invalid request: nested batches are not supported" : "Invalid request: expected JSON object", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: nestedBatch + ? "JSON-RPC batch items must be request objects, not nested arrays." + : "Each JSON-RPC batch item must be a request object.", + retrySafe: false); + + private static bool IsCancellationItem(JsonObject item) + => TryGetStringMember(item, "method") is "$/cancelRequest" or "notifications/cancelled"; + + private async Task ExecuteBatchSegmentAsync( + JsonArray batch, + IReadOnlyList indexes, + bool isolateRequestDb, + JsonNode?[] responsesByIndex, + DeferredFrameLogBuffer?[] logsByIndex, + QueuedBatchRequestRegistration?[] queuedRegistrations, + int?[]? responseItemMaxBytes, + DeferredInitializeCommits? deferredInitializeCommits, + Func? beforeDispatchAsync) + { + if (indexes.Count == 0) + return; + + var nextIndex = -1; + var workers = new Task[Math.Min(indexes.Count, MaxConcurrency)]; + for (var workerIndex = 0; workerIndex < workers.Length; workerIndex++) + { + workers[workerIndex] = Task.Run(async () => + { + while (true) + { + var segmentIndex = Interlocked.Increment(ref nextIndex); + if (segmentIndex >= indexes.Count) + return; + + var batchIndex = indexes[segmentIndex]; + await ExecuteBatchItemAsync( + batch[batchIndex]!, + batchIndex, + isolateRequestDb, + responsesByIndex, + logsByIndex, + beforeDispatchAsync, + queuedRegistrations[batchIndex], + responseItemMaxBytes?[batchIndex], + deferredInitializeCommits).ConfigureAwait(false); + } + }, CancellationToken.None); + } + + await Task.WhenAll(workers).ConfigureAwait(false); + } + + private async Task ExecuteBatchItemAsync( + JsonNode item, + int index, + bool isolateRequestDb, + JsonNode?[] responsesByIndex, + DeferredFrameLogBuffer?[] logsByIndex, + Func? beforeDispatchAsync, + QueuedBatchRequestRegistration? queuedBatchRegistration, + int? responseItemMaxBytes, + DeferredInitializeCommits? deferredInitializeCommits) + { + var result = await ExecuteBatchItemAsync( + item, + index, + isolateRequestDb, + beforeDispatchAsync, + rejectForCapacity: false, + queuedBatchRegistration, + responseItemMaxBytes, + deferredInitializeCommits).ConfigureAwait(false); + responsesByIndex[index] = result.Response; + logsByIndex[index] = result.Logs; + } + + private async Task<(JsonNode? Response, DeferredFrameLogBuffer Logs)> ExecuteBatchItemAsync( + JsonNode item, + int index, + bool isolateRequestDb, + Func? beforeDispatchAsync, + bool rejectForCapacity, + QueuedBatchRequestRegistration? queuedBatchRegistration, + int? responseItemMaxBytes, + DeferredInitializeCommits? deferredInitializeCommits) + { + var parentLogs = _deferredFrameLogs.Value; + var previousBatchResponseItemMaxBytes = _currentBatchResponseItemMaxBytes.Value; + var itemLogs = new DeferredFrameLogBuffer(); + _deferredFrameLogs.Value = itemLogs; + _currentBatchResponseItemMaxBytes.Value = responseItemMaxBytes; + Database.DbDebug.ResetContext(); + ExtractResponseId(item, out var hasId, out var id); + var hasTelemetryRequestId = item is JsonObject itemObject + && TryGetRequestId(itemObject, out var itemHasId, out _) + && itemHasId; + using var correlationScope = BeginBatchItemCorrelation(id, index, hasTelemetryRequestId); + try + { + var response = await HandleMessageAsync( + item, + isolateRequestDb, + beforeDispatchAsync, + rejectForCapacity, + queuedBatchRegistration, + deferredInitializeCommits).ConfigureAwait(false); + return (response, itemLogs); + } + catch (Exception ex) + { + DeferFrameLog(BuildUnhandledLoopErrorLog(DiagnosticRedactor.FormatExceptionMessage(ex))); + if (!hasId) + return (null, itemLogs); + + var classification = McpErrorEnvelope.ClassifyException(ex); + return (CreateErrorResponse( + hasId: true, + id, + classification.JsonRpcCode, + BuildSanitizedLoopErrorMessage(ex), + category: classification.Category, + suggestion: classification.Suggestion, + retrySafe: classification.RetrySafe), itemLogs); + } + finally + { + Database.DbDebug.ResetContext(); + _currentBatchResponseItemMaxBytes.Value = previousBatchResponseItemMaxBytes; + _deferredFrameLogs.Value = parentLogs; + queuedBatchRegistration?.DisposeIfUnclaimed(); + } + } + + private void MergeBatchItemLogs(IReadOnlyList logsByIndex) + { + var parentLogs = _deferredFrameLogs.Value; + Action forward = parentLogs is null + ? static log => log() + : parentLogs.Add; + foreach (var itemLogs in logsByIndex) + itemLogs?.ForwardTo(forward); + } + + private bool TryCreateBatchResponseBudgetSlot(JsonNode? item, out BatchResponseBudgetSlot slot) + { + slot = default; + if (!BatchItemRequiresResponse(item, out var responseId)) + return false; + + var canShapeResourcesListResponse = CanShapeResourcesListResponse(item); + var canShapeResourcesReadResponse = CanShapeResourcesReadResponse(item); + var errorResponse = canShapeResourcesReadResponse + ? CreateResourceReadBatchItemBudgetError(responseId) + : CreateBatchItemBudgetError(responseId); + _ = TryMeasureJsonUtf8BytesWithinLimit(errorResponse, _jsonOptions, int.MaxValue, out var errorResponseBytes); + slot = new BatchResponseBudgetSlot( + errorResponse, + errorResponseBytes, + canShapeResourcesListResponse, + canShapeResourcesReadResponse); + return true; + } + + private static bool CanShapeResourcesListResponse(JsonNode? item) + => item is JsonObject request + && TryGetRequestId(request, out var hasId, out _) + && hasId + && TryGetStringMember(request, "jsonrpc") == "2.0" + && TryGetStringMember(request, "method") == "resources/list"; + + private static bool CanShapeResourcesReadResponse(JsonNode? item) + => item is JsonObject request + && TryGetRequestId(request, out var hasId, out _) + && hasId + && TryGetStringMember(request, "jsonrpc") == "2.0" + && TryGetStringMember(request, "method") == "resources/read"; + + private static bool IsResourcesListSuccessResponse(JsonNode response) + => response is JsonObject responseObject + && responseObject["result"] is JsonObject result + && result["resources"] is JsonArray; + + private static bool BatchItemRequiresResponse(JsonNode? item, out JsonNode? responseId) + { + responseId = null; + if (item is not JsonObject request) + return true; + + if (!TryGetRequestId(request, out var hasId, out var id) + || TryGetStringMember(request, "jsonrpc") != "2.0") + { + return true; + } + + var method = TryGetStringMember(request, "method"); + if (method is "$/cancelRequest" + or "notifications/cancelled" + or "notifications/initialized" + or "notifications/roots/list_changed" + or "notifications/shutdown" + or "notifications/exit") + { + return false; + } + if (!hasId) + return false; + + responseId = McpJsonNode.Clone(id); + return true; + } + + private static JsonObject CreateBatchItemBudgetError(JsonNode? id) + => CreateErrorResponse( + hasId: true, + id, + code: -32603, + message: "resources/list could not fit within its share of the active batch response byte limit.", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Request a smaller resources/list page, split the batch, or raise the applicable MCP or transport response byte limit.", + retrySafe: true, + extraData: new JsonObject + { + ["reason"] = "batch_response_budget_exceeded", + }); + + private static JsonObject CreateResourceReadBatchItemBudgetError(JsonNode? id) + => CreateErrorResponse( + hasId: true, + id, + code: -32603, + message: "Batch response budget too small.", + category: McpErrorEnvelope.CategoryInternalError, + suggestion: "Use a smaller JSON-RPC batch and retry.", + retrySafe: false, + extraData: new JsonObject + { + ["reason"] = "batch_response_budget_too_small", + }); + + private static JsonObject CreateBatchEnvelopeBudgetError(int batchResponseLimit, bool retrySafe) + => CreateErrorResponse( + hasId: true, + id: null, + code: -32603, + message: retrySafe + ? "The JSON-RPC batch cannot fit within the active response byte limit." + : "The completed JSON-RPC batch exceeded the active response byte limit.", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: retrySafe + ? "Split the batch into fewer requests or raise the applicable MCP or transport response byte limit." + : "Do not automatically retry state-changing items; their completion state is unknown. Split future batches into fewer requests.", + retrySafe, + extraData: new JsonObject + { + ["reason"] = retrySafe + ? "batch_response_budget_too_small" + : "batch_response_budget_exceeded", + ["limit_bytes"] = batchResponseLimit, + ["completion_state"] = retrySafe ? "not_started" : "unknown", + }); + + private readonly record struct BatchResponseBudgetSlot( + JsonObject ErrorResponse, + int ErrorResponseBytes, + bool CanShapeResourcesListResponse, + bool CanShapeResourcesReadResponse); + + private async Task DispatchWithRequestCancellationAsync( + JsonNode? id, + bool isolateRequestDb, + Func? beforeDispatchAsync, + QueuedBatchRequestRegistration? queuedBatchRegistration, + Func> action) + { + var requestKey = SerializeRequestId(id); + var telemetryRequestId = McpRequestIdTelemetry.Create(id); + var requestCts = queuedBatchRegistration is null + ? CancellationTokenSource.CreateLinkedTokenSource(_currentRequestToken.Value, _shutdownCts.Token) + : CancellationTokenSource.CreateLinkedTokenSource( + _currentRequestToken.Value, + _shutdownCts.Token, + queuedBatchRegistration.Token); + var registeredRequest = false; + if (requestKey is not null) + { + if (!_activeRequests.TryAdd(requestKey, requestCts)) + { + requestCts.Dispose(); + return CreateErrorResponse(hasId: true, id: id, code: -32600, message: "Duplicate in-flight request id", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "JSON-RPC request ids must be unique while a previous request with the same id is still running.", + retrySafe: true); + } + registeredRequest = true; + if (queuedBatchRegistration is not null && !queuedBatchRegistration.TryClaim()) + CancelRequestCts(requestCts); + if (TryConsumePendingRequestCancellation(requestKey)) + CancelRequestCts(requestCts); + RequestRegisteredForTests?.Invoke(id); + } + + var previousToken = _currentRequestToken.Value; + Stopwatch? stopwatch = null; + var cleanupNow = true; + var executionSlotAcquired = false; + var releaseExecutionSlotNow = true; + try + { + _currentRequestToken.Value = requestCts.Token; + requestCts.Token.ThrowIfCancellationRequested(); + if (beforeDispatchAsync is not null) + await beforeDispatchAsync(requestCts.Token).ConfigureAwait(false); + await _concurrencyGate.WaitAsync(requestCts.Token).ConfigureAwait(false); + executionSlotAcquired = true; + requestCts.Token.ThrowIfCancellationRequested(); + stopwatch = Stopwatch.StartNew(); + + if (!isolateRequestDb) + { + requestCts.CancelAfter(_requestTimeout); + var previousIsolation = _isolateDbForCurrentRequest.Value; + _isolateDbForCurrentRequest.Value = false; + try + { + await DelayRequestForTestsAsync(id, requestCts.Token).ConfigureAwait(false); + return await action().ConfigureAwait(false); + } + finally + { + _isolateDbForCurrentRequest.Value = previousIsolation; + } + } + + var actionTask = Task.Run(async () => + { + var previousIsolation = _isolateDbForCurrentRequest.Value; + _isolateDbForCurrentRequest.Value = isolateRequestDb; + try + { + await DelayRequestForTestsAsync(id, requestCts.Token).ConfigureAwait(false); + return await action().ConfigureAwait(false); + } + finally + { + _isolateDbForCurrentRequest.Value = previousIsolation; + } + }, requestCts.Token); + using var timeoutDelayCts = new CancellationTokenSource(); + var remainingTimeout = _requestTimeout - stopwatch.Elapsed; + var timeoutTask = remainingTimeout <= TimeSpan.Zero + ? Task.CompletedTask + : Task.Delay(remainingTimeout, timeoutDelayCts.Token); + var cancellationSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellationRegistration = requestCts.Token.Register( + static state => ((TaskCompletionSource)state!).TrySetResult(true), + cancellationSignal); + var cancellationTask = cancellationSignal.Task; + var completed = await Task.WhenAny(actionTask, timeoutTask, cancellationTask).ConfigureAwait(false); + try { timeoutDelayCts.Cancel(); } + catch (ObjectDisposedException) { /* the timeout signal has already completed. */ } + if (completed == cancellationTask && _shutdownCts.IsCancellationRequested) + { + // EOF/server shutdown owns the bounded outer request-task drain. Keep this + // dispatch attached to non-cooperative work so teardown does not manufacture a + // late cancellation response or race a terminal protocol-error write (#4543). + // EOF/server shutdown は外側の bounded request-task drain が所有する。非協調 work を + // detach せず、遅延 cancel response や terminal protocol-error write との race を防ぐ。 + return await actionTask.ConfigureAwait(false); + } + if (completed != actionTask) + { + var timedOut = completed == timeoutTask; + if (timedOut) + CancelRequestCts(requestCts); + var elapsed = stopwatch.Elapsed; + if (timedOut) + RecordTimedOutIsolatedActionDraining(telemetryRequestId, elapsed); + cleanupNow = false; + releaseExecutionSlotNow = false; + _currentDetachedIsolatedActions.Value?.Enqueue(actionTask); + // This cleanup must run even after request timeout/shutdown cancellation; + // otherwise `_activeRequests`, the linked CTS, and the execution lease would leak + // when an isolated action eventually observes cancellation and exits. The lease + // intentionally remains held until the underlying action actually ends so timeout + // responses cannot let live handlers exceed MaxConcurrency (#3722, #4536, #4545). + // request timeout / shutdown cancellation 後でも cleanup は必ず実行する。 + // underlying action が実際に終了するまで execution lease も保持し、timeout response + // の後に live handler が MaxConcurrency を超えないようにする (#3722, #4536, #4545)。 + _ = actionTask.ContinueWith(task => + { + try + { + _ = task.Exception; + if (registeredRequest) + _activeRequests.TryRemove(requestKey!, out _); + if (timedOut) + RecordTimedOutIsolatedActionDrained(telemetryRequestId, task); + } + finally + { + requestCts.Dispose(); + _concurrencyGate.Release(); + } + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + return timedOut + ? CreateRequestTimeoutResponse(id, elapsed, isolatedActionDraining: true) + : CreateCancelledResponse(id); + } + + return await actionTask.ConfigureAwait(false); + } + catch (OperationCanceledException) when (requestCts.IsCancellationRequested) + { + if (stopwatch is not null + && !previousToken.IsCancellationRequested + && !_shutdownCts.IsCancellationRequested + && stopwatch.Elapsed >= _requestTimeout) + return CreateRequestTimeoutResponse(id, stopwatch.Elapsed); + return CreateCancelledResponse(id); + } + finally + { + _currentRequestToken.Value = previousToken; + if (executionSlotAcquired && releaseExecutionSlotNow) + _concurrencyGate.Release(); + if (cleanupNow) + { + if (registeredRequest) + _activeRequests.TryRemove(requestKey!, out _); + requestCts.Dispose(); + } + } + } + + private Task DelayRequestForTestsAsync(JsonNode? id, CancellationToken cancellationToken) + { + if (RequestDelayForTestsWithId is { } delayWithId) + return delayWithId(McpJsonNode.Clone(id), cancellationToken); + return RequestDelayForTests is { } delay + ? delay(cancellationToken) + : Task.CompletedTask; + } + + private static JsonObject CreateRequestTimeoutResponse(JsonNode? id, TimeSpan elapsed, bool isolatedActionDraining = false) + => CreateErrorResponse(hasId: true, id: id, code: -32603, message: "Request timed out", + category: McpErrorEnvelope.CategoryInternalError, + suggestion: "Retry with a narrower query, refresh the index if it is degraded, or increase the MCP request timeout before retrying.", + retrySafe: true, + extraData: new JsonObject + { + ["reason"] = "timeout", + ["timeout_category"] = OperationTimeoutCategories.McpRequest, + ["elapsed_ms"] = (long)Math.Ceiling(elapsed.TotalMilliseconds), + ["isolated_action_draining"] = isolatedActionDraining, + }); + + private void RecordTimedOutIsolatedActionDraining(McpRequestIdTelemetryData requestId, TimeSpan elapsed) + { + var elapsedMs = (long)Math.Ceiling(elapsed.TotalMilliseconds); + Interlocked.Increment(ref _timedOutIsolatedActionDrainingCount); + lock (_requestTimeoutDiagnosticsGate) + { + _lastRequestTimeoutDrainDiagnostic = new RequestTimeoutDrainDiagnostic( + requestId, + elapsedMs, + "draining"); + } + CommandErrorWriter.WriteStderr(BuildTimedOutIsolatedActionDrainingLog(requestId, elapsedMs)); + } + + private void RecordTimedOutIsolatedActionDrained(McpRequestIdTelemetryData requestId, Task task) + { + Interlocked.Decrement(ref _timedOutIsolatedActionDrainingCount); + Interlocked.Increment(ref _timedOutIsolatedActionDrainedCount); + var state = task.IsCanceled ? "canceled" : task.IsFaulted ? "faulted" : "completed"; + lock (_requestTimeoutDiagnosticsGate) + { + _lastRequestTimeoutDrainDiagnostic = new RequestTimeoutDrainDiagnostic( + requestId, + null, + state); + } + } + + internal JsonObject BuildRequestTimeoutDiagnosticsStatus() + { + RequestTimeoutDrainDiagnostic? last; + lock (_requestTimeoutDiagnosticsGate) + { + last = _lastRequestTimeoutDrainDiagnostic; + } + + var payload = new JsonObject + { + ["isolated_action_draining_count"] = Interlocked.Read(ref _timedOutIsolatedActionDrainingCount), + ["isolated_action_drained_count"] = Interlocked.Read(ref _timedOutIsolatedActionDrainedCount), + ["timeout_ms"] = (long)Math.Ceiling(_requestTimeout.TotalMilliseconds), + }; + if (last is not null) + { + payload["last"] = new JsonObject + { + ["request_id"] = last.RequestId.Token, + ["request_id_type"] = last.RequestId.Type, + ["request_id_length"] = last.RequestId.Length, + ["elapsed_ms"] = last.ElapsedMs.HasValue ? JsonValue.Create(last.ElapsedMs.Value) : null, + ["state"] = last.State, + }; + } + return payload; + } + + internal static string BuildTimedOutIsolatedActionDrainingLog(McpRequestIdTelemetryData requestId, long elapsedMs) + => $"[cdidx-mcp] Request timed out while isolated action is still draining: request_id={requestId.Token} request_id_type={requestId.Type} request_id_length={requestId.Length.ToString(CultureInfo.InvariantCulture)} elapsed_ms={elapsedMs}. The response has been sent; cleanup will continue in the background."; + + private static IDisposable BeginRequestCorrelation(JsonNode? id, bool includeRequestId = true) + { + var previous = CurrentCorrelationContext.Value; + CurrentCorrelationContext.Value = new RequestCorrelationContext( + SerializeRequestId(id), + includeRequestId ? McpRequestIdTelemetry.Create(id) : null, + Guid.NewGuid().ToString("D")); + return new CorrelationScope(previous); + } + + private static IDisposable BeginBatchItemCorrelation(JsonNode? id, int itemIndex, bool includeRequestId = false) + { + var previous = CurrentCorrelationContext.Value; + var correlationId = previous is null + ? Guid.NewGuid().ToString("D") + : $"{previous.CorrelationId}.{itemIndex.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; + CurrentCorrelationContext.Value = new RequestCorrelationContext( + SerializeRequestId(id), + includeRequestId ? McpRequestIdTelemetry.Create(id) : null, + correlationId); + return new CorrelationScope(previous); + } + + private static IDisposable BeginChildCorrelation(int childIndex) + { + var previous = CurrentCorrelationContext.Value; + var requestId = previous?.WireRequestId; + var telemetryRequestId = previous?.TelemetryRequestId; + var correlationId = previous == null + ? Guid.NewGuid().ToString("D") + : $"{previous.CorrelationId}.{childIndex.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; + CurrentCorrelationContext.Value = new RequestCorrelationContext(requestId, telemetryRequestId, correlationId); + return new CorrelationScope(previous); + } + + private sealed record RequestCorrelationContext( + string? WireRequestId, + McpRequestIdTelemetryData? TelemetryRequestId, + string CorrelationId); + private sealed record RequestTimeoutDrainDiagnostic( + McpRequestIdTelemetryData RequestId, + long? ElapsedMs, + string State); + + private sealed class CorrelationScope : IDisposable + { + private readonly RequestCorrelationContext? _previous; + private bool _disposed; + + public CorrelationScope(RequestCorrelationContext? previous) + { + _previous = previous; + } + + public void Dispose() + { + if (_disposed) + return; + CurrentCorrelationContext.Value = _previous; + _disposed = true; + } + } + + private void TryCancelRequest(JsonNode? cancelParams) + { + var requestId = cancelParams?["id"] ?? cancelParams?["requestId"]; + var requestKey = SerializeRequestId(requestId); + if (requestKey == null) + return; + if (_activeRequests.TryGetValue(requestKey, out var cts)) + { + CancelRequestCts(cts); + return; + } + if (_queuedBatchRequests.TryGetValue(requestKey, out var queuedRequest) + && queuedRequest.TryCancel()) + { + return; + } + + CancellationRegistriesMissedForTests?.Invoke(); + RememberPendingRequestCancellation(requestKey); + if (_activeRequests.TryGetValue(requestKey, out cts)) + { + _ = TryConsumePendingRequestCancellation(requestKey); + CancelRequestCts(cts); + return; + } + if (_queuedBatchRequests.TryGetValue(requestKey, out queuedRequest)) + { + // The target can enter the durable registry after the first lookup but before the + // bounded tombstone insertion. Recheck it independently of tombstone capacity so a + // full cache cannot discard cancellation for an already-queued batch item (#4545). + // target は初回 lookup 後、bounded tombstone 挿入前に durable registry へ入り得る。 + // tombstone capacity と独立して再確認し、満杯でも登録済み batch item の cancel を + // 失わないようにする (#4545)。 + _ = TryConsumePendingRequestCancellation(requestKey); + if (queuedRequest.TryCancel()) + return; + if (_activeRequests.TryGetValue(requestKey, out cts)) + { + CancelRequestCts(cts); + return; + } + } + } + + private void RememberPendingRequestCancellation(string requestKey) + { + var now = _timeProvider.GetUtcNow(); + PrunePendingRequestCancellations(now); + if (_pendingRequestCancellations.Count < MaxPendingRequestCancellationCount) + _pendingRequestCancellations[requestKey] = now; + } + + private bool TryConsumePendingRequestCancellation(string requestKey) + { + var now = _timeProvider.GetUtcNow(); + PrunePendingRequestCancellations(now); + if (!_pendingRequestCancellations.TryGetValue(requestKey, out var cancelledAt)) + return false; + if (now - cancelledAt > PendingRequestCancellationTtl) + { + _pendingRequestCancellations.TryRemove(requestKey, out _); + return false; + } + + return _pendingRequestCancellations.TryRemove(requestKey, out _); + } + + private void PrunePendingRequestCancellations(DateTimeOffset now) + { + foreach (var entry in _pendingRequestCancellations) + { + if (now - entry.Value > PendingRequestCancellationTtl) + _pendingRequestCancellations.TryRemove(entry.Key, out _); + } + } + + private static void CancelRequestCts(CancellationTokenSource cts) + { + try { cts.Cancel(); } + catch (ObjectDisposedException) { /* completed while cancellation was being delivered. */ } + } + + private static bool IsCancellationFrame(string frame) + { + if (!JsonFrameParser.TryParseNode(frame, MaxJsonDepth, out var node, out _) + || node is not JsonObject obj) + return false; + + var method = TryGetStringMember(obj, "method"); + return string.Equals(method, "$/cancelRequest", StringComparison.Ordinal) + || string.Equals(method, "notifications/cancelled", StringComparison.Ordinal); + } + + private static bool IsProtocolOrderingBarrierFrame(string frame) + { + if (!JsonFrameParser.TryParseNode(frame, MaxJsonDepth, out var node, out _)) + return false; + + if (node is JsonArray batch) + return batch.Any(IsProtocolOrderingBarrierItem); + return IsProtocolOrderingBarrierItem(node); + } + + private static bool IsProtocolOrderingBarrierItem(JsonNode? node) + { + if (node is not JsonObject obj) + return false; + + return TryGetStringMember(obj, "method") switch + { + "initialize" or + "logging/setLevel" or + "notifications/initialized" or + "notifications/roots/list_changed" or + "notifications/shutdown" or + "notifications/exit" => true, + _ => false, + }; + } + + // Safe accessor that returns null instead of throwing when `name` is missing OR present + // with a non-string value. JsonNode's `GetValue()` throws InvalidOperationException + // on non-string scalars, which would bubble out of HandleMessage and turn into -32603 + // before the auth gate runs. + // `name` が無いケースと文字列以外で存在するケースのどちらでも null を返す安全アクセサ。 + // JsonNode の `GetValue()` は非文字列で例外を投げ、認証ゲート前に -32603 化して + // しまう。 + private static string? TryGetStringMember(JsonObject obj, string name) + { + if (!obj.TryGetPropertyValue(name, out var node) || node is null) + return null; + try + { + return node.GetValue(); + } + catch + { + return null; + } + } + + // Cap on the logged `method` label. Long enough for every spec method (`notifications/cancelled` + // is 23 chars) and any plausible client extension, short enough to keep one log line readable. + // ログ出力する `method` の長さ上限。仕様メソッド全てと拡張も収まる長さで、1 行を読みやすく保つ。 + private const int LoggedMethodMaxLength = 64; + + // Strip caller-controlled control characters from `method` and clamp its length before + // interpolating into a stderr log line. Prevents log forging: a malicious client could + // otherwise send `"method":"evil\n[forged]"` and split the diagnostic across two lines + // (#1559). + // stderr 行に method を埋め込む前に制御文字を除去し、長さを切る。これをしないと + // `"method":"evil\n[forged]"` で診断ログを 2 行に分割するログ偽造ができてしまう (#1559)。 + internal static string SanitizeMethodForLog(string? method) + { + if (string.IsNullOrEmpty(method)) + return "(none)"; + var sb = new StringBuilder(Math.Min(method.Length, LoggedMethodMaxLength)); + var truncated = false; + foreach (var ch in method) + { + if (sb.Length >= LoggedMethodMaxLength) + { + truncated = true; + break; + } + if (ch < 0x20 || ch == 0x7F) + sb.Append('?'); + else + sb.Append(ch); + } + if (truncated) + sb.Append('…'); + return sb.ToString(); + } + + // Stderr log for an auth failure. Mirrors the #1530 sanitization pattern: keep the + // wire response generic and put the detail on stderr for local diagnostics. The method + // label is run through SanitizeMethodForLog because it is caller-controlled and reaches + // stderr before any allow-list check (#1559). + // 認証失敗の stderr ログ。#1530 のサニタイズ方針に倣い、ワイヤ応答は一般化したまま + // 詳細だけを stderr に残す。method は認証前に通るため SanitizeMethodForLog で + // 制御文字除去と長さ切詰めを行う (#1559)。 + internal static string BuildAuthFailureLog(string? method, string? reason) => + $"[cdidx-mcp] Auth failed for method {SanitizeMethodForLog(method)}: {reason ?? "(unspecified)"}. Set CDIDX_MCP_AUTH_TOKEN on the server and include a matching params.auth.token on each request."; + +} diff --git a/src/CodeIndex/Mcp/McpServer.Transport.cs b/src/CodeIndex/Mcp/McpServer.Transport.cs index b48628f17..b23d5f7ac 100644 --- a/src/CodeIndex/Mcp/McpServer.Transport.cs +++ b/src/CodeIndex/Mcp/McpServer.Transport.cs @@ -16,22 +16,19 @@ namespace CodeIndex.Mcp; -/// -/// MCP (Model Context Protocol) server speaking JSON-RPC 2.0 over a pluggable transport. The -/// default preserves the historic stdin/stdout wire path, and -/// exposes the same JSON-RPC catalog over POST so AI clients can -/// share a warm server across sessions (issue #1558). -/// プラガブルな 上で JSON-RPC 2.0 を話す MCP サーバー。既定の -/// は従来通り stdin/stdout を使い、 -/// は同じ JSON-RPC カタログを POST で公開して、複数クライアントから暖機済みサーバーを共有できるようにする -/// (issue #1558)。 -/// Supported protocol versions: see (negotiated per -/// `initialize` request, #1554). -/// 対応プロトコルバージョン: 参照(`initialize` ごとに交渉, #1554)。 -/// public partial class McpServer : IDisposable { - + /// + /// Run the MCP server loop on the default stdio transport. Kept as a thin wrapper around + /// so existing callers stay + /// source-compatible after the #1558 transport refactor. SIGINT (Ctrl+C) and SIGTERM are + /// translated into loop cancellation so orchestrators (systemd, launchd, supervisord) can + /// achieve a clean shutdown instead of hanging until stdin closes (#1573). + /// 既定の stdio トランスポートで MCP ループを動かす。#1558 のトランスポート抽象化後も + /// 既存呼び出しがソース互換となるよう + /// のラッパとして残す。SIGINT (Ctrl+C) と SIGTERM をループキャンセルに変換し、stdin が閉じる + /// まで固まる旧挙動を解消する(systemd / launchd / supervisord から graceful shutdown 可能に, #1573)。 + /// public async Task RunAsync() { await using var transport = new StdioMcpTransport(StdioBufferSize); diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 7fbbac798..639acce18 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -469,1614 +469,6 @@ internal TimeSpan InFlightPostCancelGracePeriod : value; } - /// - /// Run the MCP server loop on the default stdio transport. Kept as a thin wrapper around - /// so existing callers stay - /// source-compatible after the #1558 transport refactor. SIGINT (Ctrl+C) and SIGTERM are - /// translated into loop cancellation so orchestrators (systemd, launchd, supervisord) can - /// achieve a clean shutdown instead of hanging until stdin closes (#1573). - /// 既定の stdio トランスポートで MCP ループを動かす。#1558 のトランスポート抽象化後も - /// 既存呼び出しがソース互換となるよう - /// のラッパとして残す。SIGINT (Ctrl+C) と SIGTERM をループキャンセルに変換し、stdin が閉じる - /// まで固まる旧挙動を解消する(systemd / launchd / supervisord から graceful shutdown 可能に, #1573)。 - /// - - /// - /// Route a JSON-RPC message to the appropriate handler. This synchronous wrapper is retained - /// for compatibility tests and legacy in-process callers only; transports should prefer - /// to avoid sync-over-async dispatch (#3770). - /// JSON-RPCメッセージを適切なハンドラにルーティング。この同期ラッパは互換テストと legacy - /// in-process 呼び出し専用に残し、transport は sync-over-async dispatch を避けるため - /// を優先する (#3770)。 - /// - internal JsonNode? HandleMessage(JsonNode request) - // Keep this sync wrapper for existing in-process callers; async transports call - // HandleMessageAsync so server loops do not need a sync-over-async bridge. - => HandleMessageAsync( - request, - isolateRequestDb: false, - beforeDispatchAsync: null, - rejectForCapacity: false, - queuedBatchRegistration: null, - deferredInitializeCommits: null).GetAwaiter().GetResult(); - - internal Task HandleMessageAsync(JsonNode request) - => HandleMessageAsync( - request, - isolateRequestDb: false, - beforeDispatchAsync: null, - rejectForCapacity: false, - queuedBatchRegistration: null, - deferredInitializeCommits: null); - - private async Task HandleMessageAsync( - JsonNode request, - bool isolateRequestDb, - Func? beforeDispatchAsync, - bool rejectForCapacity, - QueuedBatchRequestRegistration? queuedBatchRegistration, - DeferredInitializeCommits? deferredInitializeCommits) - { - if (request is JsonArray batch) - { - if (deferredInitializeCommits is null) - { - return await HandleBatchMessageAsync( - batch, - isolateRequestDb, - beforeDispatchAsync, - rejectForCapacity, - deferredInitializeCommits).ConfigureAwait(false); - } - - var previousFrameInitializeState = _frameInitializeState.Value; - var initialFrameInitializeState = CurrentInitializeState; - var frameInitializeState = new FrameInitializeState( - initialFrameInitializeState, - isProvisionalGeneration: false); - _frameInitializeState.Value = frameInitializeState; - var batchBeforeDispatchAsync = beforeDispatchAsync; - if (beforeDispatchAsync is not null) - { - batchBeforeDispatchAsync = async cancellationToken => - { - await beforeDispatchAsync(cancellationToken).ConfigureAwait(false); - // The concurrent loop accepts and pre-registers a batch before its protocol - // predecessor finishes. Advance only this batch's original generation after - // that predecessor commits; timed-out older frames retain their own holders, - // and an in-batch initialize replaces this holder instead of being overwritten. - // concurrent loop は protocol predecessor 完了前に batch を受理・事前登録する。 - // predecessor の commit 後、この batch の元 generation だけを進める。timeout - // 後の旧 frame は別 holder を保持し、batch 内 initialize は holder 自体を置換する。 - frameInitializeState.TryAdvanceToPublishedGeneration( - initialFrameInitializeState, - PublishedInitializeState); - }; - } - try - { - return await HandleBatchMessageAsync( - batch, - isolateRequestDb, - batchBeforeDispatchAsync, - rejectForCapacity, - deferredInitializeCommits).ConfigureAwait(false); - } - finally - { - _frameInitializeState.Value = previousFrameInitializeState; - } - } - - if (request is not JsonObject obj) - return CreateExpectedJsonObjectErrorResponse(); - - lock (_healthStateGate) - _lastRequestAt = _timeProvider.GetUtcNow(); - - // Extract `method` defensively: a non-string `method` (e.g. `"method":42`) must not - // throw before the auth gate runs, otherwise a token-protected server would surface - // `-32603 "Internal error"` to an unauthenticated caller instead of `-32001 - // "Unauthorized"`, leaking that the request reached dispatch internals (#1559). - // `method` は防御的に取り出す。`"method":42` のような非文字列が GetValue() - // で例外を投げると、認証ゲート前に -32603 が返ってしまい、未認証呼び出し元に dispatch - // 内部まで届いた事実が漏れる (#1559)。 - var method = TryGetStringMember(obj, "method"); - if (!TryGetRequestId(obj, out var hasId, out var id, out var idError)) - return CreateErrorResponse(hasId: true, id: null, code: -32600, message: BuildInvalidRequestIdMessage(idError), - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: BuildInvalidRequestIdSuggestion(idError), - retrySafe: false, - extraData: BuildInvalidRequestIdData(idError)); - - if (TryGetStringMember(obj, "jsonrpc") != "2.0") - return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: jsonrpc must be exactly \"2.0\"", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: "Set the top-level `jsonrpc` member to the string `2.0`.", - retrySafe: false); - - using var correlationScope = hasId && CurrentCorrelationContext.Value is null ? BeginRequestCorrelation(id) : null; - - // A JSON-RPC notification cannot carry an error response, but that does not make it - // safe to bypass authentication when handling it mutates server state. Authenticate - // every state-changing notification before cancellation, roots, or lifecycle state is - // touched; on denial, emit only the bounded local diagnostic and preserve the required - // no-response wire contract (#4537). - // JSON-RPC notification はエラー応答を持てないが、server state を変更する通知まで認証を - // 省略してよいことにはならない。cancellation / roots / lifecycle state に触れる前に認証し、 - // 拒否時は bounded なローカル診断だけを残して no-response 契約を維持する (#4537)。 - if (IsStateChangingNotification(method)) - { - var notificationAuth = _authenticator.Authenticate(request); - if (!notificationAuth.IsAuthenticated) - { - WriteMcpLogLine(BuildAuthFailureLog(method, notificationAuth.FailureReason)); - return null; - } - } - - if (method == "$/cancelRequest" || method == "notifications/cancelled") - { - TryCancelRequest(request["params"]); - return null; - } - - if (rejectForCapacity && IsStateChangingNotification(method)) - { - // Eager cancellation is handled above. Other state notifications are dropped on - // admission overflow regardless of a malformed id, matching the normal no-id - // overload contract without mutating roots or lifecycle state (#4536, #4545). - // eager cancellation は上で処理済み。それ以外の state notification は malformed - // id の有無に関係なく admission overflow 時に drop し、roots/lifecycle を変更しない。 - return null; - } - - var protocolPredecessorAwaited = false; - if (IsStateChangingNotification(method) && beforeDispatchAsync is not null) - { - // Cancellation controls intentionally bypass protocol barriers, but roots/lifecycle - // notifications must not mutate state before an earlier initialize commits. Apply the - // method semantic even when a malformed client attaches an id to the notification. - // cancellation control は protocol barrier を bypass する一方、roots/lifecycle - // notification は先行 initialize の commit 前に state を変更してはならない。 - // malformed client が id を付けた場合も method semantics に基づいて待機する。 - await beforeDispatchAsync(_currentRequestToken.Value).ConfigureAwait(false); - protocolPredecessorAwaited = true; - } - - if (!hasId) - { - if (rejectForCapacity) - return null; - if (!protocolPredecessorAwaited && beforeDispatchAsync is not null) - await beforeDispatchAsync(_currentRequestToken.Value).ConfigureAwait(false); - } - - // Notifications (no id) don't get a response / 通知(idなし)にはレスポンスなし - if (method == "notifications/initialized") - return null; - - if (method == "notifications/roots/list_changed") - { - MarkClientRootsStale(); - _frameInitializeState.Value?.MarkRootsChangeAccepted(); - return null; - } - - // Graceful shutdown via JSON-RPC notification (#1567). Without this, the only way to - // stop a long-lived `cdidx mcp` server was to close the transport (stdin EOF / HTTP - // listener stop), which races with in-flight work and forces clients to send SIGINT. - // Treating both `notifications/shutdown` (the MCP spec-aligned name) and the legacy - // LSP-style `notifications/exit` alias as graceful-stop signals lets clients drain the - // current request and exit cleanly. Asynchronous cancellation unblocks any pending - // `ReadFrameAsync` without letting a slow user callback hold the dispatch thread (#4543). - // JSON-RPC 通知による graceful shutdown (#1567)。非同期 cancellation で slow callback に - // dispatch thread を塞がせず `ReadFrameAsync` を unblock する (#4543)。 - if (string.Equals(method, "notifications/shutdown", StringComparison.Ordinal) - || string.Equals(method, "notifications/exit", StringComparison.Ordinal)) - { - WriteMcpLogLine($"[cdidx-mcp] Received {method}; draining in-flight work and shutting down."); - _running = false; - _ = RequestShutdownCancellation(); - return null; - } - - if (!hasId) - { - if (method != null && method.StartsWith("notifications/", StringComparison.OrdinalIgnoreCase)) - WriteMcpLogLine(BuildUnknownNotificationLog(method)); - return null; - } - - // Authenticate every responded request before dispatch so the auth contract is - // uniform across `initialize`, `tools/list`, `tools/call`, and `ping`. Run auth even - // when `method` is missing or malformed so a token-protected server cannot be probed - // for method-shape errors without credentials (#1559). State-changing notifications - // pass through their own auth gate above; side-effect-free notifications short-circuit - // without authentication because they produce no response. - // すべての応答対象リクエストを dispatch 前に認証する。`method` が欠落・不正でも - // 認証は走らせ、トークン保護下のサーバーで未認証呼び出し元に method 形式エラーを - // 漏らさない (#1559)。state-changing notification は上の専用ゲートで認証し、 - // 副作用のない notification だけを応答なしで short-circuit する。 - var authResult = _authenticator.Authenticate(request); - if (!authResult.IsAuthenticated) - { - DeferFrameLog(BuildAuthFailureLog(method, authResult.FailureReason)); - return CreateErrorResponse(hasId: true, id: id, code: McpErrorEnvelope.CodeUnauthorized, message: "Unauthorized", - category: McpErrorEnvelope.CategoryPermissionDenied, - suggestion: "Set CDIDX_MCP_AUTH_TOKEN on the server and include a matching params.auth.token (or an `Authorization: Bearer ` header for HTTP) on each request.", - retrySafe: false); - } - - if (rejectForCapacity) - return CreateServerBusyResponse(id); - - if (method == null) - { - return CreateErrorResponse(hasId: true, id: id, code: -32600, message: "Invalid request: missing method", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: "JSON-RPC 2.0 requires a string `method` field.", - retrySafe: false); - } - - return await DispatchWithRequestCancellationAsync(id, isolateRequestDb, beforeDispatchAsync, queuedBatchRegistration, () => - { - if (_enforceInitializationLifecycle && !CurrentInitializeState.Initialized && method != "initialize") - { - return Task.FromResult(CreateErrorResponse(hasId: true, id: id, code: -32002, message: "Server not initialized", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: "Send a successful `initialize` request before calling other MCP methods.", - retrySafe: true)); - } - - return method switch - { - "initialize" => Task.FromResult(HandleInitialize( - id, - request["params"], - deferredInitializeCommits)), - "tools/list" => Task.FromResult(HandleToolsList(id, request["params"])), - "tools/call" => HandleToolsCallAsync(hasId, id, request["params"]), - "resources/list" => Task.FromResult(HandleResourcesList(id, request["params"])), - "resources/templates/list" => Task.FromResult(HandleResourceTemplatesList(id, request["params"])), - "resources/read" => Task.FromResult(HandleResourcesRead(id, request["params"])), - "prompts/list" => Task.FromResult(HandlePromptsList(id)), - "prompts/get" => Task.FromResult(HandlePromptsGet(id, request["params"])), - "logging/setLevel" => HandleLoggingSetLevelAsync(id, request["params"]), - "ping" => Task.FromResult(CreateSuccessResponse(hasId, id, BuildHealthResult())), - _ => Task.FromResult(CreateErrorResponse(hasId: true, id: id, code: -32601, message: $"Method not found: {method}", - category: McpErrorEnvelope.CategoryMethodNotFound, - suggestion: "Supported methods: initialize, tools/list, tools/call, resources/list, resources/templates/list, resources/read, prompts/list, prompts/get, logging/setLevel, ping, notifications/initialized, notifications/cancelled, notifications/shutdown.", - retrySafe: false)), - }; - }).ConfigureAwait(false); - } - - private static JsonObject CreateExpectedJsonObjectErrorResponse() - => CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: expected JSON object", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: "Send a JSON-RPC 2.0 object (e.g. {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}).", - retrySafe: false); - - private static bool IsStateChangingNotification(string? method) - => method is "$/cancelRequest" - or "notifications/cancelled" - or "notifications/roots/list_changed" - or "notifications/shutdown" - or "notifications/exit"; - - private static JsonObject CreateServerBusyResponse(JsonNode? id) - => CreateErrorResponse( - hasId: true, - id, - McpErrorEnvelope.CodeServerBusy, - "Server busy: MCP request backlog is full", - category: McpErrorEnvelope.CategoryServerBusy, - suggestion: "Retry after one or more in-flight MCP requests complete.", - retrySafe: true, - extraData: new JsonObject { ["retry_after_ms"] = 1000 }); - - private string BuildHealthJson(HttpMcpTransport? httpTransport = null) - => BuildHealthResult(httpTransport).ToJsonString(_jsonOptions); - - private string BuildKeepAliveNotificationJson() - { - var now = _timeProvider.GetUtcNow(); - var notification = new JsonObject - { - ["jsonrpc"] = "2.0", - ["method"] = "notifications/keep_alive", - ["params"] = new JsonObject - { - ["server_time"] = now.ToString("O", System.Globalization.CultureInfo.InvariantCulture), - ["uptime_s"] = Math.Max(0, (long)Math.Floor((now - _startedAt).TotalSeconds)), - } - }; - return notification.ToJsonString(_jsonOptions); - } - - private static TimeSpan? ReadKeepAliveIntervalFromEnvironment() - { - var raw = global::CodeIndex.EnvironmentAccess.GetProcessEnvironmentVariable(KeepAliveIntervalEnvironmentVariable); - if (string.IsNullOrWhiteSpace(raw)) - return null; - if (!double.TryParse(raw, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds) - || !double.IsFinite(seconds) - || seconds < MinKeepAliveIntervalSeconds - || seconds > MaxKeepAliveIntervalSeconds) - { - var displayValue = DiagnosticRedactor.FormatEnvironmentValue(KeepAliveIntervalEnvironmentVariable, raw); - CommandErrorWriter.WriteStderr( - $"[cdidx-mcp] Ignoring invalid {KeepAliveIntervalEnvironmentVariable}='{displayValue}'. Expected a finite value between {MinKeepAliveIntervalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} and {MaxKeepAliveIntervalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} seconds. Keep-alive notifications stay disabled."); - return null; - } - return TimeSpan.FromSeconds(seconds); - } - - private JsonObject BuildHealthResult(HttpMcpTransport? httpTransport = null) - { - var now = _timeProvider.GetUtcNow(); - var dbOpen = ProbeDbHealth(out var dbError); - var httpResponseCleanupDegraded = httpTransport?.ResponseCleanupDegraded ?? false; - var httpRequestLogDegraded = httpTransport?.RequestLogDegraded ?? false; - var auditLogDiagnostics = _auditLog?.SnapshotDiagnostics(); - var auditLogDegraded = IsAuditLogDegraded(auditLogDiagnostics); - DateTimeOffset lastRequestAt; - lock (_healthStateGate) - lastRequestAt = _lastRequestAt; - var result = new JsonObject - { - ["status"] = dbOpen && !httpResponseCleanupDegraded && !httpRequestLogDegraded && !auditLogDegraded ? "ok" : "degraded", - ["uptime_s"] = Math.Max(0, (long)Math.Floor((now - _startedAt).TotalSeconds)), - ["last_request_at"] = lastRequestAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), - ["db_open"] = dbOpen, - ["last_db_check_at"] = now.ToString("O", System.Globalization.CultureInfo.InvariantCulture), - ["transport_ready"] = _running, - }; - if (httpTransport is not null) - { - result["http_max_request_body_bytes"] = httpTransport.MaxRequestBodyBytes; - result["http_request_body_idle_timeout_ms"] = (long)httpTransport.RequestBodyIdleTimeout.TotalMilliseconds; - result["http_request_lifetime_timeout_ms"] = (long)httpTransport.RequestLifetimeTimeout.TotalMilliseconds; - result["http_request_body_budget_limit_bytes"] = httpTransport.MaxInFlightRequestBodyBytes; - result["http_request_body_bytes_in_flight"] = httpTransport.InFlightRequestBodyBytes; - result["http_request_body_process_bytes_in_flight"] = httpTransport.ProcessInFlightRequestBodyBytes; - result["http_request_body_peak_bytes"] = httpTransport.PeakInFlightRequestBodyBytes; - result["http_request_body_budget_scope"] = "process"; - result["http_request_body_budget_rejection_count"] = httpTransport.RequestBodyBudgetLimitRejectionCount; - result["http_request_body_idle_timeout_count"] = httpTransport.RequestBodyIdleTimeoutCount; - result["http_request_lifetime_timeout_count"] = httpTransport.RequestLifetimeTimeoutCount; - result["http_client_disconnect_count"] = httpTransport.ClientDisconnectCount; - result["http_queued_request_cancellation_count"] = httpTransport.QueuedRequestCancellationCount; - result["http_event_stream_count"] = httpTransport.EventStreamCount; - result["http_event_stream_limit"] = httpTransport.MaxEventStreams; - result["http_max_concurrent_handlers"] = httpTransport.MaxConcurrentHandlers; - result["http_post_handler_capacity"] = httpTransport.PostHandlerCapacity; - result["http_event_stream_handler_capacity"] = httpTransport.EventStreamHandlerCapacity; - result["http_separate_event_stream_handlers"] = httpTransport.UsesSeparateEventStreamHandlers; - result["http_queued_request_count"] = httpTransport.QueuedRequestCount; - result["http_request_queue_limit"] = httpTransport.MaxQueuedRequests; - result["http_request_log_queue_depth"] = httpTransport.RequestLogQueueDepth; - result["http_request_log_queue_capacity"] = httpTransport.RequestLogQueueCapacity; - result["http_request_log_dropped_count"] = httpTransport.RequestLogDroppedCount; - result["http_request_log_queue_full_drop_count"] = httpTransport.RequestLogQueueFullDropCount; - result["http_request_log_callback_failure_count"] = httpTransport.RequestLogCallbackFailureCount; - result["http_request_log_degraded"] = httpRequestLogDegraded; - if (!string.IsNullOrWhiteSpace(httpTransport.LastRequestLogDropReason)) - result["http_request_log_last_drop_reason"] = httpTransport.LastRequestLogDropReason; - result["http_concurrent_handler_rejection_count"] = httpTransport.ConcurrentHandlerLimitRejectionCount; - result["http_request_queue_rejection_count"] = httpTransport.RequestQueueLimitRejectionCount; - result["http_event_stream_rejection_count"] = httpTransport.EventStreamLimitRejectionCount; - result["http_event_stream_drop_count"] = httpTransport.EventStreamDropCount; - result["http_event_stream_write_failure_drop_count"] = httpTransport.EventStreamWriteFailureDropCount; - if (!string.IsNullOrWhiteSpace(httpTransport.LastEventStreamDropReason)) - result["http_event_stream_last_drop_reason"] = httpTransport.LastEventStreamDropReason; - result["http_auth_denial_count"] = httpTransport.AuthDenialCount; - result["http_auth_denial_missing_count"] = httpTransport.AuthDenialMissingCount; - result["http_auth_denial_ambiguous_count"] = httpTransport.AuthDenialAmbiguousCount; - result["http_auth_denial_wrong_scheme_count"] = httpTransport.AuthDenialWrongSchemeCount; - result["http_auth_denial_malformed_token_count"] = httpTransport.AuthDenialMalformedTokenCount; - result["http_auth_denial_oversized_token_count"] = httpTransport.AuthDenialOversizedTokenCount; - result["http_auth_denial_wrong_token_count"] = httpTransport.AuthDenialWrongTokenCount; - if (!string.IsNullOrWhiteSpace(httpTransport.LastAuthDenialReason)) - result["http_auth_denial_last_reason"] = httpTransport.LastAuthDenialReason; - result["http_auth_required"] = httpTransport.RequiresBearerToken; - result["http_auth_disabled"] = httpTransport.AuthDisabled; - if (!string.IsNullOrWhiteSpace(httpTransport.AuthDisabledWarning)) - result["http_auth_disabled_warning"] = httpTransport.AuthDisabledWarning; - result["http_response_cleanup_degraded"] = httpResponseCleanupDegraded; - result["http_response_abort_cleanup_failure_count"] = httpTransport.ResponseAbortCleanupFailureCount; - result["http_response_close_cleanup_failure_count"] = httpTransport.ResponseCloseCleanupFailureCount; - if (!string.IsNullOrWhiteSpace(httpTransport.LastResponseAbortCleanupFailure)) - result["http_response_abort_cleanup_last_error"] = httpTransport.LastResponseAbortCleanupFailure; - if (!string.IsNullOrWhiteSpace(httpTransport.LastResponseCloseCleanupFailure)) - result["http_response_close_cleanup_last_error"] = httpTransport.LastResponseCloseCleanupFailure; - } - if (auditLogDiagnostics is not null) - result["audit_log"] = BuildAuditLogStatus(auditLogDiagnostics); - result["metrics"] = BuildMetricsStatus(MetricsSink.SnapshotDiagnostics()); - if (!string.IsNullOrWhiteSpace(dbError)) - result["db_error"] = dbError; - return result; - } - - private bool ProbeDbHealth(out string? error) - { - var ok = false; - string? probeError = null; - try - { - using var connection = DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( - _dbPath, - pooling: false, - out _, - out _); - connection.Open(); - using var command = SqliteConnectionPolicy.CreateCommand(connection); - command.CommandText = "SELECT 1;"; - _ = command.ExecuteScalar(); - ok = true; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Microsoft.Data.Sqlite.SqliteException or InvalidOperationException) - { - probeError = ex.GetType().Name; - } - - error = probeError; - return ok; - } - - private async Task HandleBatchMessageAsync( - JsonArray batch, - bool isolateRequestDb, - Func? beforeDispatchAsync, - bool rejectForCapacity, - DeferredInitializeCommits? deferredInitializeCommits) - { - if (batch.Count == 0) - return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: empty batch", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: "JSON-RPC 2.0 batch requests must contain at least one request object.", - retrySafe: false); - - if (batch.Count > MaxBatchRequestCount) - return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: batch too large", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: $"JSON-RPC batch requests are limited to {MaxBatchRequestCount} items.", - retrySafe: false); - - // Client replies complete server-initiated requests and never produce a response item. - // Consume matched replies before reserving response bytes; unmatched response-shaped - // objects remain ordinary invalid requests and retain their budget slot. - // client reply は server 起点 request を完了し response item を生成しないため、response - // budget 予約前に matched reply を consume する。unmatched object は invalid request として残す。 - var completed = new bool[batch.Count]; - for (var index = 0; index < batch.Count; index++) - { - if (batch[index] is JsonObject itemObject - && TryCompletePendingClientRequest(itemObject)) - { - completed[index] = true; - } - } - - BatchResponseBudgetSlot?[]? budgetSlots = null; - int?[]? batchResponseItemLimits = null; - JsonObject? batchBudgetPreflightError = null; - var batchResponseLimit = 0; - var activeTransportMaxResponseBytes = Volatile.Read(ref _activeTransportMaxResponseBytes); - if (_usesDefaultResponseSerializer) - { - // The complete JSON array owns one response budget. Reserve brackets, commas, and a - // bounded error for every response-bearing item, then divide the remaining bytes - // deterministically before concurrent dispatch. JSON 配列全体で 1 つの response - // budget を共有する。bracket、comma、各 response item の bounded error を予約し、 - // 残りを concurrent dispatch 前に決定的に分配する。 - batchResponseLimit = GetMaxResponseBytes(); - if (activeTransportMaxResponseBytes > 0) - batchResponseLimit = Math.Min(activeTransportMaxResponseBytes, batchResponseLimit); - budgetSlots = new BatchResponseBudgetSlot?[batch.Count]; - batchResponseItemLimits = new int?[batch.Count]; - long reservedErrorBytes = 0; - var responseCount = 0; - for (var index = 0; index < batch.Count; index++) - { - if (completed[index]) - continue; - if (!TryCreateBatchResponseBudgetSlot(batch[index], out var slot)) - continue; - - budgetSlots[index] = slot; - reservedErrorBytes += slot.ErrorResponseBytes; - responseCount++; - } - - if (responseCount > 0) - { - var payloadBytes = batchResponseLimit - 2L - (responseCount - 1L); - if (payloadBytes < reservedErrorBytes) - { - // Defer the terminal budget error until request IDs are durably registered - // and cancellation controls have run. No ordinary or state-changing work is - // dispatched on this path (#4544, #4545). - // terminal budget error は request ID の durable 登録と cancellation control - // 実行後まで保留し、通常処理や他の state mutation は開始しない。 - batchBudgetPreflightError = CreateBatchEnvelopeBudgetError( - batchResponseLimit, - retrySafe: true); - } - else - { - var distributableBytes = payloadBytes - reservedErrorBytes; - var fairShareBytes = distributableBytes / responseCount; - var remainderBytes = distributableBytes % responseCount; - for (var index = 0; index < batch.Count; index++) - { - if (budgetSlots[index] is not { } slot) - continue; - - var itemExtraBytes = fairShareBytes; - if (remainderBytes > 0) - { - itemExtraBytes++; - remainderBytes--; - } - batchResponseItemLimits[index] = checked((int)(slot.ErrorResponseBytes + itemExtraBytes)); - } - - // Equal caps can strand the same resource-serialization fragment in every slot. - // Move one minimum page quantum from the first resources/list slot to the last so - // one concurrent page can consume that deterministic slack without exceeding the - // aggregate cap. 等分時に各 slot へ同じ serialization 断片が残るのを避けるため、 - // 最初の resources/list から最後へ最小 page 予算 1 単位を移す。 - var firstResourceIndex = -1; - var lastResourceIndex = -1; - for (var index = 0; index < batch.Count; index++) - { - if (budgetSlots[index]?.CanShapeResourcesListResponse != true) - continue; - if (firstResourceIndex < 0) - firstResourceIndex = index; - lastResourceIndex = index; - } - if (firstResourceIndex >= 0 && lastResourceIndex != firstResourceIndex) - { - var donorSlot = budgetSlots[firstResourceIndex]!.Value; - var donorLimit = batchResponseItemLimits[firstResourceIndex]!.Value; - var transferableBytes = Math.Min( - MinResourceListMaxBytes, - donorLimit - donorSlot.ErrorResponseBytes); - batchResponseItemLimits[firstResourceIndex] = donorLimit - transferableBytes; - batchResponseItemLimits[lastResourceIndex] = checked( - batchResponseItemLimits[lastResourceIndex]!.Value + transferableBytes); - } - } - } - } - - // A batch is one wire frame but each item is an independently bounded JSON-RPC - // operation (#4545). Invalid items are materialized immediately, cancellation controls - // run eagerly, and state-changing items split the remaining work into ordered segments. - // Response nodes are retained by input index so completion timing cannot reorder the wire - // response. バッチは 1 wire frame だが、各 item を独立した bounded operation として扱う。 - // 不正 item は即時確定し、cancel control は先行処理し、状態変更 item で順序 segment を区切る。 - var responsesByIndex = new JsonNode?[batch.Count]; - var logsByIndex = new DeferredFrameLogBuffer?[batch.Count]; - var orderingFences = new bool[batch.Count]; - var cancellationItems = new bool[batch.Count]; - var queuedRegistrations = new QueuedBatchRequestRegistration?[batch.Count]; - var seenRequestIds = new HashSet(StringComparer.Ordinal); - var isolateBatchItems = isolateRequestDb || batch.Count > 1; - - for (var index = 0; index < batch.Count; index++) - { - if (completed[index]) - continue; - - var item = batch[index]; - if (item is null || item is not JsonObject and not JsonArray) - { - using (BeginBatchItemCorrelation(id: null, index)) - responsesByIndex[index] = CreateInvalidBatchItemResponse(nestedBatch: false); - completed[index] = true; - continue; - } - if (item is JsonArray) - { - using (BeginBatchItemCorrelation(id: null, index)) - responsesByIndex[index] = CreateInvalidBatchItemResponse(nestedBatch: true); - completed[index] = true; - continue; - } - var itemObject = (JsonObject)item; - if (IsCancellationItem(itemObject)) - { - // Execute controls only after this pass has durably registered every unique - // request ID. This preserves eager cancellation even when the control precedes - // its target and the short tombstone cache is full (#4545). - // 全 unique request ID を durable 登録してから control を実行する。cancel が target - // より先でも、短命 tombstone cache が満杯でも eager cancellation を保つ。 - cancellationItems[index] = true; - continue; - } - - orderingFences[index] = IsProtocolOrderingBarrierItem(itemObject); - if (TryGetRequestId(itemObject, out var hasId, out var id) - && hasId - && SerializeRequestId(id) is { } requestKey) - { - if (!seenRequestIds.Add(requestKey)) - { - // Preserve the pre-concurrency behavior for duplicate ids in one batch: the - // later occurrence starts only after the earlier occurrence has completed. - // 同一 batch 内の重複 id は、後続を fence にして従来の逐次 semantics を保つ。 - orderingFences[index] = true; - } - else if (!rejectForCapacity) - { - queuedRegistrations[index] = TryRegisterQueuedBatchRequest(requestKey); - } - } - } - - for (var index = 0; index < batch.Count; index++) - { - if (!cancellationItems[index]) - continue; - - var cancellationResult = await ExecuteBatchItemAsync( - batch[index]!, - index, - isolateRequestDb: true, - beforeDispatchAsync: null, - rejectForCapacity: false, - queuedBatchRegistration: null, - responseItemMaxBytes: batchResponseItemLimits?[index], - deferredInitializeCommits).ConfigureAwait(false); - responsesByIndex[index] = cancellationResult.Response; - logsByIndex[index] = cancellationResult.Logs; - completed[index] = true; - } - - if (batchBudgetPreflightError is not null) - { - foreach (var registration in queuedRegistrations) - registration?.DisposeIfUnclaimed(); - MergeBatchItemLogs(logsByIndex); - return batchBudgetPreflightError; - } - - if (rejectForCapacity) - { - for (var index = 0; index < batch.Count; index++) - { - if (completed[index]) - continue; - var result = await ExecuteBatchItemAsync( - batch[index]!, - index, - isolateBatchItems, - beforeDispatchAsync: null, - rejectForCapacity: true, - queuedBatchRegistration: null, - responseItemMaxBytes: batchResponseItemLimits?[index], - deferredInitializeCommits).ConfigureAwait(false); - responsesByIndex[index] = result.Response; - logsByIndex[index] = result.Logs; - completed[index] = true; - } - - MergeBatchItemLogs(logsByIndex); - return BuildBatchResponse( - responsesByIndex, - budgetSlots, - batchResponseItemLimits, - batchResponseLimit); - } - - var independentSegment = new List(); - for (var index = 0; index < batch.Count; index++) - { - if (completed[index]) - continue; - - if (!orderingFences[index]) - { - independentSegment.Add(index); - continue; - } - - await ExecuteBatchSegmentAsync( - batch, - independentSegment, - isolateBatchItems, - responsesByIndex, - logsByIndex, - queuedRegistrations, - batchResponseItemLimits, - deferredInitializeCommits, - beforeDispatchAsync).ConfigureAwait(false); - independentSegment.Clear(); - await ExecuteBatchItemAsync( - batch[index]!, - index, - isolateBatchItems, - responsesByIndex, - logsByIndex, - beforeDispatchAsync, - queuedRegistrations[index], - batchResponseItemLimits?[index], - deferredInitializeCommits).ConfigureAwait(false); - - var fenceResponse = responsesByIndex[index]; - if (fenceResponse is not null - && deferredInitializeCommits?.TryGetRegisteredState(fenceResponse, out var initializeState) == true) - { - _frameInitializeState.Value = new FrameInitializeState( - BuildCommittedInitializeState(CurrentInitializeState, initializeState, logCallerSwap: false), - isProvisionalGeneration: true); - } - else if (_frameInitializeState.Value is { } currentFrameState - && currentFrameState.TryConsumeAcceptedRootsChange()) - { - var nextState = currentFrameState.IsProvisionalGeneration - ? currentFrameState.Current with { ClientRootsStale = true } - : PublishedInitializeState; - _frameInitializeState.Value = new FrameInitializeState( - nextState, - currentFrameState.IsProvisionalGeneration); - } - } - - await ExecuteBatchSegmentAsync( - batch, - independentSegment, - isolateBatchItems, - responsesByIndex, - logsByIndex, - queuedRegistrations, - batchResponseItemLimits, - deferredInitializeCommits, - beforeDispatchAsync).ConfigureAwait(false); - MergeBatchItemLogs(logsByIndex); - - return BuildBatchResponse( - responsesByIndex, - budgetSlots, - batchResponseItemLimits, - batchResponseLimit); - } - - private QueuedBatchRequestRegistration? TryRegisterQueuedBatchRequest(string requestKey) - { - var cancellation = CancellationTokenSource.CreateLinkedTokenSource( - _currentRequestToken.Value, - _shutdownCts.Token); - var registration = new QueuedBatchRequestRegistration(this, requestKey, cancellation); - if (!_queuedBatchRequests.TryAdd(requestKey, registration)) - { - registration.DisposeIfUnclaimed(); - return null; - } - - if (TryConsumePendingRequestCancellation(requestKey)) - registration.TryCancel(); - return registration; - } - - private JsonNode? BuildBatchResponse( - IReadOnlyList responsesByIndex, - IReadOnlyList? budgetSlots, - IReadOnlyList? responseItemLimits, - int batchResponseLimit) - { - var responses = new JsonArray(); - for (var index = 0; index < responsesByIndex.Count; index++) - { - var response = responsesByIndex[index]; - if (response is not null - && budgetSlots?[index] is { } slot - && responseItemLimits?[index] is { } itemResponseLimit - && !TryMeasureJsonUtf8BytesWithinLimit( - response, - _jsonOptions, - itemResponseLimit, - out _) - && (slot.CanShapeResourcesReadResponse - || (slot.CanShapeResourcesListResponse - && IsResourcesListSuccessResponse(response)))) - { - response = slot.ErrorResponse; - } - - if (response is not null) - responses.Add(response); - } - - if (responses.Count == 0) - return null; - if (batchResponseLimit > 0 - && !TryMeasureJsonUtf8BytesWithinLimit(responses, _jsonOptions, batchResponseLimit, out _)) - { - // Generic and state-changing responses are never rewritten item-by-item. If their - // aggregate exceeds the cap, report an unknown completion state so clients do not - // retry effects unsafely. generic / state-changing response は item ごとに書き換えず、 - // aggregate 超過時は completion unknown を返して危険な retry を防ぐ。 - return CreateBatchEnvelopeBudgetError(batchResponseLimit, retrySafe: false); - } - return responses; - } - - private static JsonObject CreateInvalidBatchItemResponse(bool nestedBatch) - => CreateErrorResponse( - hasId: true, - id: null, - code: -32600, - message: nestedBatch ? "Invalid request: nested batches are not supported" : "Invalid request: expected JSON object", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: nestedBatch - ? "JSON-RPC batch items must be request objects, not nested arrays." - : "Each JSON-RPC batch item must be a request object.", - retrySafe: false); - - private static bool IsCancellationItem(JsonObject item) - => TryGetStringMember(item, "method") is "$/cancelRequest" or "notifications/cancelled"; - - private async Task ExecuteBatchSegmentAsync( - JsonArray batch, - IReadOnlyList indexes, - bool isolateRequestDb, - JsonNode?[] responsesByIndex, - DeferredFrameLogBuffer?[] logsByIndex, - QueuedBatchRequestRegistration?[] queuedRegistrations, - int?[]? responseItemMaxBytes, - DeferredInitializeCommits? deferredInitializeCommits, - Func? beforeDispatchAsync) - { - if (indexes.Count == 0) - return; - - var nextIndex = -1; - var workers = new Task[Math.Min(indexes.Count, MaxConcurrency)]; - for (var workerIndex = 0; workerIndex < workers.Length; workerIndex++) - { - workers[workerIndex] = Task.Run(async () => - { - while (true) - { - var segmentIndex = Interlocked.Increment(ref nextIndex); - if (segmentIndex >= indexes.Count) - return; - - var batchIndex = indexes[segmentIndex]; - await ExecuteBatchItemAsync( - batch[batchIndex]!, - batchIndex, - isolateRequestDb, - responsesByIndex, - logsByIndex, - beforeDispatchAsync, - queuedRegistrations[batchIndex], - responseItemMaxBytes?[batchIndex], - deferredInitializeCommits).ConfigureAwait(false); - } - }, CancellationToken.None); - } - - await Task.WhenAll(workers).ConfigureAwait(false); - } - - private async Task ExecuteBatchItemAsync( - JsonNode item, - int index, - bool isolateRequestDb, - JsonNode?[] responsesByIndex, - DeferredFrameLogBuffer?[] logsByIndex, - Func? beforeDispatchAsync, - QueuedBatchRequestRegistration? queuedBatchRegistration, - int? responseItemMaxBytes, - DeferredInitializeCommits? deferredInitializeCommits) - { - var result = await ExecuteBatchItemAsync( - item, - index, - isolateRequestDb, - beforeDispatchAsync, - rejectForCapacity: false, - queuedBatchRegistration, - responseItemMaxBytes, - deferredInitializeCommits).ConfigureAwait(false); - responsesByIndex[index] = result.Response; - logsByIndex[index] = result.Logs; - } - - private async Task<(JsonNode? Response, DeferredFrameLogBuffer Logs)> ExecuteBatchItemAsync( - JsonNode item, - int index, - bool isolateRequestDb, - Func? beforeDispatchAsync, - bool rejectForCapacity, - QueuedBatchRequestRegistration? queuedBatchRegistration, - int? responseItemMaxBytes, - DeferredInitializeCommits? deferredInitializeCommits) - { - var parentLogs = _deferredFrameLogs.Value; - var previousBatchResponseItemMaxBytes = _currentBatchResponseItemMaxBytes.Value; - var itemLogs = new DeferredFrameLogBuffer(); - _deferredFrameLogs.Value = itemLogs; - _currentBatchResponseItemMaxBytes.Value = responseItemMaxBytes; - Database.DbDebug.ResetContext(); - ExtractResponseId(item, out var hasId, out var id); - var hasTelemetryRequestId = item is JsonObject itemObject - && TryGetRequestId(itemObject, out var itemHasId, out _) - && itemHasId; - using var correlationScope = BeginBatchItemCorrelation(id, index, hasTelemetryRequestId); - try - { - var response = await HandleMessageAsync( - item, - isolateRequestDb, - beforeDispatchAsync, - rejectForCapacity, - queuedBatchRegistration, - deferredInitializeCommits).ConfigureAwait(false); - return (response, itemLogs); - } - catch (Exception ex) - { - DeferFrameLog(BuildUnhandledLoopErrorLog(DiagnosticRedactor.FormatExceptionMessage(ex))); - if (!hasId) - return (null, itemLogs); - - var classification = McpErrorEnvelope.ClassifyException(ex); - return (CreateErrorResponse( - hasId: true, - id, - classification.JsonRpcCode, - BuildSanitizedLoopErrorMessage(ex), - category: classification.Category, - suggestion: classification.Suggestion, - retrySafe: classification.RetrySafe), itemLogs); - } - finally - { - Database.DbDebug.ResetContext(); - _currentBatchResponseItemMaxBytes.Value = previousBatchResponseItemMaxBytes; - _deferredFrameLogs.Value = parentLogs; - queuedBatchRegistration?.DisposeIfUnclaimed(); - } - } - - private void MergeBatchItemLogs(IReadOnlyList logsByIndex) - { - var parentLogs = _deferredFrameLogs.Value; - Action forward = parentLogs is null - ? static log => log() - : parentLogs.Add; - foreach (var itemLogs in logsByIndex) - itemLogs?.ForwardTo(forward); - } - - private bool TryCreateBatchResponseBudgetSlot(JsonNode? item, out BatchResponseBudgetSlot slot) - { - slot = default; - if (!BatchItemRequiresResponse(item, out var responseId)) - return false; - - var canShapeResourcesListResponse = CanShapeResourcesListResponse(item); - var canShapeResourcesReadResponse = CanShapeResourcesReadResponse(item); - var errorResponse = canShapeResourcesReadResponse - ? CreateResourceReadBatchItemBudgetError(responseId) - : CreateBatchItemBudgetError(responseId); - _ = TryMeasureJsonUtf8BytesWithinLimit(errorResponse, _jsonOptions, int.MaxValue, out var errorResponseBytes); - slot = new BatchResponseBudgetSlot( - errorResponse, - errorResponseBytes, - canShapeResourcesListResponse, - canShapeResourcesReadResponse); - return true; - } - - private static bool CanShapeResourcesListResponse(JsonNode? item) - => item is JsonObject request - && TryGetRequestId(request, out var hasId, out _) - && hasId - && TryGetStringMember(request, "jsonrpc") == "2.0" - && TryGetStringMember(request, "method") == "resources/list"; - - private static bool CanShapeResourcesReadResponse(JsonNode? item) - => item is JsonObject request - && TryGetRequestId(request, out var hasId, out _) - && hasId - && TryGetStringMember(request, "jsonrpc") == "2.0" - && TryGetStringMember(request, "method") == "resources/read"; - - private static bool IsResourcesListSuccessResponse(JsonNode response) - => response is JsonObject responseObject - && responseObject["result"] is JsonObject result - && result["resources"] is JsonArray; - - private static bool BatchItemRequiresResponse(JsonNode? item, out JsonNode? responseId) - { - responseId = null; - if (item is not JsonObject request) - return true; - - if (!TryGetRequestId(request, out var hasId, out var id) - || TryGetStringMember(request, "jsonrpc") != "2.0") - { - return true; - } - - var method = TryGetStringMember(request, "method"); - if (method is "$/cancelRequest" - or "notifications/cancelled" - or "notifications/initialized" - or "notifications/roots/list_changed" - or "notifications/shutdown" - or "notifications/exit") - { - return false; - } - if (!hasId) - return false; - - responseId = McpJsonNode.Clone(id); - return true; - } - - private static JsonObject CreateBatchItemBudgetError(JsonNode? id) - => CreateErrorResponse( - hasId: true, - id, - code: -32603, - message: "resources/list could not fit within its share of the active batch response byte limit.", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Request a smaller resources/list page, split the batch, or raise the applicable MCP or transport response byte limit.", - retrySafe: true, - extraData: new JsonObject - { - ["reason"] = "batch_response_budget_exceeded", - }); - - private static JsonObject CreateResourceReadBatchItemBudgetError(JsonNode? id) - => CreateErrorResponse( - hasId: true, - id, - code: -32603, - message: "Batch response budget too small.", - category: McpErrorEnvelope.CategoryInternalError, - suggestion: "Use a smaller JSON-RPC batch and retry.", - retrySafe: false, - extraData: new JsonObject - { - ["reason"] = "batch_response_budget_too_small", - }); - - private static JsonObject CreateBatchEnvelopeBudgetError(int batchResponseLimit, bool retrySafe) - => CreateErrorResponse( - hasId: true, - id: null, - code: -32603, - message: retrySafe - ? "The JSON-RPC batch cannot fit within the active response byte limit." - : "The completed JSON-RPC batch exceeded the active response byte limit.", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: retrySafe - ? "Split the batch into fewer requests or raise the applicable MCP or transport response byte limit." - : "Do not automatically retry state-changing items; their completion state is unknown. Split future batches into fewer requests.", - retrySafe, - extraData: new JsonObject - { - ["reason"] = retrySafe - ? "batch_response_budget_too_small" - : "batch_response_budget_exceeded", - ["limit_bytes"] = batchResponseLimit, - ["completion_state"] = retrySafe ? "not_started" : "unknown", - }); - - private readonly record struct BatchResponseBudgetSlot( - JsonObject ErrorResponse, - int ErrorResponseBytes, - bool CanShapeResourcesListResponse, - bool CanShapeResourcesReadResponse); - - private async Task DispatchWithRequestCancellationAsync( - JsonNode? id, - bool isolateRequestDb, - Func? beforeDispatchAsync, - QueuedBatchRequestRegistration? queuedBatchRegistration, - Func> action) - { - var requestKey = SerializeRequestId(id); - var telemetryRequestId = McpRequestIdTelemetry.Create(id); - var requestCts = queuedBatchRegistration is null - ? CancellationTokenSource.CreateLinkedTokenSource(_currentRequestToken.Value, _shutdownCts.Token) - : CancellationTokenSource.CreateLinkedTokenSource( - _currentRequestToken.Value, - _shutdownCts.Token, - queuedBatchRegistration.Token); - var registeredRequest = false; - if (requestKey is not null) - { - if (!_activeRequests.TryAdd(requestKey, requestCts)) - { - requestCts.Dispose(); - return CreateErrorResponse(hasId: true, id: id, code: -32600, message: "Duplicate in-flight request id", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: "JSON-RPC request ids must be unique while a previous request with the same id is still running.", - retrySafe: true); - } - registeredRequest = true; - if (queuedBatchRegistration is not null && !queuedBatchRegistration.TryClaim()) - CancelRequestCts(requestCts); - if (TryConsumePendingRequestCancellation(requestKey)) - CancelRequestCts(requestCts); - RequestRegisteredForTests?.Invoke(id); - } - - var previousToken = _currentRequestToken.Value; - Stopwatch? stopwatch = null; - var cleanupNow = true; - var executionSlotAcquired = false; - var releaseExecutionSlotNow = true; - try - { - _currentRequestToken.Value = requestCts.Token; - requestCts.Token.ThrowIfCancellationRequested(); - if (beforeDispatchAsync is not null) - await beforeDispatchAsync(requestCts.Token).ConfigureAwait(false); - await _concurrencyGate.WaitAsync(requestCts.Token).ConfigureAwait(false); - executionSlotAcquired = true; - requestCts.Token.ThrowIfCancellationRequested(); - stopwatch = Stopwatch.StartNew(); - - if (!isolateRequestDb) - { - requestCts.CancelAfter(_requestTimeout); - var previousIsolation = _isolateDbForCurrentRequest.Value; - _isolateDbForCurrentRequest.Value = false; - try - { - await DelayRequestForTestsAsync(id, requestCts.Token).ConfigureAwait(false); - return await action().ConfigureAwait(false); - } - finally - { - _isolateDbForCurrentRequest.Value = previousIsolation; - } - } - - var actionTask = Task.Run(async () => - { - var previousIsolation = _isolateDbForCurrentRequest.Value; - _isolateDbForCurrentRequest.Value = isolateRequestDb; - try - { - await DelayRequestForTestsAsync(id, requestCts.Token).ConfigureAwait(false); - return await action().ConfigureAwait(false); - } - finally - { - _isolateDbForCurrentRequest.Value = previousIsolation; - } - }, requestCts.Token); - using var timeoutDelayCts = new CancellationTokenSource(); - var remainingTimeout = _requestTimeout - stopwatch.Elapsed; - var timeoutTask = remainingTimeout <= TimeSpan.Zero - ? Task.CompletedTask - : Task.Delay(remainingTimeout, timeoutDelayCts.Token); - var cancellationSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var cancellationRegistration = requestCts.Token.Register( - static state => ((TaskCompletionSource)state!).TrySetResult(true), - cancellationSignal); - var cancellationTask = cancellationSignal.Task; - var completed = await Task.WhenAny(actionTask, timeoutTask, cancellationTask).ConfigureAwait(false); - try { timeoutDelayCts.Cancel(); } - catch (ObjectDisposedException) { /* the timeout signal has already completed. */ } - if (completed == cancellationTask && _shutdownCts.IsCancellationRequested) - { - // EOF/server shutdown owns the bounded outer request-task drain. Keep this - // dispatch attached to non-cooperative work so teardown does not manufacture a - // late cancellation response or race a terminal protocol-error write (#4543). - // EOF/server shutdown は外側の bounded request-task drain が所有する。非協調 work を - // detach せず、遅延 cancel response や terminal protocol-error write との race を防ぐ。 - return await actionTask.ConfigureAwait(false); - } - if (completed != actionTask) - { - var timedOut = completed == timeoutTask; - if (timedOut) - CancelRequestCts(requestCts); - var elapsed = stopwatch.Elapsed; - if (timedOut) - RecordTimedOutIsolatedActionDraining(telemetryRequestId, elapsed); - cleanupNow = false; - releaseExecutionSlotNow = false; - _currentDetachedIsolatedActions.Value?.Enqueue(actionTask); - // This cleanup must run even after request timeout/shutdown cancellation; - // otherwise `_activeRequests`, the linked CTS, and the execution lease would leak - // when an isolated action eventually observes cancellation and exits. The lease - // intentionally remains held until the underlying action actually ends so timeout - // responses cannot let live handlers exceed MaxConcurrency (#3722, #4536, #4545). - // request timeout / shutdown cancellation 後でも cleanup は必ず実行する。 - // underlying action が実際に終了するまで execution lease も保持し、timeout response - // の後に live handler が MaxConcurrency を超えないようにする (#3722, #4536, #4545)。 - _ = actionTask.ContinueWith(task => - { - try - { - _ = task.Exception; - if (registeredRequest) - _activeRequests.TryRemove(requestKey!, out _); - if (timedOut) - RecordTimedOutIsolatedActionDrained(telemetryRequestId, task); - } - finally - { - requestCts.Dispose(); - _concurrencyGate.Release(); - } - }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - return timedOut - ? CreateRequestTimeoutResponse(id, elapsed, isolatedActionDraining: true) - : CreateCancelledResponse(id); - } - - return await actionTask.ConfigureAwait(false); - } - catch (OperationCanceledException) when (requestCts.IsCancellationRequested) - { - if (stopwatch is not null - && !previousToken.IsCancellationRequested - && !_shutdownCts.IsCancellationRequested - && stopwatch.Elapsed >= _requestTimeout) - return CreateRequestTimeoutResponse(id, stopwatch.Elapsed); - return CreateCancelledResponse(id); - } - finally - { - _currentRequestToken.Value = previousToken; - if (executionSlotAcquired && releaseExecutionSlotNow) - _concurrencyGate.Release(); - if (cleanupNow) - { - if (registeredRequest) - _activeRequests.TryRemove(requestKey!, out _); - requestCts.Dispose(); - } - } - } - - private Task DelayRequestForTestsAsync(JsonNode? id, CancellationToken cancellationToken) - { - if (RequestDelayForTestsWithId is { } delayWithId) - return delayWithId(McpJsonNode.Clone(id), cancellationToken); - return RequestDelayForTests is { } delay - ? delay(cancellationToken) - : Task.CompletedTask; - } - - private static JsonObject CreateRequestTimeoutResponse(JsonNode? id, TimeSpan elapsed, bool isolatedActionDraining = false) - => CreateErrorResponse(hasId: true, id: id, code: -32603, message: "Request timed out", - category: McpErrorEnvelope.CategoryInternalError, - suggestion: "Retry with a narrower query, refresh the index if it is degraded, or increase the MCP request timeout before retrying.", - retrySafe: true, - extraData: new JsonObject - { - ["reason"] = "timeout", - ["timeout_category"] = OperationTimeoutCategories.McpRequest, - ["elapsed_ms"] = (long)Math.Ceiling(elapsed.TotalMilliseconds), - ["isolated_action_draining"] = isolatedActionDraining, - }); - - private void RecordTimedOutIsolatedActionDraining(McpRequestIdTelemetryData requestId, TimeSpan elapsed) - { - var elapsedMs = (long)Math.Ceiling(elapsed.TotalMilliseconds); - Interlocked.Increment(ref _timedOutIsolatedActionDrainingCount); - lock (_requestTimeoutDiagnosticsGate) - { - _lastRequestTimeoutDrainDiagnostic = new RequestTimeoutDrainDiagnostic( - requestId, - elapsedMs, - "draining"); - } - CommandErrorWriter.WriteStderr(BuildTimedOutIsolatedActionDrainingLog(requestId, elapsedMs)); - } - - private void RecordTimedOutIsolatedActionDrained(McpRequestIdTelemetryData requestId, Task task) - { - Interlocked.Decrement(ref _timedOutIsolatedActionDrainingCount); - Interlocked.Increment(ref _timedOutIsolatedActionDrainedCount); - var state = task.IsCanceled ? "canceled" : task.IsFaulted ? "faulted" : "completed"; - lock (_requestTimeoutDiagnosticsGate) - { - _lastRequestTimeoutDrainDiagnostic = new RequestTimeoutDrainDiagnostic( - requestId, - null, - state); - } - } - - internal JsonObject BuildRequestTimeoutDiagnosticsStatus() - { - RequestTimeoutDrainDiagnostic? last; - lock (_requestTimeoutDiagnosticsGate) - { - last = _lastRequestTimeoutDrainDiagnostic; - } - - var payload = new JsonObject - { - ["isolated_action_draining_count"] = Interlocked.Read(ref _timedOutIsolatedActionDrainingCount), - ["isolated_action_drained_count"] = Interlocked.Read(ref _timedOutIsolatedActionDrainedCount), - ["timeout_ms"] = (long)Math.Ceiling(_requestTimeout.TotalMilliseconds), - }; - if (last is not null) - { - payload["last"] = new JsonObject - { - ["request_id"] = last.RequestId.Token, - ["request_id_type"] = last.RequestId.Type, - ["request_id_length"] = last.RequestId.Length, - ["elapsed_ms"] = last.ElapsedMs.HasValue ? JsonValue.Create(last.ElapsedMs.Value) : null, - ["state"] = last.State, - }; - } - return payload; - } - - internal static string BuildTimedOutIsolatedActionDrainingLog(McpRequestIdTelemetryData requestId, long elapsedMs) - => $"[cdidx-mcp] Request timed out while isolated action is still draining: request_id={requestId.Token} request_id_type={requestId.Type} request_id_length={requestId.Length.ToString(CultureInfo.InvariantCulture)} elapsed_ms={elapsedMs}. The response has been sent; cleanup will continue in the background."; - - private static IDisposable BeginRequestCorrelation(JsonNode? id, bool includeRequestId = true) - { - var previous = CurrentCorrelationContext.Value; - CurrentCorrelationContext.Value = new RequestCorrelationContext( - SerializeRequestId(id), - includeRequestId ? McpRequestIdTelemetry.Create(id) : null, - Guid.NewGuid().ToString("D")); - return new CorrelationScope(previous); - } - - private static IDisposable BeginBatchItemCorrelation(JsonNode? id, int itemIndex, bool includeRequestId = false) - { - var previous = CurrentCorrelationContext.Value; - var correlationId = previous is null - ? Guid.NewGuid().ToString("D") - : $"{previous.CorrelationId}.{itemIndex.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; - CurrentCorrelationContext.Value = new RequestCorrelationContext( - SerializeRequestId(id), - includeRequestId ? McpRequestIdTelemetry.Create(id) : null, - correlationId); - return new CorrelationScope(previous); - } - - private static IDisposable BeginChildCorrelation(int childIndex) - { - var previous = CurrentCorrelationContext.Value; - var requestId = previous?.WireRequestId; - var telemetryRequestId = previous?.TelemetryRequestId; - var correlationId = previous == null - ? Guid.NewGuid().ToString("D") - : $"{previous.CorrelationId}.{childIndex.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; - CurrentCorrelationContext.Value = new RequestCorrelationContext(requestId, telemetryRequestId, correlationId); - return new CorrelationScope(previous); - } - - private sealed record RequestCorrelationContext( - string? WireRequestId, - McpRequestIdTelemetryData? TelemetryRequestId, - string CorrelationId); - private sealed record RequestTimeoutDrainDiagnostic( - McpRequestIdTelemetryData RequestId, - long? ElapsedMs, - string State); - - private sealed class CorrelationScope : IDisposable - { - private readonly RequestCorrelationContext? _previous; - private bool _disposed; - - public CorrelationScope(RequestCorrelationContext? previous) - { - _previous = previous; - } - - public void Dispose() - { - if (_disposed) - return; - CurrentCorrelationContext.Value = _previous; - _disposed = true; - } - } - - private void TryCancelRequest(JsonNode? cancelParams) - { - var requestId = cancelParams?["id"] ?? cancelParams?["requestId"]; - var requestKey = SerializeRequestId(requestId); - if (requestKey == null) - return; - if (_activeRequests.TryGetValue(requestKey, out var cts)) - { - CancelRequestCts(cts); - return; - } - if (_queuedBatchRequests.TryGetValue(requestKey, out var queuedRequest) - && queuedRequest.TryCancel()) - { - return; - } - - CancellationRegistriesMissedForTests?.Invoke(); - RememberPendingRequestCancellation(requestKey); - if (_activeRequests.TryGetValue(requestKey, out cts)) - { - _ = TryConsumePendingRequestCancellation(requestKey); - CancelRequestCts(cts); - return; - } - if (_queuedBatchRequests.TryGetValue(requestKey, out queuedRequest)) - { - // The target can enter the durable registry after the first lookup but before the - // bounded tombstone insertion. Recheck it independently of tombstone capacity so a - // full cache cannot discard cancellation for an already-queued batch item (#4545). - // target は初回 lookup 後、bounded tombstone 挿入前に durable registry へ入り得る。 - // tombstone capacity と独立して再確認し、満杯でも登録済み batch item の cancel を - // 失わないようにする (#4545)。 - _ = TryConsumePendingRequestCancellation(requestKey); - if (queuedRequest.TryCancel()) - return; - if (_activeRequests.TryGetValue(requestKey, out cts)) - { - CancelRequestCts(cts); - return; - } - } - } - - private void RememberPendingRequestCancellation(string requestKey) - { - var now = _timeProvider.GetUtcNow(); - PrunePendingRequestCancellations(now); - if (_pendingRequestCancellations.Count < MaxPendingRequestCancellationCount) - _pendingRequestCancellations[requestKey] = now; - } - - private bool TryConsumePendingRequestCancellation(string requestKey) - { - var now = _timeProvider.GetUtcNow(); - PrunePendingRequestCancellations(now); - if (!_pendingRequestCancellations.TryGetValue(requestKey, out var cancelledAt)) - return false; - if (now - cancelledAt > PendingRequestCancellationTtl) - { - _pendingRequestCancellations.TryRemove(requestKey, out _); - return false; - } - - return _pendingRequestCancellations.TryRemove(requestKey, out _); - } - - private void PrunePendingRequestCancellations(DateTimeOffset now) - { - foreach (var entry in _pendingRequestCancellations) - { - if (now - entry.Value > PendingRequestCancellationTtl) - _pendingRequestCancellations.TryRemove(entry.Key, out _); - } - } - - private static void CancelRequestCts(CancellationTokenSource cts) - { - try { cts.Cancel(); } - catch (ObjectDisposedException) { /* completed while cancellation was being delivered. */ } - } - - private static bool IsCancellationFrame(string frame) - { - if (!JsonFrameParser.TryParseNode(frame, MaxJsonDepth, out var node, out _) - || node is not JsonObject obj) - return false; - - var method = TryGetStringMember(obj, "method"); - return string.Equals(method, "$/cancelRequest", StringComparison.Ordinal) - || string.Equals(method, "notifications/cancelled", StringComparison.Ordinal); - } - - private static bool IsProtocolOrderingBarrierFrame(string frame) - { - if (!JsonFrameParser.TryParseNode(frame, MaxJsonDepth, out var node, out _)) - return false; - - if (node is JsonArray batch) - return batch.Any(IsProtocolOrderingBarrierItem); - return IsProtocolOrderingBarrierItem(node); - } - - private static bool IsProtocolOrderingBarrierItem(JsonNode? node) - { - if (node is not JsonObject obj) - return false; - - return TryGetStringMember(obj, "method") switch - { - "initialize" or - "logging/setLevel" or - "notifications/initialized" or - "notifications/roots/list_changed" or - "notifications/shutdown" or - "notifications/exit" => true, - _ => false, - }; - } - - // Safe accessor that returns null instead of throwing when `name` is missing OR present - // with a non-string value. JsonNode's `GetValue()` throws InvalidOperationException - // on non-string scalars, which would bubble out of HandleMessage and turn into -32603 - // before the auth gate runs. - // `name` が無いケースと文字列以外で存在するケースのどちらでも null を返す安全アクセサ。 - // JsonNode の `GetValue()` は非文字列で例外を投げ、認証ゲート前に -32603 化して - // しまう。 - private static string? TryGetStringMember(JsonObject obj, string name) - { - if (!obj.TryGetPropertyValue(name, out var node) || node is null) - return null; - try - { - return node.GetValue(); - } - catch - { - return null; - } - } - - // Cap on the logged `method` label. Long enough for every spec method (`notifications/cancelled` - // is 23 chars) and any plausible client extension, short enough to keep one log line readable. - // ログ出力する `method` の長さ上限。仕様メソッド全てと拡張も収まる長さで、1 行を読みやすく保つ。 - private const int LoggedMethodMaxLength = 64; - - // Strip caller-controlled control characters from `method` and clamp its length before - // interpolating into a stderr log line. Prevents log forging: a malicious client could - // otherwise send `"method":"evil\n[forged]"` and split the diagnostic across two lines - // (#1559). - // stderr 行に method を埋め込む前に制御文字を除去し、長さを切る。これをしないと - // `"method":"evil\n[forged]"` で診断ログを 2 行に分割するログ偽造ができてしまう (#1559)。 - internal static string SanitizeMethodForLog(string? method) - { - if (string.IsNullOrEmpty(method)) - return "(none)"; - var sb = new StringBuilder(Math.Min(method.Length, LoggedMethodMaxLength)); - var truncated = false; - foreach (var ch in method) - { - if (sb.Length >= LoggedMethodMaxLength) - { - truncated = true; - break; - } - if (ch < 0x20 || ch == 0x7F) - sb.Append('?'); - else - sb.Append(ch); - } - if (truncated) - sb.Append('…'); - return sb.ToString(); - } - - // Stderr log for an auth failure. Mirrors the #1530 sanitization pattern: keep the - // wire response generic and put the detail on stderr for local diagnostics. The method - // label is run through SanitizeMethodForLog because it is caller-controlled and reaches - // stderr before any allow-list check (#1559). - // 認証失敗の stderr ログ。#1530 のサニタイズ方針に倣い、ワイヤ応答は一般化したまま - // 詳細だけを stderr に残す。method は認証前に通るため SanitizeMethodForLog で - // 制御文字除去と長さ切詰めを行う (#1559)。 - internal static string BuildAuthFailureLog(string? method, string? reason) => - $"[cdidx-mcp] Auth failed for method {SanitizeMethodForLog(method)}: {reason ?? "(unspecified)"}. Set CDIDX_MCP_AUTH_TOKEN on the server and include a matching params.auth.token on each request."; - /// /// Handle the initialize handshake. /// initializeハンドシェイクを処理。 From c2be148484ba195344df3b05032680ef31cab45a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:20:47 +0900 Subject: [PATCH 003/101] Isolate MCP initialization state management --- src/CodeIndex/Mcp/McpServer.Initialization.cs | 554 ++++++++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 531 ----------------- 2 files changed, 554 insertions(+), 531 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.Initialization.cs diff --git a/src/CodeIndex/Mcp/McpServer.Initialization.cs b/src/CodeIndex/Mcp/McpServer.Initialization.cs new file mode 100644 index 000000000..809c74874 --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.Initialization.cs @@ -0,0 +1,554 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Mcp; + +public partial class McpServer : IDisposable +{ + + /// + /// Handle the initialize handshake. + /// initializeハンドシェイクを処理。 + /// + private JsonNode HandleInitialize( + JsonNode? id, + JsonNode? _params, + DeferredInitializeCommits? deferredInitializeCommits) + { + var negotiated = NegotiateProtocolVersion(_params, out var requestedVersion); + if (negotiated == null) + { + // No overlap between the client's requested version and this server's supported + // set. Issue #1554: respond with structured `-32602` (invalid params) carrying the + // requested + supported versions in `error.data` so clients can branch on it + // instead of guessing why the handshake silently failed. Reject before committing + // any client/session snapshot so a failed re-initialize cannot corrupt the active + // session (#4536, #4540). + // クライアント要求バージョンとサーバー対応集合に重なりがない場合。Issue #1554: + // クライアントが分岐判定できるよう、`error.data` に要求バージョンと対応バージョン + // を入れた -32602 (invalid params) を返す。client/session snapshot の commit 前に + // 拒否し、失敗した re-initialize で有効 session を壊さない (#4536, #4540)。 + DeferFrameLog(BuildUnsupportedProtocolLog(requestedVersion)); + return CreateUnsupportedProtocolError(id, requestedVersion); + } + + // Parse caller-controlled identity, capability, and root metadata into a detached + // draft. None of it becomes observable session state until protocol negotiation and + // complete success-response serialization have both succeeded (#4540). + // caller が制御する identity / capability / root metadata は切り離した draft へ解析する。 + // protocol 交渉と success response の serialization が完了するまで公開しない (#4540)。 + var initializeState = BuildInitializeState(_params); + var result = new JsonObject + { + ["protocolVersion"] = negotiated, + ["capabilities"] = new JsonObject + { + ["tools"] = new JsonObject + { + ["listChanged"] = false + }, + ["resources"] = new JsonObject + { + ["subscribe"] = false, + ["listChanged"] = false + }, + ["prompts"] = new JsonObject + { + ["listChanged"] = false + }, + ["logging"] = new JsonObject(), + ["roots"] = new JsonObject + { + ["listChanged"] = true + }, + ["sampling"] = new JsonObject() + }, + ["serverInfo"] = new JsonObject + { + ["name"] = "cdidx", + ["version"] = _version + }, + // Server instructions — tool-selection guidance for AI clients + // サーバー指示 — AIクライアント向けツール選択ガイダンス + ["instructions"] = BuildInstructions() + }; + var response = CreateSuccessResponse(true, id, result); + if (deferredInitializeCommits is null) + CommitInitializeState(initializeState); + else + deferredInitializeCommits.Register(response, initializeState); + return response; + } + + /// + /// Build a detached snapshot of caller-controlled initialize metadata. The caller must + /// commit this snapshot only after protocol negotiation and success-response serialization succeed. + /// caller が制御する initialize metadata の切り離した snapshot を構築する。呼び出し元は + /// protocol 交渉と success response の serialization 成功後に限って commit すること。 + /// + private PendingInitializeState BuildInitializeState(JsonNode? initializeParams) + { + BoundedMcpText? clientNameDisplay = null; + BoundedMcpText? clientVersionDisplay = null; + JsonNode? clientCapabilities = null; + int? clientCapabilitiesSerializedBytes = null; + string? clientCapabilitiesTruncationReason = null; + var clientSupportsRoots = false; + var clientSupportsSampling = false; + var clientRoots = new List(); + var clientRootDiagnostics = new List(); + var clientRootsTruncated = false; + var markClientRootsStale = false; + + if (initializeParams is JsonObject obj) + { + markClientRootsStale = true; + if (obj["clientInfo"] is JsonObject info) + { + clientNameDisplay = TryReadBoundedClientInfoMember(info, "name"); + clientVersionDisplay = TryReadBoundedClientInfoMember(info, "version"); + } + + if (!obj.TryGetPropertyValue("capabilities", out var capabilities)) + obj.TryGetPropertyValue("clientCapabilities", out capabilities); + if (capabilities is not null) + { + if (capabilities is JsonObject capabilitiesObject) + { + clientSupportsRoots = capabilitiesObject.TryGetPropertyValue("roots", out var rootsCapability) + && rootsCapability is not null; + clientSupportsSampling = capabilitiesObject.TryGetPropertyValue("sampling", out var samplingCapability) + && samplingCapability is not null; + } + + if (!TryMeasureJsonUtf8BytesWithinLimit(capabilities, _jsonOptions, MaxClientCapabilitiesJsonBytes, out var serializedBytes)) + { + clientCapabilitiesSerializedBytes = serializedBytes; + clientCapabilities = new JsonObject(); + clientCapabilitiesTruncationReason = "byte_limit"; + } + else + { + clientCapabilitiesSerializedBytes = serializedBytes; + if (!IsJsonNodeDepthWithinLimit(capabilities, MaxClientCapabilitiesDepth)) + { + clientCapabilities = new JsonObject(); + clientCapabilitiesTruncationReason = "depth_limit"; + } + else + { + clientCapabilities = McpJsonNode.Clone(capabilities); + } + } + } + + void AddRoot(string uri) + { + clientRoots.Add(uri); + if (clientRootDiagnostics.Count >= MaxClientRootCount) + { + clientRootsTruncated = true; + return; + } + + var display = McpBoundedText.ForDisplay(uri, MaxClientRootUriChars); + clientRootDiagnostics.Add(display.Text); + clientRootsTruncated |= display.Truncated; + } + + if (TryReadStringValue(obj["rootUri"]) is { Length: > 0 } rootUri) + AddRoot(rootUri); + + if (obj["roots"] is JsonArray roots) + { + foreach (var root in roots) + { + var uri = TryReadStringValue(root?["uri"]) ?? TryReadStringValue(root); + if (!string.IsNullOrWhiteSpace(uri)) + AddRoot(uri); + } + } + } + + return new PendingInitializeState( + ResolveCallerIdentity(initializeParams), + markClientRootsStale, + clientNameDisplay, + clientVersionDisplay, + clientCapabilities, + clientCapabilitiesSerializedBytes, + clientCapabilitiesTruncationReason, + clientSupportsRoots, + clientSupportsSampling, + clientRoots.ToArray(), + clientRootDiagnostics.ToArray(), + clientRootsTruncated); + } + + private void CommitInitializeState(PendingInitializeState state) + { + lock (_initializeStateGate) + { + var committed = BuildCommittedInitializeState( + PublishedInitializeState, + state, + logCallerSwap: true); + + // One release publication makes lifecycle and all negotiated metadata visible + // together; no reader can observe initialized=true with a partial state (#4540). + // lifecycle と交渉済み metadata を 1 回の release publication で同時に公開し、 + // initialized=true と部分的な state の組み合わせを reader に見せない (#4540)。 + Volatile.Write(ref _initializeState, committed); + } + } + + private InitializeSessionState BuildCommittedInitializeState( + InitializeSessionState previous, + PendingInitializeState state, + bool logCallerSwap) + { + var caller = previous.Caller; + + // Caller stickiness: allow upgrading from the default "unknown" bucket to a named + // identity, but reject successful re-initialize attempts that swap named identities. + // caller の sticky 制御: "unknown" から名前付き ID への昇格だけを許可し、成功した + // re-initialize による名前付き ID 同士のスワップは拒否する。 + if (caller == "unknown") + { + caller = state.ResolvedCaller; + } + else if (state.ResolvedCaller != caller && state.ResolvedCaller != "unknown" && logCallerSwap) + { + DeferFrameLog(BuildCallerSwapRejectionLog(caller, state.ResolvedCaller)); + } + + return new InitializeSessionState( + true, + caller, + state.ClientNameDisplay, + state.ClientVersionDisplay, + state.ClientCapabilities, + state.ClientCapabilitiesSerializedBytes, + state.ClientCapabilitiesTruncationReason, + state.ClientSupportsRoots, + state.ClientSupportsSampling, + state.ClientRoots.ToArray(), + state.ClientRootDiagnostics.ToArray(), + state.ClientRootsTruncated, + state.MarkClientRootsStale || previous.ClientRootsStale); + } + + private sealed record InitializeSessionState( + bool Initialized, + string Caller, + BoundedMcpText? ClientNameDisplay, + BoundedMcpText? ClientVersionDisplay, + JsonNode? ClientCapabilities, + int? ClientCapabilitiesSerializedBytes, + string? ClientCapabilitiesTruncationReason, + bool ClientSupportsRoots, + bool ClientSupportsSampling, + string[] ClientRoots, + string[] ClientRootDiagnostics, + bool ClientRootsTruncated, + bool ClientRootsStale) + { + internal static InitializeSessionState Empty { get; } = new( + false, + "unknown", + null, + null, + null, + null, + null, + false, + false, + Array.Empty(), + Array.Empty(), + false, + true); + + internal string? ClientName => ClientNameDisplay?.Text; + internal string? ClientVersion => ClientVersionDisplay?.Text; + internal int ClientRootCount => ClientRoots.Length; + } + + private sealed record PendingInitializeState( + string ResolvedCaller, + bool MarkClientRootsStale, + BoundedMcpText? ClientNameDisplay, + BoundedMcpText? ClientVersionDisplay, + JsonNode? ClientCapabilities, + int? ClientCapabilitiesSerializedBytes, + string? ClientCapabilitiesTruncationReason, + bool ClientSupportsRoots, + bool ClientSupportsSampling, + string[] ClientRoots, + string[] ClientRootDiagnostics, + bool ClientRootsTruncated); + + private sealed class FrameInitializeState + { + private readonly object _gate = new(); + private InitializeSessionState _current; + private int _acceptedRootsChange; + + internal FrameInitializeState( + InitializeSessionState current, + bool isProvisionalGeneration) + { + _current = current; + IsProvisionalGeneration = isProvisionalGeneration; + } + + internal bool IsProvisionalGeneration { get; } + internal InitializeSessionState Current => Volatile.Read(ref _current); + + internal void MarkRootsChangeAccepted() + => Volatile.Write(ref _acceptedRootsChange, 1); + + internal bool TryConsumeAcceptedRootsChange() + => Interlocked.Exchange(ref _acceptedRootsChange, 0) != 0; + + internal bool TryAdvanceToPublishedGeneration( + InitializeSessionState expectedState, + InitializeSessionState publishedState) + { + if (IsProvisionalGeneration) + return false; + + lock (_gate) + { + if (!ReferenceEquals(Current, expectedState)) + return false; + + Volatile.Write(ref _current, publishedState); + return true; + } + } + + internal bool TryRefreshClientRoots( + InitializeSessionState expectedState, + ClientRootSnapshot refreshedRoots) + { + lock (_gate) + { + if (!ReferenceEquals(Current, expectedState)) + return false; + + Volatile.Write( + ref _current, + expectedState with + { + ClientRoots = refreshedRoots.Roots.ToArray(), + ClientRootDiagnostics = refreshedRoots.Diagnostics.ToArray(), + ClientRootsTruncated = refreshedRoots.Truncated, + ClientRootsStale = false, + }); + return true; + } + } + } + + /// + /// Tracks initialize drafts for one wire frame until the exact success response that owns + /// each draft has been serialized. The collection is frame-local but synchronized because + /// isolated request dispatch can finish on a worker after its caller has timed out. + /// initialize draft を wire frame 単位で追跡し、対応する success response の serialization + /// 成功後にだけ commit する。timeout 後も worker が完了し得るため collection は同期する。 + /// + private sealed class DeferredInitializeCommits + { + private readonly object _gate = new(); + private readonly List _entries = []; + + internal void Register(JsonNode response, PendingInitializeState state) + { + lock (_gate) + _entries.Add(new Entry(response, state)); + } + + internal bool TryGetRegisteredState(JsonNode response, out PendingInitializeState state) + { + lock (_gate) + { + foreach (var entry in _entries) + { + if (!ReferenceEquals(entry.Response, response)) + continue; + + state = entry.State; + return true; + } + } + + state = null!; + return false; + } + + internal PendingInitializeState[] GetIncludedStates(JsonNode serializedResponse) + { + lock (_gate) + { + return _entries + .Where(entry => IsIncludedResponse(serializedResponse, entry.Response)) + .Select(entry => entry.State) + .ToArray(); + } + } + + private static bool IsIncludedResponse(JsonNode serializedResponse, JsonNode candidate) + { + if (ReferenceEquals(serializedResponse, candidate)) + return true; + + if (serializedResponse is not JsonArray batchResponse) + return false; + + foreach (var item in batchResponse) + { + if (ReferenceEquals(item, candidate)) + return true; + } + + return false; + } + + private sealed record Entry(JsonNode Response, PendingInitializeState State); + } + + private static bool IsJsonNodeDepthWithinLimit(JsonNode node, int maxDepth) + => IsJsonNodeDepthWithinLimit(node, depth: 0, maxDepth); + + private static bool IsJsonNodeDepthWithinLimit(JsonNode? node, int depth, int maxDepth) + { + if (node is null) + return true; + if (depth > maxDepth) + return false; + + if (node is JsonObject obj) + { + foreach (var kvp in obj) + { + if (!IsJsonNodeDepthWithinLimit(kvp.Value, depth + 1, maxDepth)) + return false; + } + } + else if (node is JsonArray array) + { + foreach (var item in array) + { + if (!IsJsonNodeDepthWithinLimit(item, depth + 1, maxDepth)) + return false; + } + } + + return true; + } + + private static ClientRootSnapshot BuildClientRootSnapshot(IEnumerable roots) + { + var capturedRoots = new List(); + var diagnostics = new List(); + var truncated = false; + foreach (var uri in roots) + { + capturedRoots.Add(uri); + if (diagnostics.Count >= MaxClientRootCount) + { + truncated = true; + continue; + } + + var display = McpBoundedText.ForDisplay(uri, MaxClientRootUriChars); + diagnostics.Add(display.Text); + truncated |= display.Truncated; + } + + return new ClientRootSnapshot(capturedRoots.ToArray(), diagnostics.ToArray(), truncated); + } + + private void MarkClientRootsStale() + { + lock (_initializeStateGate) + { + var current = PublishedInitializeState; + // Always replace the reference, even when already stale, so a notification that + // races an in-flight roots/list refresh invalidates that refresh's expected state. + // 既に stale でも必ず reference を置き換え、進行中の roots/list refresh と競合した + // notification がその refresh の expected state を無効化できるようにする。 + Volatile.Write(ref _initializeState, current with { ClientRootsStale = true }); + } + } + + private sealed record ClientRootSnapshot(string[] Roots, string[] Diagnostics, bool Truncated); + + internal JsonNode? ClientCapabilitiesForTests + { + get + { + var state = CurrentInitializeState; + return McpJsonNode.Clone(state.ClientCapabilities); + } + } + + internal string[] ClientRootsForTests + { + get + { + var state = CurrentInitializeState; + return state.ClientRoots.ToArray(); + } + } + + internal bool ClientSupportsRootsForTests => CurrentInitializeState.ClientSupportsRoots; + + internal bool ClientSupportsSamplingForTests => CurrentInitializeState.ClientSupportsSampling; + + internal bool ClientRootsStaleForTests + { + get => CurrentInitializeState.ClientRootsStale; + set + { + lock (_initializeStateGate) + { + var current = PublishedInitializeState; + Volatile.Write(ref _initializeState, current with { ClientRootsStale = value }); + } + } + } + + internal string McpLogLevelForTests => _mcpLogLevel; + + internal Func? ClientRequestHandlerForTests { get; set; } + + private static string? TryReadStringMember(JsonObject obj, string key) + { + if (!obj.TryGetPropertyValue(key, out var node)) + return null; + if (node is JsonValue value && value.TryGetValue(out var s) && !string.IsNullOrWhiteSpace(s)) + return s.Trim(); + return null; + } + + private static BoundedMcpText? TryReadBoundedClientInfoMember(JsonObject obj, string key) + { + var value = TryReadStringMember(obj, key); + return value is null ? null : BoundClientInfoForDisplay(value); + } + +} diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 639acce18..c9b300f0d 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -469,537 +469,6 @@ internal TimeSpan InFlightPostCancelGracePeriod : value; } - /// - /// Handle the initialize handshake. - /// initializeハンドシェイクを処理。 - /// - private JsonNode HandleInitialize( - JsonNode? id, - JsonNode? _params, - DeferredInitializeCommits? deferredInitializeCommits) - { - var negotiated = NegotiateProtocolVersion(_params, out var requestedVersion); - if (negotiated == null) - { - // No overlap between the client's requested version and this server's supported - // set. Issue #1554: respond with structured `-32602` (invalid params) carrying the - // requested + supported versions in `error.data` so clients can branch on it - // instead of guessing why the handshake silently failed. Reject before committing - // any client/session snapshot so a failed re-initialize cannot corrupt the active - // session (#4536, #4540). - // クライアント要求バージョンとサーバー対応集合に重なりがない場合。Issue #1554: - // クライアントが分岐判定できるよう、`error.data` に要求バージョンと対応バージョン - // を入れた -32602 (invalid params) を返す。client/session snapshot の commit 前に - // 拒否し、失敗した re-initialize で有効 session を壊さない (#4536, #4540)。 - DeferFrameLog(BuildUnsupportedProtocolLog(requestedVersion)); - return CreateUnsupportedProtocolError(id, requestedVersion); - } - - // Parse caller-controlled identity, capability, and root metadata into a detached - // draft. None of it becomes observable session state until protocol negotiation and - // complete success-response serialization have both succeeded (#4540). - // caller が制御する identity / capability / root metadata は切り離した draft へ解析する。 - // protocol 交渉と success response の serialization が完了するまで公開しない (#4540)。 - var initializeState = BuildInitializeState(_params); - var result = new JsonObject - { - ["protocolVersion"] = negotiated, - ["capabilities"] = new JsonObject - { - ["tools"] = new JsonObject - { - ["listChanged"] = false - }, - ["resources"] = new JsonObject - { - ["subscribe"] = false, - ["listChanged"] = false - }, - ["prompts"] = new JsonObject - { - ["listChanged"] = false - }, - ["logging"] = new JsonObject(), - ["roots"] = new JsonObject - { - ["listChanged"] = true - }, - ["sampling"] = new JsonObject() - }, - ["serverInfo"] = new JsonObject - { - ["name"] = "cdidx", - ["version"] = _version - }, - // Server instructions — tool-selection guidance for AI clients - // サーバー指示 — AIクライアント向けツール選択ガイダンス - ["instructions"] = BuildInstructions() - }; - var response = CreateSuccessResponse(true, id, result); - if (deferredInitializeCommits is null) - CommitInitializeState(initializeState); - else - deferredInitializeCommits.Register(response, initializeState); - return response; - } - - /// - /// Build a detached snapshot of caller-controlled initialize metadata. The caller must - /// commit this snapshot only after protocol negotiation and success-response serialization succeed. - /// caller が制御する initialize metadata の切り離した snapshot を構築する。呼び出し元は - /// protocol 交渉と success response の serialization 成功後に限って commit すること。 - /// - private PendingInitializeState BuildInitializeState(JsonNode? initializeParams) - { - BoundedMcpText? clientNameDisplay = null; - BoundedMcpText? clientVersionDisplay = null; - JsonNode? clientCapabilities = null; - int? clientCapabilitiesSerializedBytes = null; - string? clientCapabilitiesTruncationReason = null; - var clientSupportsRoots = false; - var clientSupportsSampling = false; - var clientRoots = new List(); - var clientRootDiagnostics = new List(); - var clientRootsTruncated = false; - var markClientRootsStale = false; - - if (initializeParams is JsonObject obj) - { - markClientRootsStale = true; - if (obj["clientInfo"] is JsonObject info) - { - clientNameDisplay = TryReadBoundedClientInfoMember(info, "name"); - clientVersionDisplay = TryReadBoundedClientInfoMember(info, "version"); - } - - if (!obj.TryGetPropertyValue("capabilities", out var capabilities)) - obj.TryGetPropertyValue("clientCapabilities", out capabilities); - if (capabilities is not null) - { - if (capabilities is JsonObject capabilitiesObject) - { - clientSupportsRoots = capabilitiesObject.TryGetPropertyValue("roots", out var rootsCapability) - && rootsCapability is not null; - clientSupportsSampling = capabilitiesObject.TryGetPropertyValue("sampling", out var samplingCapability) - && samplingCapability is not null; - } - - if (!TryMeasureJsonUtf8BytesWithinLimit(capabilities, _jsonOptions, MaxClientCapabilitiesJsonBytes, out var serializedBytes)) - { - clientCapabilitiesSerializedBytes = serializedBytes; - clientCapabilities = new JsonObject(); - clientCapabilitiesTruncationReason = "byte_limit"; - } - else - { - clientCapabilitiesSerializedBytes = serializedBytes; - if (!IsJsonNodeDepthWithinLimit(capabilities, MaxClientCapabilitiesDepth)) - { - clientCapabilities = new JsonObject(); - clientCapabilitiesTruncationReason = "depth_limit"; - } - else - { - clientCapabilities = McpJsonNode.Clone(capabilities); - } - } - } - - void AddRoot(string uri) - { - clientRoots.Add(uri); - if (clientRootDiagnostics.Count >= MaxClientRootCount) - { - clientRootsTruncated = true; - return; - } - - var display = McpBoundedText.ForDisplay(uri, MaxClientRootUriChars); - clientRootDiagnostics.Add(display.Text); - clientRootsTruncated |= display.Truncated; - } - - if (TryReadStringValue(obj["rootUri"]) is { Length: > 0 } rootUri) - AddRoot(rootUri); - - if (obj["roots"] is JsonArray roots) - { - foreach (var root in roots) - { - var uri = TryReadStringValue(root?["uri"]) ?? TryReadStringValue(root); - if (!string.IsNullOrWhiteSpace(uri)) - AddRoot(uri); - } - } - } - - return new PendingInitializeState( - ResolveCallerIdentity(initializeParams), - markClientRootsStale, - clientNameDisplay, - clientVersionDisplay, - clientCapabilities, - clientCapabilitiesSerializedBytes, - clientCapabilitiesTruncationReason, - clientSupportsRoots, - clientSupportsSampling, - clientRoots.ToArray(), - clientRootDiagnostics.ToArray(), - clientRootsTruncated); - } - - private void CommitInitializeState(PendingInitializeState state) - { - lock (_initializeStateGate) - { - var committed = BuildCommittedInitializeState( - PublishedInitializeState, - state, - logCallerSwap: true); - - // One release publication makes lifecycle and all negotiated metadata visible - // together; no reader can observe initialized=true with a partial state (#4540). - // lifecycle と交渉済み metadata を 1 回の release publication で同時に公開し、 - // initialized=true と部分的な state の組み合わせを reader に見せない (#4540)。 - Volatile.Write(ref _initializeState, committed); - } - } - - private InitializeSessionState BuildCommittedInitializeState( - InitializeSessionState previous, - PendingInitializeState state, - bool logCallerSwap) - { - var caller = previous.Caller; - - // Caller stickiness: allow upgrading from the default "unknown" bucket to a named - // identity, but reject successful re-initialize attempts that swap named identities. - // caller の sticky 制御: "unknown" から名前付き ID への昇格だけを許可し、成功した - // re-initialize による名前付き ID 同士のスワップは拒否する。 - if (caller == "unknown") - { - caller = state.ResolvedCaller; - } - else if (state.ResolvedCaller != caller && state.ResolvedCaller != "unknown" && logCallerSwap) - { - DeferFrameLog(BuildCallerSwapRejectionLog(caller, state.ResolvedCaller)); - } - - return new InitializeSessionState( - true, - caller, - state.ClientNameDisplay, - state.ClientVersionDisplay, - state.ClientCapabilities, - state.ClientCapabilitiesSerializedBytes, - state.ClientCapabilitiesTruncationReason, - state.ClientSupportsRoots, - state.ClientSupportsSampling, - state.ClientRoots.ToArray(), - state.ClientRootDiagnostics.ToArray(), - state.ClientRootsTruncated, - state.MarkClientRootsStale || previous.ClientRootsStale); - } - - private sealed record InitializeSessionState( - bool Initialized, - string Caller, - BoundedMcpText? ClientNameDisplay, - BoundedMcpText? ClientVersionDisplay, - JsonNode? ClientCapabilities, - int? ClientCapabilitiesSerializedBytes, - string? ClientCapabilitiesTruncationReason, - bool ClientSupportsRoots, - bool ClientSupportsSampling, - string[] ClientRoots, - string[] ClientRootDiagnostics, - bool ClientRootsTruncated, - bool ClientRootsStale) - { - internal static InitializeSessionState Empty { get; } = new( - false, - "unknown", - null, - null, - null, - null, - null, - false, - false, - Array.Empty(), - Array.Empty(), - false, - true); - - internal string? ClientName => ClientNameDisplay?.Text; - internal string? ClientVersion => ClientVersionDisplay?.Text; - internal int ClientRootCount => ClientRoots.Length; - } - - private sealed record PendingInitializeState( - string ResolvedCaller, - bool MarkClientRootsStale, - BoundedMcpText? ClientNameDisplay, - BoundedMcpText? ClientVersionDisplay, - JsonNode? ClientCapabilities, - int? ClientCapabilitiesSerializedBytes, - string? ClientCapabilitiesTruncationReason, - bool ClientSupportsRoots, - bool ClientSupportsSampling, - string[] ClientRoots, - string[] ClientRootDiagnostics, - bool ClientRootsTruncated); - - private sealed class FrameInitializeState - { - private readonly object _gate = new(); - private InitializeSessionState _current; - private int _acceptedRootsChange; - - internal FrameInitializeState( - InitializeSessionState current, - bool isProvisionalGeneration) - { - _current = current; - IsProvisionalGeneration = isProvisionalGeneration; - } - - internal bool IsProvisionalGeneration { get; } - internal InitializeSessionState Current => Volatile.Read(ref _current); - - internal void MarkRootsChangeAccepted() - => Volatile.Write(ref _acceptedRootsChange, 1); - - internal bool TryConsumeAcceptedRootsChange() - => Interlocked.Exchange(ref _acceptedRootsChange, 0) != 0; - - internal bool TryAdvanceToPublishedGeneration( - InitializeSessionState expectedState, - InitializeSessionState publishedState) - { - if (IsProvisionalGeneration) - return false; - - lock (_gate) - { - if (!ReferenceEquals(Current, expectedState)) - return false; - - Volatile.Write(ref _current, publishedState); - return true; - } - } - - internal bool TryRefreshClientRoots( - InitializeSessionState expectedState, - ClientRootSnapshot refreshedRoots) - { - lock (_gate) - { - if (!ReferenceEquals(Current, expectedState)) - return false; - - Volatile.Write( - ref _current, - expectedState with - { - ClientRoots = refreshedRoots.Roots.ToArray(), - ClientRootDiagnostics = refreshedRoots.Diagnostics.ToArray(), - ClientRootsTruncated = refreshedRoots.Truncated, - ClientRootsStale = false, - }); - return true; - } - } - } - - /// - /// Tracks initialize drafts for one wire frame until the exact success response that owns - /// each draft has been serialized. The collection is frame-local but synchronized because - /// isolated request dispatch can finish on a worker after its caller has timed out. - /// initialize draft を wire frame 単位で追跡し、対応する success response の serialization - /// 成功後にだけ commit する。timeout 後も worker が完了し得るため collection は同期する。 - /// - private sealed class DeferredInitializeCommits - { - private readonly object _gate = new(); - private readonly List _entries = []; - - internal void Register(JsonNode response, PendingInitializeState state) - { - lock (_gate) - _entries.Add(new Entry(response, state)); - } - - internal bool TryGetRegisteredState(JsonNode response, out PendingInitializeState state) - { - lock (_gate) - { - foreach (var entry in _entries) - { - if (!ReferenceEquals(entry.Response, response)) - continue; - - state = entry.State; - return true; - } - } - - state = null!; - return false; - } - - internal PendingInitializeState[] GetIncludedStates(JsonNode serializedResponse) - { - lock (_gate) - { - return _entries - .Where(entry => IsIncludedResponse(serializedResponse, entry.Response)) - .Select(entry => entry.State) - .ToArray(); - } - } - - private static bool IsIncludedResponse(JsonNode serializedResponse, JsonNode candidate) - { - if (ReferenceEquals(serializedResponse, candidate)) - return true; - - if (serializedResponse is not JsonArray batchResponse) - return false; - - foreach (var item in batchResponse) - { - if (ReferenceEquals(item, candidate)) - return true; - } - - return false; - } - - private sealed record Entry(JsonNode Response, PendingInitializeState State); - } - - private static bool IsJsonNodeDepthWithinLimit(JsonNode node, int maxDepth) - => IsJsonNodeDepthWithinLimit(node, depth: 0, maxDepth); - - private static bool IsJsonNodeDepthWithinLimit(JsonNode? node, int depth, int maxDepth) - { - if (node is null) - return true; - if (depth > maxDepth) - return false; - - if (node is JsonObject obj) - { - foreach (var kvp in obj) - { - if (!IsJsonNodeDepthWithinLimit(kvp.Value, depth + 1, maxDepth)) - return false; - } - } - else if (node is JsonArray array) - { - foreach (var item in array) - { - if (!IsJsonNodeDepthWithinLimit(item, depth + 1, maxDepth)) - return false; - } - } - - return true; - } - - private static ClientRootSnapshot BuildClientRootSnapshot(IEnumerable roots) - { - var capturedRoots = new List(); - var diagnostics = new List(); - var truncated = false; - foreach (var uri in roots) - { - capturedRoots.Add(uri); - if (diagnostics.Count >= MaxClientRootCount) - { - truncated = true; - continue; - } - - var display = McpBoundedText.ForDisplay(uri, MaxClientRootUriChars); - diagnostics.Add(display.Text); - truncated |= display.Truncated; - } - - return new ClientRootSnapshot(capturedRoots.ToArray(), diagnostics.ToArray(), truncated); - } - - private void MarkClientRootsStale() - { - lock (_initializeStateGate) - { - var current = PublishedInitializeState; - // Always replace the reference, even when already stale, so a notification that - // races an in-flight roots/list refresh invalidates that refresh's expected state. - // 既に stale でも必ず reference を置き換え、進行中の roots/list refresh と競合した - // notification がその refresh の expected state を無効化できるようにする。 - Volatile.Write(ref _initializeState, current with { ClientRootsStale = true }); - } - } - - private sealed record ClientRootSnapshot(string[] Roots, string[] Diagnostics, bool Truncated); - - internal JsonNode? ClientCapabilitiesForTests - { - get - { - var state = CurrentInitializeState; - return McpJsonNode.Clone(state.ClientCapabilities); - } - } - - internal string[] ClientRootsForTests - { - get - { - var state = CurrentInitializeState; - return state.ClientRoots.ToArray(); - } - } - - internal bool ClientSupportsRootsForTests => CurrentInitializeState.ClientSupportsRoots; - - internal bool ClientSupportsSamplingForTests => CurrentInitializeState.ClientSupportsSampling; - - internal bool ClientRootsStaleForTests - { - get => CurrentInitializeState.ClientRootsStale; - set - { - lock (_initializeStateGate) - { - var current = PublishedInitializeState; - Volatile.Write(ref _initializeState, current with { ClientRootsStale = value }); - } - } - } - - internal string McpLogLevelForTests => _mcpLogLevel; - - internal Func? ClientRequestHandlerForTests { get; set; } - - private static string? TryReadStringMember(JsonObject obj, string key) - { - if (!obj.TryGetPropertyValue(key, out var node)) - return null; - if (node is JsonValue value && value.TryGetValue(out var s) && !string.IsNullOrWhiteSpace(s)) - return s.Trim(); - return null; - } - - private static BoundedMcpText? TryReadBoundedClientInfoMember(JsonObject obj, string key) - { - var value = TryReadStringMember(obj, key); - return value is null ? null : BoundClientInfoForDisplay(value); - } private static JsonNode HandleResourceTemplatesList(JsonNode? id, JsonNode? templateParams) { From aa31542d8dd6964e676bb67fbfd1c0443f652d1b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:22:43 +0900 Subject: [PATCH 004/101] Extract MCP resource listing and reads --- src/CodeIndex/Mcp/McpServer.Resources.cs | 1128 ++++++++++++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 1105 --------------------- 2 files changed, 1128 insertions(+), 1105 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.Resources.cs diff --git a/src/CodeIndex/Mcp/McpServer.Resources.cs b/src/CodeIndex/Mcp/McpServer.Resources.cs new file mode 100644 index 000000000..184da7ce6 --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.Resources.cs @@ -0,0 +1,1128 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Mcp; + +public partial class McpServer : IDisposable +{ + + + private static JsonNode HandleResourceTemplatesList(JsonNode? id, JsonNode? templateParams) + { + if (templateParams is not null && templateParams is not JsonObject) + { + return CreateErrorResponse(hasId: true, id: id, code: -32602, + message: "resources/templates/list params must be an object.", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Pass an empty params object or omit params.", + retrySafe: false); + } + + if (templateParams?["cursor"] is not null) + { + return CreateErrorResponse(hasId: true, id: id, code: -32602, + message: "resources/templates/list does not have another page.", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Omit params.cursor; the complete resource template catalog fits in one response.", + retrySafe: false); + } + + return CreateSuccessResponse(true, id, new JsonObject + { + ["resourceTemplates"] = new JsonArray + { + new JsonObject + { + ["uriTemplate"] = "cdidx://file-path/{path}", + ["name"] = "indexed-file", + ["title"] = "Indexed repository file", + ["description"] = "Read one indexed, non-generated file by its exact repository-relative path. The template-only file-path resolver decodes the URI-template value, validates it as a relative path, and returns the canonical cdidx://file resource identity.", + }, + }, + }); + } + + private JsonNode HandleResourcesList(JsonNode? id, JsonNode? listParams) + { + var filterError = ValidateResourceListFilters(id, listParams, out var filters); + if (filterError is not null) + return filterError; + var filterFingerprint = ComputeResourceListFilterFingerprint(filters); + + var requestedMaxBytes = DefaultResourceListMaxBytes; + if (listParams?["maxBytes"] is JsonNode maxBytesNode) + { + if (maxBytesNode is not JsonValue maxBytesValue + || !maxBytesValue.TryGetValue(out requestedMaxBytes) + || requestedMaxBytes < MinResourceListMaxBytes + || requestedMaxBytes > MaxResourceListMaxBytes) + { + return CreateResourcesListMaxBytesError(id); + } + } + var effectiveMaxBytes = Math.Min(requestedMaxBytes, GetMaxResponseBytes()); + var activeTransportMaxResponseBytes = Volatile.Read(ref _activeTransportMaxResponseBytes); + if (activeTransportMaxResponseBytes > 0) + effectiveMaxBytes = Math.Min(effectiveMaxBytes, activeTransportMaxResponseBytes); + if (_currentBatchResponseItemMaxBytes.Value is { } batchResponseItemMaxBytes) + effectiveMaxBytes = Math.Min(effectiveMaxBytes, batchResponseItemMaxBytes); + + long? afterFileId = null; + long? expectedGeneration = null; + var legacyOffset = 0; + if (listParams?["cursor"] is JsonNode cursorNode) + { + if (cursorNode is not JsonValue cursorValue + || !cursorValue.TryGetValue(out var cursor)) + { + return CreateResourcesListCursorError(id); + } + + if (cursor.Length > MaxResourceListCursorChars) + return CreateResourcesListCursorError(id); + + if (int.TryParse(cursor, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedLegacyOffset)) + { + if (parsedLegacyOffset < 0 || parsedLegacyOffset > MaxMcpPaginationOffset) + return CreateResourcesListCursorError(id); + if (parsedLegacyOffset != 0) + return CreateResourcesListRestartError(id); + } + else if (TryDecodeResourceListCursor(cursor, out var decodedCursor)) + { + if ((decodedCursor.HasFilterFingerprint + && decodedCursor.FilterFingerprint != filterFingerprint) + || (!decodedCursor.HasFilterFingerprint && !filters.IsDefault)) + { + return CreateResourcesListFilterMismatchError(id); + } + afterFileId = decodedCursor.AfterFileId; + expectedGeneration = decodedCursor.Generation; + } + else + { + return CreateResourcesListCursorError(id); + } + } + + return WithDbReader(id, args: listParams, reader => + { + var resourcePage = reader.ListResourceFiles( + limit: ResourceListPageSize + 1, + afterFileId: afterFileId, + expectedGeneration: expectedGeneration, + legacyOffset: legacyOffset, + pathPatterns: filters.PathPatterns, + lang: filters.Language, + includeGenerated: filters.IncludeGenerated); + if (resourcePage.GenerationTrackingUnavailable) + return CreateResourcesListGenerationUnavailableError(id); + if (resourcePage.CursorRestartRequired) + return CreateResourcesListRestartError(id); + + var page = resourcePage.Files.Take(ResourceListPageSize).ToArray(); + var resources = new JsonArray(); + var reservedResponse = CreateResourceListResponse( + id, + resources: [], + generation: long.MaxValue, + lastConsumedFileId: long.MaxValue, + filterFingerprint: ulong.MaxValue, + hasContinuation: true, + requestedMaxBytes: MaxResourceListMaxBytes, + effectiveMaxBytes: MaxResourceListMaxBytes, + candidatesConsumed: ResourceListPageSize, + uriTooLongCount: ResourceListPageSize, + resourceExceedsMaxBytesCount: ResourceListPageSize, + byteBudgetReached: true); + _ = TryMeasureJsonUtf8BytesWithinLimit( + reservedResponse, + _jsonOptions, + int.MaxValue, + out var reservedResponseBytes); + if (reservedResponseBytes > effectiveMaxBytes) + return CreateResourcesListEffectiveMaxBytesError(id, requestedMaxBytes, effectiveMaxBytes); + + var acceptedResourceBytes = 0L; + var candidatesConsumed = 0; + var uriTooLongCount = 0; + var resourceExceedsMaxBytesCount = 0; + var byteBudgetReached = false; + var stoppedForByteBudget = false; + long? lastConsumedFileId = null; + foreach (var file in page) + { + var uri = BuildResourceUri(file.Path); + if (uri.Length > McpBoundedText.MaxResourceUriChars) + { + uriTooLongCount++; + candidatesConsumed++; + lastConsumedFileId = file.Id; + continue; + } + + var resource = new JsonObject + { + ["uri"] = uri, + ["name"] = file.Path, + ["description"] = $"{file.Path} ({file.Lang ?? "unknown"}, {file.Lines} lines)", + ["mimeType"] = GetResourceMimeType(file.Lang), + }; + var resourceFitsAlone = TryMeasureJsonUtf8BytesWithinLimit( + resource, + _jsonOptions, + effectiveMaxBytes, + out var resourceBytes); + var commaBytes = resources.Count == 0 ? 0 : 1; + var resourceFitsEmptyPage = resourceFitsAlone + && reservedResponseBytes + resourceBytes <= effectiveMaxBytes; + var resourceFitsPage = resourceFitsEmptyPage + && reservedResponseBytes + acceptedResourceBytes + commaBytes + resourceBytes <= effectiveMaxBytes; + if (!resourceFitsPage) + { + byteBudgetReached = true; + if (resourceFitsEmptyPage || resources.Count > 0) + { + stoppedForByteBudget = true; + break; + } + + // Consume resources that cannot fit even on an empty page so the cursor cannot livelock. + // 空ページにも収まらない resource は消費・報告し、cursor の livelock を防ぐ。 + resourceExceedsMaxBytesCount++; + candidatesConsumed++; + lastConsumedFileId = file.Id; + continue; + } + + resources.Add(resource); + acceptedResourceBytes += commaBytes + resourceBytes; + candidatesConsumed++; + lastConsumedFileId = file.Id; + } + + var hasContinuation = stoppedForByteBudget || resourcePage.Files.Count > ResourceListPageSize; + var response = CreateResourceListResponse( + id, + resources, + resourcePage.Generation, + lastConsumedFileId, + filterFingerprint, + hasContinuation, + requestedMaxBytes, + effectiveMaxBytes, + candidatesConsumed, + uriTooLongCount, + resourceExceedsMaxBytesCount, + byteBudgetReached); + + if (!TryMeasureJsonUtf8BytesWithinLimit(response, _jsonOptions, effectiveMaxBytes, out _)) + return CreateResourcesListEffectiveMaxBytesError(id, requestedMaxBytes, effectiveMaxBytes); + return response; + }); + } + + private static JsonObject CreateResourcesListMaxBytesError(JsonNode? id) + => CreateErrorResponse(hasId: true, id: id, code: -32602, + message: $"resources/list maxBytes must be between {MinResourceListMaxBytes} and {MaxResourceListMaxBytes}.", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use an integer params.maxBytes within the documented range, or omit it to use the default.", + retrySafe: false, + extraData: new JsonObject + { + ["min_max_bytes"] = MinResourceListMaxBytes, + ["max_max_bytes"] = MaxResourceListMaxBytes, + ["default_max_bytes"] = DefaultResourceListMaxBytes, + }); + + private static JsonObject CreateResourcesListEffectiveMaxBytesError( + JsonNode? id, + int requestedMaxBytes, + int effectiveMaxBytes) + => CreateErrorResponse(hasId: true, id: id, code: -32602, + message: "resources/list response metadata does not fit within the effective byte limit.", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Raise the MCP response byte limit or request a larger params.maxBytes value.", + retrySafe: false, + extraData: new JsonObject + { + ["requested_max_bytes"] = requestedMaxBytes, + ["effective_max_bytes"] = effectiveMaxBytes, + }); + + private static JsonObject CreateResourceListResponse( + JsonNode? id, + JsonArray resources, + long generation, + long? lastConsumedFileId, + ulong filterFingerprint, + bool hasContinuation, + int requestedMaxBytes, + int effectiveMaxBytes, + int candidatesConsumed, + int uriTooLongCount, + int resourceExceedsMaxBytesCount, + bool byteBudgetReached) + { + var result = new JsonObject + { + ["resources"] = resources, + ["_meta"] = new JsonObject + { + ["response_controls"] = CreateResourceListResponseControls( + requestedMaxBytes, + effectiveMaxBytes, + candidatesConsumed, + resources.Count, + uriTooLongCount, + resourceExceedsMaxBytesCount, + byteBudgetReached, + hasContinuation), + }, + }; + if (hasContinuation && lastConsumedFileId is not null) + result["nextCursor"] = EncodeResourceListCursor(generation, lastConsumedFileId.Value, filterFingerprint); + return CreateSuccessResponse(true, id, result); + } + + private static JsonObject CreateResourceListResponseControls( + int requestedMaxBytes, + int effectiveMaxBytes, + int candidatesConsumed, + int resourcesReturned, + int uriTooLongCount, + int resourceExceedsMaxBytesCount, + bool byteBudgetReached, + bool hasContinuation) + => new() + { + ["requested_max_bytes"] = requestedMaxBytes, + ["effective_max_bytes"] = effectiveMaxBytes, + ["page_item_limit"] = ResourceListPageSize, + ["resource_candidates_consumed"] = candidatesConsumed, + ["resources_returned"] = resourcesReturned, + ["omitted_resource_count"] = uriTooLongCount + resourceExceedsMaxBytesCount, + ["omitted_resource_reason_counts"] = new JsonObject + { + ["resource_uri_too_long"] = uriTooLongCount, + ["resource_exceeds_max_bytes"] = resourceExceedsMaxBytesCount, + }, + ["byte_budget_reached"] = byteBudgetReached, + ["continuation_reason"] = hasContinuation + ? byteBudgetReached ? "byte_budget" : "item_limit" + : "completed", + }; + + private static JsonObject CreateResourcesListCursorError(JsonNode? id) + => CreateErrorResponse(hasId: true, id: id, code: -32602, + message: "resources/list cursor is invalid or unsupported.", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use the `nextCursor` value returned by the previous resources/list response, or omit params.cursor to start from the first page.", + retrySafe: false, + extraData: new JsonObject + { + ["max_cursor_length"] = MaxResourceListCursorChars, + ["max_legacy_pagination_offset"] = MaxMcpPaginationOffset, + }); + + private static JsonObject CreateResourcesListFilterMismatchError(JsonNode? id) + => CreateErrorResponse(hasId: true, id: id, code: -32602, + message: "The resources/list filters do not match the supplied cursor.", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Continue with the same path, lang, and includeGenerated filters used to create the cursor, or omit params.cursor to start a new filtered listing.", + retrySafe: false, + extraData: new JsonObject + { + ["reason"] = "resources_list_filters_changed", + ["restart_required"] = true, + }); + + private static JsonObject CreateResourcesListRestartError(JsonNode? id) + => CreateErrorResponse(hasId: true, id: id, code: McpErrorEnvelope.CodeIndexStale, + message: "The indexed file set changed after this resources/list cursor was issued.", + category: McpErrorEnvelope.CategoryIndexStale, + suggestion: "Omit params.cursor and restart resources/list from the first page.", + retrySafe: false, + extraData: new JsonObject + { + ["reason"] = "resources_list_generation_changed", + ["restart_required"] = true, + }); + + private static JsonObject CreateResourcesListGenerationUnavailableError(JsonNode? id) + => CreateErrorResponse(hasId: true, id: id, code: McpErrorEnvelope.CodeIndexStale, + message: "This database cannot prove a stable resources/list generation.", + category: McpErrorEnvelope.CategoryIndexStale, + suggestion: "Open the database on writable storage and run `cdidx index ` with the current cdidx to install generation tracking. Use an `immutable=1` URI only for a snapshot guaranteed not to change.", + retrySafe: false, + extraData: new JsonObject + { + ["reason"] = "resources_list_generation_unavailable", + ["migration_required"] = true, + ["restart_required"] = false, + }); + + private static JsonObject? ValidateResourceListFilters( + JsonNode? id, + JsonNode? listParams, + out ResourceListFilters filters) + { + filters = ResourceListFilters.Default; + if (listParams is null) + return null; + if (listParams is not JsonObject obj) + { + return CreateResourcesListFilterError( + id, + parameter: "params", + message: "resources/list params must be an object.", + suggestion: "Pass an object containing optional cursor, maxBytes, path, lang, and includeGenerated members."); + } + + var pathPatterns = new List(); + if (obj.TryGetPropertyValue("path", out var pathNode) && pathNode is not null) + { + if (pathNode is JsonValue pathValue + && pathValue.TryGetValue(out var scalarPath)) + { + pathPatterns.Add(scalarPath); + } + else if (pathNode is JsonArray pathArray) + { + if (pathArray.Count > MaxResourceListPathFilterCount) + { + return CreateResourcesListFilterError( + id, + parameter: "path", + message: $"resources/list params.path accepts at most {MaxResourceListPathFilterCount} values.", + suggestion: "Reduce the path filter array and retry.", + extraData: new JsonObject + { + ["max_item_count"] = MaxResourceListPathFilterCount, + ["actual_item_count"] = pathArray.Count, + }); + } + + foreach (var item in pathArray) + { + if (item is not JsonValue itemValue + || !itemValue.TryGetValue(out var pathText)) + { + return CreateResourcesListFilterError( + id, + parameter: "path", + message: "resources/list params.path array items must be strings.", + suggestion: "Use a single path string or an array containing only non-empty path strings."); + } + pathPatterns.Add(pathText); + } + } + else + { + return CreateResourcesListFilterError( + id, + parameter: "path", + message: "resources/list params.path must be a string or an array of strings.", + suggestion: "Use repository-relative path text or bounded glob-style path patterns."); + } + } + + for (var i = 0; i < pathPatterns.Count; i++) + { + var pathPattern = pathPatterns[i]; + if (string.IsNullOrWhiteSpace(pathPattern) + || pathPattern.Length > MaxResourceListPathFilterChars) + { + return CreateResourcesListFilterError( + id, + parameter: "path", + message: $"resources/list params.path values must contain text and be at most {MaxResourceListPathFilterChars} characters.", + suggestion: "Use a shorter non-empty repository-relative path filter.", + extraData: new JsonObject + { + ["max_value_length"] = MaxResourceListPathFilterChars, + ["item_index"] = i, + ["actual_value_length"] = pathPattern?.Length ?? 0, + }); + } + + if (CountUnescapedResourcePathWildcards(pathPattern) > MaxResourceListPathFilterWildcards) + { + return CreateResourcesListFilterError( + id, + parameter: "path", + message: $"resources/list params.path values may contain at most {MaxResourceListPathFilterWildcards} wildcard operators.", + suggestion: "Split the path filters or use a narrower directory prefix.", + extraData: new JsonObject + { + ["max_wildcard_count"] = MaxResourceListPathFilterWildcards, + ["item_index"] = i, + }); + } + } + + string? language = null; + if (obj.TryGetPropertyValue("lang", out var languageNode) && languageNode is not null) + { + if (languageNode is not JsonValue languageValue + || !languageValue.TryGetValue(out var parsedLanguage) + || string.IsNullOrWhiteSpace(parsedLanguage) + || parsedLanguage.Length > MaxResourceListLanguageFilterChars) + { + return CreateResourcesListFilterError( + id, + parameter: "lang", + message: $"resources/list params.lang must be a non-empty string of at most {MaxResourceListLanguageFilterChars} characters.", + suggestion: "Use an indexed language name or alias such as `csharp`, `cs`, `typescript`, or `python`.", + extraData: new JsonObject + { + ["max_value_length"] = MaxResourceListLanguageFilterChars, + }); + } + language = DbReader.NormalizeQueryLanguage(parsedLanguage); + if (string.IsNullOrEmpty(language)) + { + return CreateResourcesListFilterError( + id, + parameter: "lang", + message: "resources/list params.lang must contain at least one letter or digit after normalization.", + suggestion: "Use an indexed language name or alias such as `csharp`, `cs`, `typescript`, or `python`."); + } + } + + var includeGenerated = false; + if (obj.TryGetPropertyValue("includeGenerated", out var generatedNode) && generatedNode is not null) + { + if (generatedNode is not JsonValue generatedValue + || !generatedValue.TryGetValue(out includeGenerated)) + { + return CreateResourcesListFilterError( + id, + parameter: "includeGenerated", + message: "resources/list params.includeGenerated must be a boolean.", + suggestion: "Use true to include generated files or false to preserve the default exclusion."); + } + } + + filters = new ResourceListFilters( + pathPatterns + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(), + language, + includeGenerated); + return null; + } + + private static JsonObject CreateResourcesListFilterError( + JsonNode? id, + string parameter, + string message, + string suggestion, + JsonObject? extraData = null) + { + extraData ??= new JsonObject(); + extraData["reason"] = "resource_filter_invalid"; + extraData["parameter"] = parameter; + return CreateErrorResponse( + hasId: true, + id: id, + code: -32602, + message: message, + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: suggestion, + retrySafe: false, + extraData: extraData); + } + + private static ulong ComputeResourceListFilterFingerprint(ResourceListFilters filters) + { + var canonical = new StringBuilder("resources-list-filters-v1\n"); + canonical.Append(filters.IncludeGenerated ? "1\n" : "0\n"); + AppendFingerprintValue(canonical, filters.Language ?? string.Empty); + var canonicalPathPatterns = filters.PathPatterns + .Select(static pathPattern => + (DbReader.PathLikePatternHasWildcard(pathPattern) ? "W:" : "P:") + + DbReader.BuildPathLikePattern(pathPattern)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + canonical.Append(canonicalPathPatterns.Length).Append('\n'); + foreach (var pathPattern in canonicalPathPatterns) + AppendFingerprintValue(canonical, pathPattern); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString())); + return BinaryPrimitives.ReadUInt64BigEndian(hash); + } + + private static void AppendFingerprintValue(StringBuilder builder, string value) + => builder.Append(value.Length).Append(':').Append(value).Append('\n'); + + private static int CountUnescapedResourcePathWildcards(string pathPattern) + { + var count = 0; + var escaped = false; + foreach (var ch in pathPattern) + { + if (escaped) + { + escaped = false; + continue; + } + if (ch == '\\') + { + escaped = true; + continue; + } + if (ch is '*' or '?') + count++; + } + return count; + } + + private readonly record struct ResourceListFilters( + string[] PathPatterns, + string? Language, + bool IncludeGenerated) + { + internal static ResourceListFilters Default { get; } = new([], null, false); + + internal bool IsDefault + => PathPatterns.Length == 0 + && Language is null + && !IncludeGenerated; + } + + private static string EncodeResourceListCursor( + long generation, + long afterFileId, + ulong filterFingerprint) + { + Span payload = stackalloc byte[ResourceListCursorPayloadBytes]; + payload[0] = ResourceListCursorVersion; + BinaryPrimitives.WriteInt64BigEndian(payload[1..9], generation); + BinaryPrimitives.WriteInt64BigEndian(payload[9..17], afterFileId); + BinaryPrimitives.WriteUInt64BigEndian(payload[17..25], filterFingerprint); + return Convert.ToBase64String(payload).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static bool TryDecodeResourceListCursor(string cursor, out ResourceListCursor decoded) + { + decoded = default; + if (cursor.Length is not LegacyResourceListCursorChars and not MaxResourceListCursorChars + || cursor.Any(static ch => !char.IsAsciiLetterOrDigit(ch) && ch is not '-' and not '_')) + { + return false; + } + + var paddedLength = ((cursor.Length + 3) / 4) * 4; + Span base64 = stackalloc char[paddedLength]; + for (var i = 0; i < cursor.Length; i++) + { + base64[i] = cursor[i] switch + { + '-' => '+', + '_' => '/', + _ => cursor[i], + }; + } + base64[cursor.Length..].Fill('='); + + Span payload = stackalloc byte[ResourceListCursorPayloadBytes]; + if (!Convert.TryFromBase64Chars(base64, payload, out var bytesWritten) + || (bytesWritten != LegacyResourceListCursorPayloadBytes + && bytesWritten != ResourceListCursorPayloadBytes)) + { + return false; + } + + var version = payload[0]; + if ((version == LegacyResourceListCursorVersion && bytesWritten != LegacyResourceListCursorPayloadBytes) + || (version == ResourceListCursorVersion && bytesWritten != ResourceListCursorPayloadBytes) + || version is not LegacyResourceListCursorVersion and not ResourceListCursorVersion) + { + return false; + } + + var generation = BinaryPrimitives.ReadInt64BigEndian(payload[1..9]); + var afterFileId = BinaryPrimitives.ReadInt64BigEndian(payload[9..17]); + if (generation < 0 || afterFileId <= 0) + return false; + + decoded = version == ResourceListCursorVersion + ? new ResourceListCursor( + generation, + afterFileId, + BinaryPrimitives.ReadUInt64BigEndian(payload[17..25]), + HasFilterFingerprint: true) + : new ResourceListCursor( + generation, + afterFileId, + FilterFingerprint: 0, + HasFilterFingerprint: false); + return true; + } + + private readonly record struct ResourceListCursor( + long Generation, + long AfterFileId, + ulong FilterFingerprint, + bool HasFilterFingerprint); + + private JsonNode HandleResourcesRead(JsonNode? id, JsonNode? readParams) + { + if (readParams is not null && readParams is not JsonObject) + { + return CreateResourceReadArgumentError( + id, + "params", + "resources/read params must be an object.", + "Pass an object containing uri and optional startLine, endLine, maxBytes, cursor, and includeGenerated members."); + } + + var uri = TryReadStringValue(readParams?["uri"]); + if (string.IsNullOrWhiteSpace(uri)) + return CreateErrorResponse(hasId: true, id: id, code: -32602, message: "Missing resource uri", + category: McpErrorEnvelope.CategoryMissingParameter, + suggestion: "resources/read requires `params.uri` from resources/list or resources/templates/list, such as `cdidx://file/src/app.cs`.", + retrySafe: false); + if (uri.Length > McpBoundedText.MaxResourceUriChars) + return CreateResourceUriError(id, uri, messagePrefix: "Resource uri is too long", + suggestion: "Use a resource URI returned by resources/list or expanded from resources/templates/list, and keep it within the documented MCP resource URI length limit.", + retrySafe: false, + includeLengthLimit: true); + + if (!TryParseResourceUri(uri, out var path)) + return CreateResourceUriError(id, uri, messagePrefix: "Invalid resource uri", + suggestion: "Use a cdidx file resource URI returned by resources/list or expanded from resources/templates/list (`cdidx://file/`).", + retrySafe: false); + + if (readParams?["includeGenerated"] is JsonNode includeGeneratedNode + && (includeGeneratedNode is not JsonValue includeGeneratedValue + || !includeGeneratedValue.TryGetValue(out _))) + { + return CreateResourceReadArgumentError( + id, + "includeGenerated", + "resources/read params.includeGenerated must be a boolean.", + "Use true only when reading a generated URI returned by resources/list with includeGenerated enabled."); + } + + if (!TryReadOptionalResourceReadInteger(readParams, "startLine", out var requestedStartLine)) + return CreateResourceReadArgumentError(id, "startLine", + "resources/read params.startLine must be a positive integer.", + "Pass a 1-based line number, or omit startLine to begin at line 1."); + if (!TryReadOptionalResourceReadInteger(readParams, "endLine", out var requestedEndLine)) + return CreateResourceReadArgumentError(id, "endLine", + "resources/read params.endLine must be a positive integer.", + "Pass an inclusive 1-based line number greater than or equal to startLine, or omit endLine to read through the resource."); + if (!TryReadOptionalResourceReadInteger(readParams, "maxBytes", out var requestedMaxBytes)) + return CreateResourceReadArgumentError(id, "maxBytes", + "resources/read params.maxBytes must be an integer.", + $"Pass a UTF-8 text budget between {MinResourceReadMaxBytes} and {MaxResourceReadMaxBytes} bytes."); + if (!TryReadOptionalResourceReadString(readParams, "cursor", out var cursorText)) + return CreateResourceReadArgumentError(id, "cursor", + "resources/read params.cursor must be a non-empty string.", + "Use the nextCursor returned in result._meta, or omit cursor to start a new range."); + + if (requestedStartLine is <= 0) + return CreateResourceReadIntegerRangeError(id, "startLine", 1, int.MaxValue, requestedStartLine.Value); + if (requestedEndLine is <= 0) + return CreateResourceReadIntegerRangeError(id, "endLine", 1, int.MaxValue, requestedEndLine.Value); + if (requestedStartLine.HasValue && requestedEndLine.HasValue && requestedEndLine.Value < requestedStartLine.Value) + return CreateResourceReadArgumentError(id, "endLine", + "resources/read params.endLine must be greater than or equal to params.startLine.", + "Increase endLine or start a new range with matching 1-based boundaries."); + + var maxBytes = requestedMaxBytes ?? DefaultResourceReadMaxBytes; + if (maxBytes < MinResourceReadMaxBytes || maxBytes > MaxResourceReadMaxBytes) + return CreateResourceReadIntegerRangeError(id, "maxBytes", MinResourceReadMaxBytes, MaxResourceReadMaxBytes, maxBytes); + + ResourceReadCursor? cursor = null; + if (cursorText is not null) + { + if (requestedStartLine.HasValue || requestedEndLine.HasValue) + return CreateResourceReadArgumentError(id, "cursor", + "resources/read params.cursor cannot be combined with startLine or endLine.", + "Continue with cursor and an optional maxBytes value, or omit cursor to start a new line range."); + if (cursorText.Length > MaxResourceReadCursorCharacters || !TryParseResourceReadCursor(cursorText, out var parsedCursor)) + return CreateResourceReadArgumentError(id, "cursor", + "resources/read params.cursor is invalid or expired.", + "Use the exact nextCursor returned by the previous resources/read response, or omit cursor to restart the range.", + new JsonObject + { + ["maxCursorCharacters"] = MaxResourceReadCursorCharacters, + }); + cursor = parsedCursor; + } + + return WithDbReader(id, args: readParams, reader => reader.RunInReadSnapshot(() => + { + var file = reader.GetResourceFileMetadata(path); + if (file == null) + return CreateResourceUriError(id, uri, messagePrefix: "Resource not found", + suggestion: "Verify the exact indexed path through resources/templates/list or call resources/list again, then retry with a matching resource URI.", + retrySafe: true); + + var fingerprint = BuildResourceReadFingerprint(file.Path, file.Checksum, file.Size, file.Lines, file.Modified); + if (cursor is { } suppliedCursor && !string.Equals(suppliedCursor.Fingerprint, fingerprint, StringComparison.Ordinal)) + return CreateResourceReadArgumentError(id, "cursor", + "resources/read params.cursor no longer matches the indexed resource.", + "The resource changed after the previous page. Omit cursor and restart the range to avoid skipped or duplicated text.", + new JsonObject + { + ["cursorStale"] = true, + }); + + ResourceReadMetadataLoadedForTests?.Invoke(); + + var isEmpty = file.Size >= 0 + && DbReader.IsAffirmativelyEmptyIndexedFile(file.Lines, file.Checksum); + var totalLines = Math.Max(0, file.Lines); + var hasReadableLines = !isEmpty && file.Lines > 0; + if (isEmpty && cursor.HasValue) + return CreateResourceReadArgumentError(id, "cursor", + "resources/read params.cursor does not identify a readable position in this empty resource.", + "Omit cursor and restart the resource read without line boundaries."); + + var startLine = isEmpty ? 0 : hasReadableLines ? cursor?.Line ?? requestedStartLine ?? 1 : 1; + var endLine = isEmpty ? 0 : hasReadableLines ? cursor?.EndLine ?? requestedEndLine ?? totalLines : 1; + if (hasReadableLines && startLine > totalLines) + return CreateResourceReadArgumentError(id, "startLine", + $"resources/read params.startLine exceeds the resource line count ({file.Lines}).", + "Use a startLine from resources/read result._meta or restart at line 1.", + new JsonObject + { + ["totalLines"] = file.Lines, + }); + if (hasReadableLines) + endLine = Math.Min(endLine, totalLines); + if (hasReadableLines && endLine < startLine) + return CreateResourceReadArgumentError(id, "endLine", + "resources/read effective endLine is before startLine.", + "Restart the range with an endLine greater than or equal to startLine."); + + var resourceUri = BuildResourceUri(file.Path); + var mimeType = GetResourceMimeType(file.Lang); + var effectiveMaxBytes = GetEffectiveResourceReadMaxBytes( + id, + resourceUri, + mimeType, + maxBytes); + if (effectiveMaxBytes < MinResourceReadMaxBytes) + return CreateErrorResponse(hasId: true, id: id, code: -32603, + message: "The configured MCP response limit is too small for a resources/read page.", + category: McpErrorEnvelope.CategoryInternalError, + suggestion: "Use a smaller JSON-RPC batch, or increase CDIDX_MCP_RESPONSE_MAX_BYTES or CDIDX_MCP_HTTP_MAX_RESPONSE_BYTES, then retry.", + retrySafe: false, + extraData: new JsonObject + { + ["reason"] = "resource_response_budget_too_small", + ["minimumContentBytes"] = MinResourceReadMaxBytes, + ["responseLimitBytes"] = GetEffectiveResourceReadResponseLimit(), + }); + + var page = reader.GetBoundedFileContent( + file, + isEmpty ? 1 : startLine, + isEmpty ? 1 : endLine, + effectiveMaxBytes, + MaxResourceReadLinesPerPage, + hasReadableLines ? cursor?.Line : null, + hasReadableLines ? cursor?.ByteOffset ?? 0 : 0); + switch (page.Status) + { + case BoundedFileReadStatus.FileNotFound: + return CreateResourceUriError(id, uri, messagePrefix: "Resource not found", + suggestion: "Verify the exact indexed path through resources/templates/list or call resources/list again, then retry with a matching resource URI.", + retrySafe: true); + case BoundedFileReadStatus.InvalidContinuation: + return CreateResourceReadArgumentError(id, "cursor", + "resources/read params.cursor does not identify a readable UTF-8 position in this resource.", + "Omit cursor and restart the range to obtain a fresh continuation token."); + case BoundedFileReadStatus.IncompleteCoverage: + case BoundedFileReadStatus.ContentUnavailable: + case BoundedFileReadStatus.InvalidTopology: + return CreateResourceReadStorageError(id, page.Status, page.FailureReason); + } + + var text = page.Content; + var returnedBytes = page.Utf8Bytes; + var truncated = page.Truncated && page.NextLine.HasValue; + var metadata = new JsonObject + { + ["startLine"] = startLine, + ["startLineByteOffset"] = cursor?.ByteOffset ?? 0, + ["endLine"] = endLine, + ["totalLines"] = totalLines, + ["maxBytes"] = maxBytes, + ["maxLines"] = MaxResourceReadLinesPerPage, + ["returnedStartLine"] = isEmpty ? 0 : page.StartLine, + ["returnedEndLine"] = isEmpty ? 0 : page.EndLine, + ["returnedBytes"] = returnedBytes, + ["truncated"] = truncated, + }; + if (effectiveMaxBytes != maxBytes) + metadata["effectiveMaxBytes"] = effectiveMaxBytes; + if (truncated) + { + metadata["truncationReason"] = page.TruncationReason switch + { + "max_lines" => "maxLines", + "max_bytes" when effectiveMaxBytes < maxBytes => "maxResponseBytes", + _ => "maxBytes", + }; + metadata["nextLine"] = page.NextLine!.Value; + metadata["nextLineByteOffset"] = page.NextByteOffset ?? 0; + metadata["nextCursor"] = BuildResourceReadCursor( + page.NextLine.Value, + page.NextByteOffset ?? 0, + endLine, + fingerprint); + } + + var contents = new JsonArray + { + new JsonObject + { + ["uri"] = resourceUri, + ["mimeType"] = mimeType, + ["text"] = text, + } + }; + return CreateSuccessResponse(true, id, new JsonObject + { + ["contents"] = contents, + ["_meta"] = metadata, + }); + })); + } + + private JsonObject CreateResourceReadStorageError( + JsonNode? id, + BoundedFileReadStatus status, + string? reason) + { + var normalizedReason = reason ?? status switch + { + BoundedFileReadStatus.IncompleteCoverage => "resource_chunk_coverage_incomplete", + BoundedFileReadStatus.ContentUnavailable => "resource_content_unavailable", + _ => "resource_chunk_topology_invalid", + }; + var extraData = new JsonObject + { + ["reason"] = normalizedReason, + }; + if (status == BoundedFileReadStatus.InvalidTopology) + { + extraData["maxChunks"] = DbReader.MaxBoundedFileReadChunks; + extraData["maxScannedBytes"] = DbReader.MaxBoundedFileReadScannedUtf8Bytes; + } + + return status switch + { + BoundedFileReadStatus.IncompleteCoverage => CreateErrorResponse(hasId: true, id: id, + code: McpErrorEnvelope.CodeIndexStale, + message: "Indexed resource chunks do not cover the requested range.", + category: McpErrorEnvelope.CategoryIndexStale, + suggestion: "Refresh or rebuild the index, then call resources/list and retry the read.", + retrySafe: true, + extraData: extraData), + BoundedFileReadStatus.ContentUnavailable => CreateErrorResponse(hasId: true, id: id, + code: McpErrorEnvelope.CodeIndexMissing, + message: "Indexed content is unavailable for this non-empty resource.", + category: McpErrorEnvelope.CategoryIndexMissing, + suggestion: "Inspect file issues, resolve skipped-content diagnostics, and rebuild the index before retrying.", + retrySafe: true, + extraData: extraData), + _ => CreateErrorResponse(hasId: true, id: id, + code: McpErrorEnvelope.CodeIndexCorrupted, + message: "Indexed resource storage metadata is inconsistent or exceeds safe read limits.", + category: McpErrorEnvelope.CategoryIndexCorrupted, + suggestion: "Delete the index database, rebuild it, and retry with a resource URI from resources/list.", + retrySafe: false, + extraData: extraData), + }; + } + + private int GetEffectiveResourceReadMaxBytes( + JsonNode? id, + string resourceUri, + string mimeType, + int requestedMaxBytes) + { + var worstCaseMetadata = new JsonObject + { + ["startLine"] = int.MaxValue, + ["startLineByteOffset"] = int.MaxValue, + ["endLine"] = int.MaxValue, + ["totalLines"] = int.MaxValue, + ["maxBytes"] = requestedMaxBytes, + ["effectiveMaxBytes"] = int.MaxValue, + ["maxLines"] = MaxResourceReadLinesPerPage, + ["returnedStartLine"] = int.MaxValue, + ["returnedEndLine"] = int.MaxValue, + ["returnedBytes"] = int.MaxValue, + ["truncated"] = true, + ["truncationReason"] = "maxResponseBytes", + ["nextLine"] = int.MaxValue, + ["nextLineByteOffset"] = int.MaxValue, + ["nextCursor"] = new string('x', MaxResourceReadCursorCharacters), + }; + var worstCaseResponse = CreateSuccessResponse(true, id, new JsonObject + { + ["contents"] = new JsonArray + { + new JsonObject + { + ["uri"] = resourceUri, + ["mimeType"] = mimeType, + ["text"] = string.Empty, + }, + }, + ["_meta"] = worstCaseMetadata, + }); + var envelopeBytes = Encoding.UTF8.GetByteCount(worstCaseResponse.ToJsonString(_jsonOptions)); + var availableEncodedTextBytes = GetEffectiveResourceReadResponseLimit() - envelopeBytes; + if (availableEncodedTextBytes <= 0) + return 0; + + // System.Text.Json's default encoder expands any valid source UTF-8 byte by at most + // six bytes (`\uXXXX` for an ASCII control or HTML-sensitive character). + // System.Text.Json既定encoderで有効なsource UTF-8 1 byteが展開される最大は6 byte + // (ASCII control/HTML-sensitive文字の`\uXXXX`)。 + const int worstCaseJsonExpansion = 6; + return Math.Min(requestedMaxBytes, availableEncodedTextBytes / worstCaseJsonExpansion); + } + + private int GetEffectiveResourceReadResponseLimit() + { + var responseLimit = GetMaxResponseBytes(); + var transportLimit = Volatile.Read(ref _activeTransportMaxResponseBytes); + if (transportLimit > 0) + responseLimit = Math.Min(responseLimit, transportLimit); + if (_currentBatchResponseItemMaxBytes.Value is { } batchLimit) + responseLimit = Math.Min(responseLimit, Math.Max(0, batchLimit)); + return responseLimit; + } + + private readonly record struct ResourceReadCursor(int Line, int ByteOffset, int EndLine, string Fingerprint); + + private static bool TryReadOptionalResourceReadInteger(JsonNode? readParams, string name, out int? result) + { + result = null; + if (readParams is not JsonObject obj || !obj.TryGetPropertyValue(name, out var node) || node is null) + return true; + if (node is not JsonValue value || !value.TryGetValue(out var parsed)) + return false; + result = parsed; + return true; + } + + private static bool TryReadOptionalResourceReadString(JsonNode? readParams, string name, out string? result) + { + result = null; + if (readParams is not JsonObject obj || !obj.TryGetPropertyValue(name, out var node) || node is null) + return true; + if (node is not JsonValue value || !value.TryGetValue(out var parsed) || string.IsNullOrWhiteSpace(parsed)) + return false; + result = parsed; + return true; + } + + private static JsonObject CreateResourceReadIntegerRangeError(JsonNode? id, string argument, int minimum, int maximum, int actual) + => CreateResourceReadArgumentError(id, argument, + $"resources/read params.{argument} must be between {minimum} and {maximum}.", + $"Choose a {argument} value inside the documented resources/read range.", + new JsonObject + { + ["minimum"] = minimum, + ["maximum"] = maximum, + ["actual"] = actual, + }); + + private static JsonObject CreateResourceReadArgumentError( + JsonNode? id, + string argument, + string message, + string suggestion, + JsonObject? extraData = null) + { + var data = extraData ?? new JsonObject(); + data["argument"] = argument; + return CreateErrorResponse(hasId: true, id: id, code: -32602, message: message, + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: suggestion, + retrySafe: false, + extraData: data); + } + + private static bool TryParseResourceReadCursor(string value, out ResourceReadCursor cursor) + { + cursor = default; + var parts = value.Split(':'); + if (parts.Length != 5 + || !string.Equals(parts[0], "v1", StringComparison.Ordinal) + || !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var line) + || !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var byteOffset) + || !int.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out var endLine) + || line <= 0 + || byteOffset < 0 + || byteOffset > DbReader.MaxBoundedFileReadScannedUtf8Bytes + || endLine < line + || parts[4].Length != 16) + { + return false; + } + + cursor = new ResourceReadCursor(line, byteOffset, endLine, parts[4]); + return true; + } + + private static string BuildResourceReadCursor(int line, int byteOffset, int endLine, string fingerprint) + => string.Create(CultureInfo.InvariantCulture, $"v1:{line}:{byteOffset}:{endLine}:{fingerprint}"); + + private static string BuildResourceReadFingerprint(string path, string? checksum, long size, int lines, DateTime? modified) + { + var descriptor = string.Create( + CultureInfo.InvariantCulture, + $"{path}\n{checksum ?? string.Empty}\n{size}\n{lines}\n{modified?.ToUniversalTime().Ticks ?? 0}"); + Span digest = stackalloc byte[32]; + SHA256.HashData(Encoding.UTF8.GetBytes(descriptor), digest); + return Convert.ToHexString(digest[..8]); + } + + private static JsonNode CreateResourceUriError(JsonNode? id, string uri, string messagePrefix, string suggestion, bool retrySafe, bool includeLengthLimit = false) + { + var display = McpBoundedText.ForDisplay(uri, McpBoundedText.MaxResourceUriChars); + var data = new JsonObject + { + ["uri"] = display.Text, + }; + display.AddMetadata(data, "uri"); + if (includeLengthLimit) + { + data["max_length"] = McpBoundedText.MaxResourceUriChars; + data["actual_length"] = uri.Length; + } + return CreateErrorResponse(hasId: true, id: id, code: -32602, message: $"{messagePrefix}: {display.Text}", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: suggestion, + retrySafe: retrySafe, + extraData: data); + } + +} diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index c9b300f0d..504f647c9 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -470,1111 +470,6 @@ internal TimeSpan InFlightPostCancelGracePeriod } - private static JsonNode HandleResourceTemplatesList(JsonNode? id, JsonNode? templateParams) - { - if (templateParams is not null && templateParams is not JsonObject) - { - return CreateErrorResponse(hasId: true, id: id, code: -32602, - message: "resources/templates/list params must be an object.", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Pass an empty params object or omit params.", - retrySafe: false); - } - - if (templateParams?["cursor"] is not null) - { - return CreateErrorResponse(hasId: true, id: id, code: -32602, - message: "resources/templates/list does not have another page.", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Omit params.cursor; the complete resource template catalog fits in one response.", - retrySafe: false); - } - - return CreateSuccessResponse(true, id, new JsonObject - { - ["resourceTemplates"] = new JsonArray - { - new JsonObject - { - ["uriTemplate"] = "cdidx://file-path/{path}", - ["name"] = "indexed-file", - ["title"] = "Indexed repository file", - ["description"] = "Read one indexed, non-generated file by its exact repository-relative path. The template-only file-path resolver decodes the URI-template value, validates it as a relative path, and returns the canonical cdidx://file resource identity.", - }, - }, - }); - } - - private JsonNode HandleResourcesList(JsonNode? id, JsonNode? listParams) - { - var filterError = ValidateResourceListFilters(id, listParams, out var filters); - if (filterError is not null) - return filterError; - var filterFingerprint = ComputeResourceListFilterFingerprint(filters); - - var requestedMaxBytes = DefaultResourceListMaxBytes; - if (listParams?["maxBytes"] is JsonNode maxBytesNode) - { - if (maxBytesNode is not JsonValue maxBytesValue - || !maxBytesValue.TryGetValue(out requestedMaxBytes) - || requestedMaxBytes < MinResourceListMaxBytes - || requestedMaxBytes > MaxResourceListMaxBytes) - { - return CreateResourcesListMaxBytesError(id); - } - } - var effectiveMaxBytes = Math.Min(requestedMaxBytes, GetMaxResponseBytes()); - var activeTransportMaxResponseBytes = Volatile.Read(ref _activeTransportMaxResponseBytes); - if (activeTransportMaxResponseBytes > 0) - effectiveMaxBytes = Math.Min(effectiveMaxBytes, activeTransportMaxResponseBytes); - if (_currentBatchResponseItemMaxBytes.Value is { } batchResponseItemMaxBytes) - effectiveMaxBytes = Math.Min(effectiveMaxBytes, batchResponseItemMaxBytes); - - long? afterFileId = null; - long? expectedGeneration = null; - var legacyOffset = 0; - if (listParams?["cursor"] is JsonNode cursorNode) - { - if (cursorNode is not JsonValue cursorValue - || !cursorValue.TryGetValue(out var cursor)) - { - return CreateResourcesListCursorError(id); - } - - if (cursor.Length > MaxResourceListCursorChars) - return CreateResourcesListCursorError(id); - - if (int.TryParse(cursor, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedLegacyOffset)) - { - if (parsedLegacyOffset < 0 || parsedLegacyOffset > MaxMcpPaginationOffset) - return CreateResourcesListCursorError(id); - if (parsedLegacyOffset != 0) - return CreateResourcesListRestartError(id); - } - else if (TryDecodeResourceListCursor(cursor, out var decodedCursor)) - { - if ((decodedCursor.HasFilterFingerprint - && decodedCursor.FilterFingerprint != filterFingerprint) - || (!decodedCursor.HasFilterFingerprint && !filters.IsDefault)) - { - return CreateResourcesListFilterMismatchError(id); - } - afterFileId = decodedCursor.AfterFileId; - expectedGeneration = decodedCursor.Generation; - } - else - { - return CreateResourcesListCursorError(id); - } - } - - return WithDbReader(id, args: listParams, reader => - { - var resourcePage = reader.ListResourceFiles( - limit: ResourceListPageSize + 1, - afterFileId: afterFileId, - expectedGeneration: expectedGeneration, - legacyOffset: legacyOffset, - pathPatterns: filters.PathPatterns, - lang: filters.Language, - includeGenerated: filters.IncludeGenerated); - if (resourcePage.GenerationTrackingUnavailable) - return CreateResourcesListGenerationUnavailableError(id); - if (resourcePage.CursorRestartRequired) - return CreateResourcesListRestartError(id); - - var page = resourcePage.Files.Take(ResourceListPageSize).ToArray(); - var resources = new JsonArray(); - var reservedResponse = CreateResourceListResponse( - id, - resources: [], - generation: long.MaxValue, - lastConsumedFileId: long.MaxValue, - filterFingerprint: ulong.MaxValue, - hasContinuation: true, - requestedMaxBytes: MaxResourceListMaxBytes, - effectiveMaxBytes: MaxResourceListMaxBytes, - candidatesConsumed: ResourceListPageSize, - uriTooLongCount: ResourceListPageSize, - resourceExceedsMaxBytesCount: ResourceListPageSize, - byteBudgetReached: true); - _ = TryMeasureJsonUtf8BytesWithinLimit( - reservedResponse, - _jsonOptions, - int.MaxValue, - out var reservedResponseBytes); - if (reservedResponseBytes > effectiveMaxBytes) - return CreateResourcesListEffectiveMaxBytesError(id, requestedMaxBytes, effectiveMaxBytes); - - var acceptedResourceBytes = 0L; - var candidatesConsumed = 0; - var uriTooLongCount = 0; - var resourceExceedsMaxBytesCount = 0; - var byteBudgetReached = false; - var stoppedForByteBudget = false; - long? lastConsumedFileId = null; - foreach (var file in page) - { - var uri = BuildResourceUri(file.Path); - if (uri.Length > McpBoundedText.MaxResourceUriChars) - { - uriTooLongCount++; - candidatesConsumed++; - lastConsumedFileId = file.Id; - continue; - } - - var resource = new JsonObject - { - ["uri"] = uri, - ["name"] = file.Path, - ["description"] = $"{file.Path} ({file.Lang ?? "unknown"}, {file.Lines} lines)", - ["mimeType"] = GetResourceMimeType(file.Lang), - }; - var resourceFitsAlone = TryMeasureJsonUtf8BytesWithinLimit( - resource, - _jsonOptions, - effectiveMaxBytes, - out var resourceBytes); - var commaBytes = resources.Count == 0 ? 0 : 1; - var resourceFitsEmptyPage = resourceFitsAlone - && reservedResponseBytes + resourceBytes <= effectiveMaxBytes; - var resourceFitsPage = resourceFitsEmptyPage - && reservedResponseBytes + acceptedResourceBytes + commaBytes + resourceBytes <= effectiveMaxBytes; - if (!resourceFitsPage) - { - byteBudgetReached = true; - if (resourceFitsEmptyPage || resources.Count > 0) - { - stoppedForByteBudget = true; - break; - } - - // Consume resources that cannot fit even on an empty page so the cursor cannot livelock. - // 空ページにも収まらない resource は消費・報告し、cursor の livelock を防ぐ。 - resourceExceedsMaxBytesCount++; - candidatesConsumed++; - lastConsumedFileId = file.Id; - continue; - } - - resources.Add(resource); - acceptedResourceBytes += commaBytes + resourceBytes; - candidatesConsumed++; - lastConsumedFileId = file.Id; - } - - var hasContinuation = stoppedForByteBudget || resourcePage.Files.Count > ResourceListPageSize; - var response = CreateResourceListResponse( - id, - resources, - resourcePage.Generation, - lastConsumedFileId, - filterFingerprint, - hasContinuation, - requestedMaxBytes, - effectiveMaxBytes, - candidatesConsumed, - uriTooLongCount, - resourceExceedsMaxBytesCount, - byteBudgetReached); - - if (!TryMeasureJsonUtf8BytesWithinLimit(response, _jsonOptions, effectiveMaxBytes, out _)) - return CreateResourcesListEffectiveMaxBytesError(id, requestedMaxBytes, effectiveMaxBytes); - return response; - }); - } - - private static JsonObject CreateResourcesListMaxBytesError(JsonNode? id) - => CreateErrorResponse(hasId: true, id: id, code: -32602, - message: $"resources/list maxBytes must be between {MinResourceListMaxBytes} and {MaxResourceListMaxBytes}.", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Use an integer params.maxBytes within the documented range, or omit it to use the default.", - retrySafe: false, - extraData: new JsonObject - { - ["min_max_bytes"] = MinResourceListMaxBytes, - ["max_max_bytes"] = MaxResourceListMaxBytes, - ["default_max_bytes"] = DefaultResourceListMaxBytes, - }); - - private static JsonObject CreateResourcesListEffectiveMaxBytesError( - JsonNode? id, - int requestedMaxBytes, - int effectiveMaxBytes) - => CreateErrorResponse(hasId: true, id: id, code: -32602, - message: "resources/list response metadata does not fit within the effective byte limit.", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Raise the MCP response byte limit or request a larger params.maxBytes value.", - retrySafe: false, - extraData: new JsonObject - { - ["requested_max_bytes"] = requestedMaxBytes, - ["effective_max_bytes"] = effectiveMaxBytes, - }); - - private static JsonObject CreateResourceListResponse( - JsonNode? id, - JsonArray resources, - long generation, - long? lastConsumedFileId, - ulong filterFingerprint, - bool hasContinuation, - int requestedMaxBytes, - int effectiveMaxBytes, - int candidatesConsumed, - int uriTooLongCount, - int resourceExceedsMaxBytesCount, - bool byteBudgetReached) - { - var result = new JsonObject - { - ["resources"] = resources, - ["_meta"] = new JsonObject - { - ["response_controls"] = CreateResourceListResponseControls( - requestedMaxBytes, - effectiveMaxBytes, - candidatesConsumed, - resources.Count, - uriTooLongCount, - resourceExceedsMaxBytesCount, - byteBudgetReached, - hasContinuation), - }, - }; - if (hasContinuation && lastConsumedFileId is not null) - result["nextCursor"] = EncodeResourceListCursor(generation, lastConsumedFileId.Value, filterFingerprint); - return CreateSuccessResponse(true, id, result); - } - - private static JsonObject CreateResourceListResponseControls( - int requestedMaxBytes, - int effectiveMaxBytes, - int candidatesConsumed, - int resourcesReturned, - int uriTooLongCount, - int resourceExceedsMaxBytesCount, - bool byteBudgetReached, - bool hasContinuation) - => new() - { - ["requested_max_bytes"] = requestedMaxBytes, - ["effective_max_bytes"] = effectiveMaxBytes, - ["page_item_limit"] = ResourceListPageSize, - ["resource_candidates_consumed"] = candidatesConsumed, - ["resources_returned"] = resourcesReturned, - ["omitted_resource_count"] = uriTooLongCount + resourceExceedsMaxBytesCount, - ["omitted_resource_reason_counts"] = new JsonObject - { - ["resource_uri_too_long"] = uriTooLongCount, - ["resource_exceeds_max_bytes"] = resourceExceedsMaxBytesCount, - }, - ["byte_budget_reached"] = byteBudgetReached, - ["continuation_reason"] = hasContinuation - ? byteBudgetReached ? "byte_budget" : "item_limit" - : "completed", - }; - - private static JsonObject CreateResourcesListCursorError(JsonNode? id) - => CreateErrorResponse(hasId: true, id: id, code: -32602, - message: "resources/list cursor is invalid or unsupported.", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Use the `nextCursor` value returned by the previous resources/list response, or omit params.cursor to start from the first page.", - retrySafe: false, - extraData: new JsonObject - { - ["max_cursor_length"] = MaxResourceListCursorChars, - ["max_legacy_pagination_offset"] = MaxMcpPaginationOffset, - }); - - private static JsonObject CreateResourcesListFilterMismatchError(JsonNode? id) - => CreateErrorResponse(hasId: true, id: id, code: -32602, - message: "The resources/list filters do not match the supplied cursor.", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Continue with the same path, lang, and includeGenerated filters used to create the cursor, or omit params.cursor to start a new filtered listing.", - retrySafe: false, - extraData: new JsonObject - { - ["reason"] = "resources_list_filters_changed", - ["restart_required"] = true, - }); - - private static JsonObject CreateResourcesListRestartError(JsonNode? id) - => CreateErrorResponse(hasId: true, id: id, code: McpErrorEnvelope.CodeIndexStale, - message: "The indexed file set changed after this resources/list cursor was issued.", - category: McpErrorEnvelope.CategoryIndexStale, - suggestion: "Omit params.cursor and restart resources/list from the first page.", - retrySafe: false, - extraData: new JsonObject - { - ["reason"] = "resources_list_generation_changed", - ["restart_required"] = true, - }); - - private static JsonObject CreateResourcesListGenerationUnavailableError(JsonNode? id) - => CreateErrorResponse(hasId: true, id: id, code: McpErrorEnvelope.CodeIndexStale, - message: "This database cannot prove a stable resources/list generation.", - category: McpErrorEnvelope.CategoryIndexStale, - suggestion: "Open the database on writable storage and run `cdidx index ` with the current cdidx to install generation tracking. Use an `immutable=1` URI only for a snapshot guaranteed not to change.", - retrySafe: false, - extraData: new JsonObject - { - ["reason"] = "resources_list_generation_unavailable", - ["migration_required"] = true, - ["restart_required"] = false, - }); - - private static JsonObject? ValidateResourceListFilters( - JsonNode? id, - JsonNode? listParams, - out ResourceListFilters filters) - { - filters = ResourceListFilters.Default; - if (listParams is null) - return null; - if (listParams is not JsonObject obj) - { - return CreateResourcesListFilterError( - id, - parameter: "params", - message: "resources/list params must be an object.", - suggestion: "Pass an object containing optional cursor, maxBytes, path, lang, and includeGenerated members."); - } - - var pathPatterns = new List(); - if (obj.TryGetPropertyValue("path", out var pathNode) && pathNode is not null) - { - if (pathNode is JsonValue pathValue - && pathValue.TryGetValue(out var scalarPath)) - { - pathPatterns.Add(scalarPath); - } - else if (pathNode is JsonArray pathArray) - { - if (pathArray.Count > MaxResourceListPathFilterCount) - { - return CreateResourcesListFilterError( - id, - parameter: "path", - message: $"resources/list params.path accepts at most {MaxResourceListPathFilterCount} values.", - suggestion: "Reduce the path filter array and retry.", - extraData: new JsonObject - { - ["max_item_count"] = MaxResourceListPathFilterCount, - ["actual_item_count"] = pathArray.Count, - }); - } - - foreach (var item in pathArray) - { - if (item is not JsonValue itemValue - || !itemValue.TryGetValue(out var pathText)) - { - return CreateResourcesListFilterError( - id, - parameter: "path", - message: "resources/list params.path array items must be strings.", - suggestion: "Use a single path string or an array containing only non-empty path strings."); - } - pathPatterns.Add(pathText); - } - } - else - { - return CreateResourcesListFilterError( - id, - parameter: "path", - message: "resources/list params.path must be a string or an array of strings.", - suggestion: "Use repository-relative path text or bounded glob-style path patterns."); - } - } - - for (var i = 0; i < pathPatterns.Count; i++) - { - var pathPattern = pathPatterns[i]; - if (string.IsNullOrWhiteSpace(pathPattern) - || pathPattern.Length > MaxResourceListPathFilterChars) - { - return CreateResourcesListFilterError( - id, - parameter: "path", - message: $"resources/list params.path values must contain text and be at most {MaxResourceListPathFilterChars} characters.", - suggestion: "Use a shorter non-empty repository-relative path filter.", - extraData: new JsonObject - { - ["max_value_length"] = MaxResourceListPathFilterChars, - ["item_index"] = i, - ["actual_value_length"] = pathPattern?.Length ?? 0, - }); - } - - if (CountUnescapedResourcePathWildcards(pathPattern) > MaxResourceListPathFilterWildcards) - { - return CreateResourcesListFilterError( - id, - parameter: "path", - message: $"resources/list params.path values may contain at most {MaxResourceListPathFilterWildcards} wildcard operators.", - suggestion: "Split the path filters or use a narrower directory prefix.", - extraData: new JsonObject - { - ["max_wildcard_count"] = MaxResourceListPathFilterWildcards, - ["item_index"] = i, - }); - } - } - - string? language = null; - if (obj.TryGetPropertyValue("lang", out var languageNode) && languageNode is not null) - { - if (languageNode is not JsonValue languageValue - || !languageValue.TryGetValue(out var parsedLanguage) - || string.IsNullOrWhiteSpace(parsedLanguage) - || parsedLanguage.Length > MaxResourceListLanguageFilterChars) - { - return CreateResourcesListFilterError( - id, - parameter: "lang", - message: $"resources/list params.lang must be a non-empty string of at most {MaxResourceListLanguageFilterChars} characters.", - suggestion: "Use an indexed language name or alias such as `csharp`, `cs`, `typescript`, or `python`.", - extraData: new JsonObject - { - ["max_value_length"] = MaxResourceListLanguageFilterChars, - }); - } - language = DbReader.NormalizeQueryLanguage(parsedLanguage); - if (string.IsNullOrEmpty(language)) - { - return CreateResourcesListFilterError( - id, - parameter: "lang", - message: "resources/list params.lang must contain at least one letter or digit after normalization.", - suggestion: "Use an indexed language name or alias such as `csharp`, `cs`, `typescript`, or `python`."); - } - } - - var includeGenerated = false; - if (obj.TryGetPropertyValue("includeGenerated", out var generatedNode) && generatedNode is not null) - { - if (generatedNode is not JsonValue generatedValue - || !generatedValue.TryGetValue(out includeGenerated)) - { - return CreateResourcesListFilterError( - id, - parameter: "includeGenerated", - message: "resources/list params.includeGenerated must be a boolean.", - suggestion: "Use true to include generated files or false to preserve the default exclusion."); - } - } - - filters = new ResourceListFilters( - pathPatterns - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal) - .ToArray(), - language, - includeGenerated); - return null; - } - - private static JsonObject CreateResourcesListFilterError( - JsonNode? id, - string parameter, - string message, - string suggestion, - JsonObject? extraData = null) - { - extraData ??= new JsonObject(); - extraData["reason"] = "resource_filter_invalid"; - extraData["parameter"] = parameter; - return CreateErrorResponse( - hasId: true, - id: id, - code: -32602, - message: message, - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: suggestion, - retrySafe: false, - extraData: extraData); - } - - private static ulong ComputeResourceListFilterFingerprint(ResourceListFilters filters) - { - var canonical = new StringBuilder("resources-list-filters-v1\n"); - canonical.Append(filters.IncludeGenerated ? "1\n" : "0\n"); - AppendFingerprintValue(canonical, filters.Language ?? string.Empty); - var canonicalPathPatterns = filters.PathPatterns - .Select(static pathPattern => - (DbReader.PathLikePatternHasWildcard(pathPattern) ? "W:" : "P:") - + DbReader.BuildPathLikePattern(pathPattern)) - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal) - .ToArray(); - canonical.Append(canonicalPathPatterns.Length).Append('\n'); - foreach (var pathPattern in canonicalPathPatterns) - AppendFingerprintValue(canonical, pathPattern); - - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString())); - return BinaryPrimitives.ReadUInt64BigEndian(hash); - } - - private static void AppendFingerprintValue(StringBuilder builder, string value) - => builder.Append(value.Length).Append(':').Append(value).Append('\n'); - - private static int CountUnescapedResourcePathWildcards(string pathPattern) - { - var count = 0; - var escaped = false; - foreach (var ch in pathPattern) - { - if (escaped) - { - escaped = false; - continue; - } - if (ch == '\\') - { - escaped = true; - continue; - } - if (ch is '*' or '?') - count++; - } - return count; - } - - private readonly record struct ResourceListFilters( - string[] PathPatterns, - string? Language, - bool IncludeGenerated) - { - internal static ResourceListFilters Default { get; } = new([], null, false); - - internal bool IsDefault - => PathPatterns.Length == 0 - && Language is null - && !IncludeGenerated; - } - - private static string EncodeResourceListCursor( - long generation, - long afterFileId, - ulong filterFingerprint) - { - Span payload = stackalloc byte[ResourceListCursorPayloadBytes]; - payload[0] = ResourceListCursorVersion; - BinaryPrimitives.WriteInt64BigEndian(payload[1..9], generation); - BinaryPrimitives.WriteInt64BigEndian(payload[9..17], afterFileId); - BinaryPrimitives.WriteUInt64BigEndian(payload[17..25], filterFingerprint); - return Convert.ToBase64String(payload).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - } - - private static bool TryDecodeResourceListCursor(string cursor, out ResourceListCursor decoded) - { - decoded = default; - if (cursor.Length is not LegacyResourceListCursorChars and not MaxResourceListCursorChars - || cursor.Any(static ch => !char.IsAsciiLetterOrDigit(ch) && ch is not '-' and not '_')) - { - return false; - } - - var paddedLength = ((cursor.Length + 3) / 4) * 4; - Span base64 = stackalloc char[paddedLength]; - for (var i = 0; i < cursor.Length; i++) - { - base64[i] = cursor[i] switch - { - '-' => '+', - '_' => '/', - _ => cursor[i], - }; - } - base64[cursor.Length..].Fill('='); - - Span payload = stackalloc byte[ResourceListCursorPayloadBytes]; - if (!Convert.TryFromBase64Chars(base64, payload, out var bytesWritten) - || (bytesWritten != LegacyResourceListCursorPayloadBytes - && bytesWritten != ResourceListCursorPayloadBytes)) - { - return false; - } - - var version = payload[0]; - if ((version == LegacyResourceListCursorVersion && bytesWritten != LegacyResourceListCursorPayloadBytes) - || (version == ResourceListCursorVersion && bytesWritten != ResourceListCursorPayloadBytes) - || version is not LegacyResourceListCursorVersion and not ResourceListCursorVersion) - { - return false; - } - - var generation = BinaryPrimitives.ReadInt64BigEndian(payload[1..9]); - var afterFileId = BinaryPrimitives.ReadInt64BigEndian(payload[9..17]); - if (generation < 0 || afterFileId <= 0) - return false; - - decoded = version == ResourceListCursorVersion - ? new ResourceListCursor( - generation, - afterFileId, - BinaryPrimitives.ReadUInt64BigEndian(payload[17..25]), - HasFilterFingerprint: true) - : new ResourceListCursor( - generation, - afterFileId, - FilterFingerprint: 0, - HasFilterFingerprint: false); - return true; - } - - private readonly record struct ResourceListCursor( - long Generation, - long AfterFileId, - ulong FilterFingerprint, - bool HasFilterFingerprint); - - private JsonNode HandleResourcesRead(JsonNode? id, JsonNode? readParams) - { - if (readParams is not null && readParams is not JsonObject) - { - return CreateResourceReadArgumentError( - id, - "params", - "resources/read params must be an object.", - "Pass an object containing uri and optional startLine, endLine, maxBytes, cursor, and includeGenerated members."); - } - - var uri = TryReadStringValue(readParams?["uri"]); - if (string.IsNullOrWhiteSpace(uri)) - return CreateErrorResponse(hasId: true, id: id, code: -32602, message: "Missing resource uri", - category: McpErrorEnvelope.CategoryMissingParameter, - suggestion: "resources/read requires `params.uri` from resources/list or resources/templates/list, such as `cdidx://file/src/app.cs`.", - retrySafe: false); - if (uri.Length > McpBoundedText.MaxResourceUriChars) - return CreateResourceUriError(id, uri, messagePrefix: "Resource uri is too long", - suggestion: "Use a resource URI returned by resources/list or expanded from resources/templates/list, and keep it within the documented MCP resource URI length limit.", - retrySafe: false, - includeLengthLimit: true); - - if (!TryParseResourceUri(uri, out var path)) - return CreateResourceUriError(id, uri, messagePrefix: "Invalid resource uri", - suggestion: "Use a cdidx file resource URI returned by resources/list or expanded from resources/templates/list (`cdidx://file/`).", - retrySafe: false); - - if (readParams?["includeGenerated"] is JsonNode includeGeneratedNode - && (includeGeneratedNode is not JsonValue includeGeneratedValue - || !includeGeneratedValue.TryGetValue(out _))) - { - return CreateResourceReadArgumentError( - id, - "includeGenerated", - "resources/read params.includeGenerated must be a boolean.", - "Use true only when reading a generated URI returned by resources/list with includeGenerated enabled."); - } - - if (!TryReadOptionalResourceReadInteger(readParams, "startLine", out var requestedStartLine)) - return CreateResourceReadArgumentError(id, "startLine", - "resources/read params.startLine must be a positive integer.", - "Pass a 1-based line number, or omit startLine to begin at line 1."); - if (!TryReadOptionalResourceReadInteger(readParams, "endLine", out var requestedEndLine)) - return CreateResourceReadArgumentError(id, "endLine", - "resources/read params.endLine must be a positive integer.", - "Pass an inclusive 1-based line number greater than or equal to startLine, or omit endLine to read through the resource."); - if (!TryReadOptionalResourceReadInteger(readParams, "maxBytes", out var requestedMaxBytes)) - return CreateResourceReadArgumentError(id, "maxBytes", - "resources/read params.maxBytes must be an integer.", - $"Pass a UTF-8 text budget between {MinResourceReadMaxBytes} and {MaxResourceReadMaxBytes} bytes."); - if (!TryReadOptionalResourceReadString(readParams, "cursor", out var cursorText)) - return CreateResourceReadArgumentError(id, "cursor", - "resources/read params.cursor must be a non-empty string.", - "Use the nextCursor returned in result._meta, or omit cursor to start a new range."); - - if (requestedStartLine is <= 0) - return CreateResourceReadIntegerRangeError(id, "startLine", 1, int.MaxValue, requestedStartLine.Value); - if (requestedEndLine is <= 0) - return CreateResourceReadIntegerRangeError(id, "endLine", 1, int.MaxValue, requestedEndLine.Value); - if (requestedStartLine.HasValue && requestedEndLine.HasValue && requestedEndLine.Value < requestedStartLine.Value) - return CreateResourceReadArgumentError(id, "endLine", - "resources/read params.endLine must be greater than or equal to params.startLine.", - "Increase endLine or start a new range with matching 1-based boundaries."); - - var maxBytes = requestedMaxBytes ?? DefaultResourceReadMaxBytes; - if (maxBytes < MinResourceReadMaxBytes || maxBytes > MaxResourceReadMaxBytes) - return CreateResourceReadIntegerRangeError(id, "maxBytes", MinResourceReadMaxBytes, MaxResourceReadMaxBytes, maxBytes); - - ResourceReadCursor? cursor = null; - if (cursorText is not null) - { - if (requestedStartLine.HasValue || requestedEndLine.HasValue) - return CreateResourceReadArgumentError(id, "cursor", - "resources/read params.cursor cannot be combined with startLine or endLine.", - "Continue with cursor and an optional maxBytes value, or omit cursor to start a new line range."); - if (cursorText.Length > MaxResourceReadCursorCharacters || !TryParseResourceReadCursor(cursorText, out var parsedCursor)) - return CreateResourceReadArgumentError(id, "cursor", - "resources/read params.cursor is invalid or expired.", - "Use the exact nextCursor returned by the previous resources/read response, or omit cursor to restart the range.", - new JsonObject - { - ["maxCursorCharacters"] = MaxResourceReadCursorCharacters, - }); - cursor = parsedCursor; - } - - return WithDbReader(id, args: readParams, reader => reader.RunInReadSnapshot(() => - { - var file = reader.GetResourceFileMetadata(path); - if (file == null) - return CreateResourceUriError(id, uri, messagePrefix: "Resource not found", - suggestion: "Verify the exact indexed path through resources/templates/list or call resources/list again, then retry with a matching resource URI.", - retrySafe: true); - - var fingerprint = BuildResourceReadFingerprint(file.Path, file.Checksum, file.Size, file.Lines, file.Modified); - if (cursor is { } suppliedCursor && !string.Equals(suppliedCursor.Fingerprint, fingerprint, StringComparison.Ordinal)) - return CreateResourceReadArgumentError(id, "cursor", - "resources/read params.cursor no longer matches the indexed resource.", - "The resource changed after the previous page. Omit cursor and restart the range to avoid skipped or duplicated text.", - new JsonObject - { - ["cursorStale"] = true, - }); - - ResourceReadMetadataLoadedForTests?.Invoke(); - - var isEmpty = file.Size >= 0 - && DbReader.IsAffirmativelyEmptyIndexedFile(file.Lines, file.Checksum); - var totalLines = Math.Max(0, file.Lines); - var hasReadableLines = !isEmpty && file.Lines > 0; - if (isEmpty && cursor.HasValue) - return CreateResourceReadArgumentError(id, "cursor", - "resources/read params.cursor does not identify a readable position in this empty resource.", - "Omit cursor and restart the resource read without line boundaries."); - - var startLine = isEmpty ? 0 : hasReadableLines ? cursor?.Line ?? requestedStartLine ?? 1 : 1; - var endLine = isEmpty ? 0 : hasReadableLines ? cursor?.EndLine ?? requestedEndLine ?? totalLines : 1; - if (hasReadableLines && startLine > totalLines) - return CreateResourceReadArgumentError(id, "startLine", - $"resources/read params.startLine exceeds the resource line count ({file.Lines}).", - "Use a startLine from resources/read result._meta or restart at line 1.", - new JsonObject - { - ["totalLines"] = file.Lines, - }); - if (hasReadableLines) - endLine = Math.Min(endLine, totalLines); - if (hasReadableLines && endLine < startLine) - return CreateResourceReadArgumentError(id, "endLine", - "resources/read effective endLine is before startLine.", - "Restart the range with an endLine greater than or equal to startLine."); - - var resourceUri = BuildResourceUri(file.Path); - var mimeType = GetResourceMimeType(file.Lang); - var effectiveMaxBytes = GetEffectiveResourceReadMaxBytes( - id, - resourceUri, - mimeType, - maxBytes); - if (effectiveMaxBytes < MinResourceReadMaxBytes) - return CreateErrorResponse(hasId: true, id: id, code: -32603, - message: "The configured MCP response limit is too small for a resources/read page.", - category: McpErrorEnvelope.CategoryInternalError, - suggestion: "Use a smaller JSON-RPC batch, or increase CDIDX_MCP_RESPONSE_MAX_BYTES or CDIDX_MCP_HTTP_MAX_RESPONSE_BYTES, then retry.", - retrySafe: false, - extraData: new JsonObject - { - ["reason"] = "resource_response_budget_too_small", - ["minimumContentBytes"] = MinResourceReadMaxBytes, - ["responseLimitBytes"] = GetEffectiveResourceReadResponseLimit(), - }); - - var page = reader.GetBoundedFileContent( - file, - isEmpty ? 1 : startLine, - isEmpty ? 1 : endLine, - effectiveMaxBytes, - MaxResourceReadLinesPerPage, - hasReadableLines ? cursor?.Line : null, - hasReadableLines ? cursor?.ByteOffset ?? 0 : 0); - switch (page.Status) - { - case BoundedFileReadStatus.FileNotFound: - return CreateResourceUriError(id, uri, messagePrefix: "Resource not found", - suggestion: "Verify the exact indexed path through resources/templates/list or call resources/list again, then retry with a matching resource URI.", - retrySafe: true); - case BoundedFileReadStatus.InvalidContinuation: - return CreateResourceReadArgumentError(id, "cursor", - "resources/read params.cursor does not identify a readable UTF-8 position in this resource.", - "Omit cursor and restart the range to obtain a fresh continuation token."); - case BoundedFileReadStatus.IncompleteCoverage: - case BoundedFileReadStatus.ContentUnavailable: - case BoundedFileReadStatus.InvalidTopology: - return CreateResourceReadStorageError(id, page.Status, page.FailureReason); - } - - var text = page.Content; - var returnedBytes = page.Utf8Bytes; - var truncated = page.Truncated && page.NextLine.HasValue; - var metadata = new JsonObject - { - ["startLine"] = startLine, - ["startLineByteOffset"] = cursor?.ByteOffset ?? 0, - ["endLine"] = endLine, - ["totalLines"] = totalLines, - ["maxBytes"] = maxBytes, - ["maxLines"] = MaxResourceReadLinesPerPage, - ["returnedStartLine"] = isEmpty ? 0 : page.StartLine, - ["returnedEndLine"] = isEmpty ? 0 : page.EndLine, - ["returnedBytes"] = returnedBytes, - ["truncated"] = truncated, - }; - if (effectiveMaxBytes != maxBytes) - metadata["effectiveMaxBytes"] = effectiveMaxBytes; - if (truncated) - { - metadata["truncationReason"] = page.TruncationReason switch - { - "max_lines" => "maxLines", - "max_bytes" when effectiveMaxBytes < maxBytes => "maxResponseBytes", - _ => "maxBytes", - }; - metadata["nextLine"] = page.NextLine!.Value; - metadata["nextLineByteOffset"] = page.NextByteOffset ?? 0; - metadata["nextCursor"] = BuildResourceReadCursor( - page.NextLine.Value, - page.NextByteOffset ?? 0, - endLine, - fingerprint); - } - - var contents = new JsonArray - { - new JsonObject - { - ["uri"] = resourceUri, - ["mimeType"] = mimeType, - ["text"] = text, - } - }; - return CreateSuccessResponse(true, id, new JsonObject - { - ["contents"] = contents, - ["_meta"] = metadata, - }); - })); - } - - private JsonObject CreateResourceReadStorageError( - JsonNode? id, - BoundedFileReadStatus status, - string? reason) - { - var normalizedReason = reason ?? status switch - { - BoundedFileReadStatus.IncompleteCoverage => "resource_chunk_coverage_incomplete", - BoundedFileReadStatus.ContentUnavailable => "resource_content_unavailable", - _ => "resource_chunk_topology_invalid", - }; - var extraData = new JsonObject - { - ["reason"] = normalizedReason, - }; - if (status == BoundedFileReadStatus.InvalidTopology) - { - extraData["maxChunks"] = DbReader.MaxBoundedFileReadChunks; - extraData["maxScannedBytes"] = DbReader.MaxBoundedFileReadScannedUtf8Bytes; - } - - return status switch - { - BoundedFileReadStatus.IncompleteCoverage => CreateErrorResponse(hasId: true, id: id, - code: McpErrorEnvelope.CodeIndexStale, - message: "Indexed resource chunks do not cover the requested range.", - category: McpErrorEnvelope.CategoryIndexStale, - suggestion: "Refresh or rebuild the index, then call resources/list and retry the read.", - retrySafe: true, - extraData: extraData), - BoundedFileReadStatus.ContentUnavailable => CreateErrorResponse(hasId: true, id: id, - code: McpErrorEnvelope.CodeIndexMissing, - message: "Indexed content is unavailable for this non-empty resource.", - category: McpErrorEnvelope.CategoryIndexMissing, - suggestion: "Inspect file issues, resolve skipped-content diagnostics, and rebuild the index before retrying.", - retrySafe: true, - extraData: extraData), - _ => CreateErrorResponse(hasId: true, id: id, - code: McpErrorEnvelope.CodeIndexCorrupted, - message: "Indexed resource storage metadata is inconsistent or exceeds safe read limits.", - category: McpErrorEnvelope.CategoryIndexCorrupted, - suggestion: "Delete the index database, rebuild it, and retry with a resource URI from resources/list.", - retrySafe: false, - extraData: extraData), - }; - } - - private int GetEffectiveResourceReadMaxBytes( - JsonNode? id, - string resourceUri, - string mimeType, - int requestedMaxBytes) - { - var worstCaseMetadata = new JsonObject - { - ["startLine"] = int.MaxValue, - ["startLineByteOffset"] = int.MaxValue, - ["endLine"] = int.MaxValue, - ["totalLines"] = int.MaxValue, - ["maxBytes"] = requestedMaxBytes, - ["effectiveMaxBytes"] = int.MaxValue, - ["maxLines"] = MaxResourceReadLinesPerPage, - ["returnedStartLine"] = int.MaxValue, - ["returnedEndLine"] = int.MaxValue, - ["returnedBytes"] = int.MaxValue, - ["truncated"] = true, - ["truncationReason"] = "maxResponseBytes", - ["nextLine"] = int.MaxValue, - ["nextLineByteOffset"] = int.MaxValue, - ["nextCursor"] = new string('x', MaxResourceReadCursorCharacters), - }; - var worstCaseResponse = CreateSuccessResponse(true, id, new JsonObject - { - ["contents"] = new JsonArray - { - new JsonObject - { - ["uri"] = resourceUri, - ["mimeType"] = mimeType, - ["text"] = string.Empty, - }, - }, - ["_meta"] = worstCaseMetadata, - }); - var envelopeBytes = Encoding.UTF8.GetByteCount(worstCaseResponse.ToJsonString(_jsonOptions)); - var availableEncodedTextBytes = GetEffectiveResourceReadResponseLimit() - envelopeBytes; - if (availableEncodedTextBytes <= 0) - return 0; - - // System.Text.Json's default encoder expands any valid source UTF-8 byte by at most - // six bytes (`\uXXXX` for an ASCII control or HTML-sensitive character). - // System.Text.Json既定encoderで有効なsource UTF-8 1 byteが展開される最大は6 byte - // (ASCII control/HTML-sensitive文字の`\uXXXX`)。 - const int worstCaseJsonExpansion = 6; - return Math.Min(requestedMaxBytes, availableEncodedTextBytes / worstCaseJsonExpansion); - } - - private int GetEffectiveResourceReadResponseLimit() - { - var responseLimit = GetMaxResponseBytes(); - var transportLimit = Volatile.Read(ref _activeTransportMaxResponseBytes); - if (transportLimit > 0) - responseLimit = Math.Min(responseLimit, transportLimit); - if (_currentBatchResponseItemMaxBytes.Value is { } batchLimit) - responseLimit = Math.Min(responseLimit, Math.Max(0, batchLimit)); - return responseLimit; - } - - private readonly record struct ResourceReadCursor(int Line, int ByteOffset, int EndLine, string Fingerprint); - - private static bool TryReadOptionalResourceReadInteger(JsonNode? readParams, string name, out int? result) - { - result = null; - if (readParams is not JsonObject obj || !obj.TryGetPropertyValue(name, out var node) || node is null) - return true; - if (node is not JsonValue value || !value.TryGetValue(out var parsed)) - return false; - result = parsed; - return true; - } - - private static bool TryReadOptionalResourceReadString(JsonNode? readParams, string name, out string? result) - { - result = null; - if (readParams is not JsonObject obj || !obj.TryGetPropertyValue(name, out var node) || node is null) - return true; - if (node is not JsonValue value || !value.TryGetValue(out var parsed) || string.IsNullOrWhiteSpace(parsed)) - return false; - result = parsed; - return true; - } - - private static JsonObject CreateResourceReadIntegerRangeError(JsonNode? id, string argument, int minimum, int maximum, int actual) - => CreateResourceReadArgumentError(id, argument, - $"resources/read params.{argument} must be between {minimum} and {maximum}.", - $"Choose a {argument} value inside the documented resources/read range.", - new JsonObject - { - ["minimum"] = minimum, - ["maximum"] = maximum, - ["actual"] = actual, - }); - - private static JsonObject CreateResourceReadArgumentError( - JsonNode? id, - string argument, - string message, - string suggestion, - JsonObject? extraData = null) - { - var data = extraData ?? new JsonObject(); - data["argument"] = argument; - return CreateErrorResponse(hasId: true, id: id, code: -32602, message: message, - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: suggestion, - retrySafe: false, - extraData: data); - } - - private static bool TryParseResourceReadCursor(string value, out ResourceReadCursor cursor) - { - cursor = default; - var parts = value.Split(':'); - if (parts.Length != 5 - || !string.Equals(parts[0], "v1", StringComparison.Ordinal) - || !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var line) - || !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var byteOffset) - || !int.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out var endLine) - || line <= 0 - || byteOffset < 0 - || byteOffset > DbReader.MaxBoundedFileReadScannedUtf8Bytes - || endLine < line - || parts[4].Length != 16) - { - return false; - } - - cursor = new ResourceReadCursor(line, byteOffset, endLine, parts[4]); - return true; - } - - private static string BuildResourceReadCursor(int line, int byteOffset, int endLine, string fingerprint) - => string.Create(CultureInfo.InvariantCulture, $"v1:{line}:{byteOffset}:{endLine}:{fingerprint}"); - - private static string BuildResourceReadFingerprint(string path, string? checksum, long size, int lines, DateTime? modified) - { - var descriptor = string.Create( - CultureInfo.InvariantCulture, - $"{path}\n{checksum ?? string.Empty}\n{size}\n{lines}\n{modified?.ToUniversalTime().Ticks ?? 0}"); - Span digest = stackalloc byte[32]; - SHA256.HashData(Encoding.UTF8.GetBytes(descriptor), digest); - return Convert.ToHexString(digest[..8]); - } - - private static JsonNode CreateResourceUriError(JsonNode? id, string uri, string messagePrefix, string suggestion, bool retrySafe, bool includeLengthLimit = false) - { - var display = McpBoundedText.ForDisplay(uri, McpBoundedText.MaxResourceUriChars); - var data = new JsonObject - { - ["uri"] = display.Text, - }; - display.AddMetadata(data, "uri"); - if (includeLengthLimit) - { - data["max_length"] = McpBoundedText.MaxResourceUriChars; - data["actual_length"] = uri.Length; - } - return CreateErrorResponse(hasId: true, id: id, code: -32602, message: $"{messagePrefix}: {display.Text}", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: suggestion, - retrySafe: retrySafe, - extraData: data); - } - private JsonNode HandlePromptsList(JsonNode? id) { var prompts = new JsonArray From 48cbfd4af08326830d38a5577c2fd81cc7051566 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:24:32 +0900 Subject: [PATCH 005/101] Separate MCP prompts and resource URI helpers --- src/CodeIndex/Mcp/McpServer.Prompts.cs | 293 +++++++++++++++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 270 ----------------------- 2 files changed, 293 insertions(+), 270 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.Prompts.cs diff --git a/src/CodeIndex/Mcp/McpServer.Prompts.cs b/src/CodeIndex/Mcp/McpServer.Prompts.cs new file mode 100644 index 000000000..f98d19e4d --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.Prompts.cs @@ -0,0 +1,293 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Mcp; + +public partial class McpServer : IDisposable +{ + + + private JsonNode HandlePromptsList(JsonNode? id) + { + var prompts = new JsonArray + { + CreatePromptDefinition("summarize_file", "Summarize the API surface and responsibilities of an indexed file.", "path", "Indexed file path to summarize."), + CreatePromptDefinition("find_unused", "Find likely unused symbols in an optional language or path scope.", "scope", "Optional language, module, or path scope."), + CreatePromptDefinition("impact_of_changing", "Plan impact analysis for changing a symbol.", "symbol", "Symbol name to analyze."), + CreatePromptDefinition("investigate_before_edit", "Investigate relevant code before making edits.", "topic", "Optional feature, symbol, file, or behavior to investigate."), + CreatePromptDefinition("find_existing_pattern", "Find existing implementation and test patterns before adding code.", "topic", "Optional API, behavior, module, or feature pattern to search for."), + CreatePromptDefinition("safe_symbol_change", "Plan a safe symbol rename or behavior change using graph-aware tools.", "symbol", "Symbol or behavior being changed."), + CreatePromptDefinition("debug_failure", "Debug a failing build, test, or runtime error using indexed evidence.", "failure", "Optional error text, test name, or failing behavior."), + }; + return CreateSuccessResponse(true, id, new JsonObject { ["prompts"] = prompts }); + } + + private JsonNode HandlePromptsGet(JsonNode? id, JsonNode? getParams) + { + var name = TryReadStringValue(getParams?["name"]); + if (string.IsNullOrWhiteSpace(name)) + return CreateErrorResponse(hasId: true, id: id, code: -32602, message: "Missing prompt name", + category: McpErrorEnvelope.CategoryMissingParameter, + suggestion: "prompts/get requires `params.name`; call prompts/list to enumerate available names.", + retrySafe: false); + name = name.Trim(); + if (name.Length > McpBoundedText.MaxPromptNameChars) + return CreatePromptStringTooLongError(id, parameterName: "name", value: name, maxChars: McpBoundedText.MaxPromptNameChars, + messagePrefix: "Prompt name is too long", + suggestion: "Use one of the short prompt names returned by prompts/list."); + + var args = getParams?["arguments"] as JsonObject; + string? ReadArg(string key, out JsonNode? error) + { + error = null; + if (args == null + || !args.TryGetPropertyValue(key, out var node) + || node is not JsonValue value + || !value.TryGetValue(out var s)) + { + return null; + } + if (s.Length > McpBoundedText.MaxPromptArgumentChars) + { + error = CreatePromptStringTooLongError(id, parameterName: key, value: s, maxChars: McpBoundedText.MaxPromptArgumentChars, + messagePrefix: $"Prompt argument '{key}' is too long", + suggestion: "Shorten prompt arguments before calling prompts/get; long source or path context should be fetched with tools instead."); + return null; + } + return McpBoundedText.ForDisplay(s, McpBoundedText.MaxPromptArgumentChars).Text; + } + + string text; + switch (name) + { + case "summarize_file": + { + var path = ReadArg("path", out var argumentError); + if (argumentError is not null) + return argumentError; + text = $"Use the `outline` tool for `{path ?? ""}`, then use `excerpt` only for the ranges needed to summarize public API, key symbols, and responsibilities."; + break; + } + case "find_unused": + { + var scope = ReadArg("scope", out var argumentError); + if (argumentError is not null) + return argumentError; + text = $"Use `unused_symbols` with the requested scope `{scope ?? ""}`. Cross-check surprising results with `references` or `callers` before recommending deletions."; + break; + } + case "impact_of_changing": + { + var symbol = ReadArg("symbol", out var argumentError); + if (argumentError is not null) + return argumentError; + text = $"Use `impact_analysis` for `{symbol ?? ""}`. Summarize direct callers, transitive callers, and files that likely need tests."; + break; + } + case "investigate_before_edit": + { + var topic = ReadArg("topic", out var argumentError); + if (argumentError is not null) + return argumentError; + text = $"Before editing `{topic ?? ""}`, use `map` for orientation if needed, `search` for broad discovery, `symbols` or `definition` for declarations, `references` for usage and tests, and focused `excerpt` calls for only the relevant ranges."; + break; + } + case "find_existing_pattern": + { + var topic = ReadArg("topic", out var argumentError); + if (argumentError is not null) + return argumentError; + text = $"Find existing patterns for `{topic ?? ""}` with `search` and `symbols`, inspect representative files with `outline`, then use focused `excerpt` ranges from implementation and tests before adding new code."; + break; + } + case "safe_symbol_change": + { + var symbol = ReadArg("symbol", out var argumentError); + if (argumentError is not null) + return argumentError; + text = $"For `{symbol ?? ""}`, confirm identity with `definition` or `symbols exactName:true`, inspect `references`, `callers`, and `callees`, then read focused `excerpt` ranges for declarations, call sites, and tests before changing behavior or names."; + break; + } + case "debug_failure": + { + var failure = ReadArg("failure", out var argumentError); + if (argumentError is not null) + return argumentError; + text = $"Debug `{failure ?? ""}` by searching exact error text with `search` or `exactSubstring`, finding related symbols with `definition` and `references`, checking callers/callees for the failing path, and reading focused `excerpt` ranges before proposing a fix."; + break; + } + default: + return CreateUnknownPromptError(id, name); + } + + var messages = new JsonArray + { + new JsonObject + { + ["role"] = "user", + ["content"] = new JsonObject + { + ["type"] = "text", + ["text"] = text, + }, + }, + }; + return CreateSuccessResponse(true, id, new JsonObject + { + ["description"] = name, + ["messages"] = messages, + }); + } + + private static JsonNode CreatePromptStringTooLongError(JsonNode? id, string parameterName, string value, int maxChars, string messagePrefix, string suggestion) + { + var display = McpBoundedText.ForDisplay(value, maxChars); + var data = new JsonObject + { + ["parameter"] = parameterName, + ["max_length"] = maxChars, + ["actual_length"] = value.Length, + ["value"] = display.Text, + }; + display.AddMetadata(data, "value"); + return CreateErrorResponse(hasId: true, id: id, code: -32602, message: $"{messagePrefix}: '{display.Text}'", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: suggestion, + retrySafe: false, + extraData: data); + } + + private static JsonNode CreateUnknownPromptError(JsonNode? id, string name) + { + var display = McpBoundedText.ForDisplay(name, McpBoundedText.MaxPromptNameChars); + var data = new JsonObject + { + ["prompt"] = display.Text, + }; + display.AddMetadata(data, "prompt"); + return CreateErrorResponse(hasId: true, id: id, code: -32602, message: $"Unknown prompt: {display.Text}", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Call prompts/list and request one of the advertised prompt names.", + retrySafe: false, + extraData: data); + } + + private async Task HandleLoggingSetLevelAsync(JsonNode? id, JsonNode? setLevelParams) + { + var level = TryReadStringValue(setLevelParams?["level"]); + if (!IsSupportedMcpLogLevel(level)) + return CreateErrorResponse(hasId: true, id: id, code: -32602, message: "Invalid logging level", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "logging/setLevel requires params.level to be one of: debug, info, notice, warning, error, critical, alert, emergency.", + retrySafe: false); + + var previous = Interlocked.Exchange(ref _mcpLogLevel, level!); + await EmitLogNotificationAsync("info", $"MCP logging level changed from {previous} to {level}.").ConfigureAwait(false); + return CreateSuccessResponse(true, id, new JsonObject()); + } + + private static JsonObject CreatePromptDefinition(string name, string description, string argumentName, string argumentDescription) + => new() + { + ["name"] = name, + ["description"] = description, + ["arguments"] = new JsonArray + { + new JsonObject + { + ["name"] = argumentName, + ["description"] = argumentDescription, + ["required"] = false, + }, + }, + }; + + private static string BuildResourceUri(string path) + => "cdidx://file/" + string.Join('/', path.Split('/').Select(Uri.EscapeDataString)); + + private static bool TryParseResourceUri(string uri, out string path) + { + path = string.Empty; + if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed) + || !string.Equals(parsed.Scheme, "cdidx", StringComparison.OrdinalIgnoreCase) + || !TryExtractRawResourcePath(uri, out var rawPath)) + { + return false; + } + + var isCanonicalFile = string.Equals(parsed.Host, "file", StringComparison.OrdinalIgnoreCase); + var isTemplateFilePath = string.Equals(parsed.Host, "file-path", StringComparison.OrdinalIgnoreCase); + if (!isCanonicalFile && !isTemplateFilePath) + return false; + if (isTemplateFilePath + && (!string.IsNullOrEmpty(parsed.Query) || !string.IsNullOrEmpty(parsed.Fragment))) + { + return false; + } + + var decodedSuccessfully = isTemplateFilePath + ? PathUriNormalizer.TryDecodeTemplateRelativeUriPath(rawPath, out var decoded) + : PathUriNormalizer.TryDecodeRelativeUriPath(rawPath, allowBackslash: false, out decoded); + if (!decodedSuccessfully) + return false; + + path = decoded; + return true; + } + + private static bool TryExtractRawResourcePath(string uri, out string rawPath) + { + rawPath = string.Empty; + var schemeSeparator = uri.IndexOf("://", StringComparison.Ordinal); + if (schemeSeparator < 0) + return false; + + var hostStart = schemeSeparator + 3; + var pathStart = uri.IndexOf('/', hostStart); + if (pathStart < 0 || pathStart == uri.Length - 1) + return false; + + rawPath = uri[(pathStart + 1)..]; + var terminator = rawPath.IndexOfAny(['?', '#']); + if (terminator >= 0) + rawPath = rawPath[..terminator]; + + return !string.IsNullOrWhiteSpace(rawPath); + } + + private static string? TryReadStringValue(JsonNode? node) + => node is JsonValue value && value.TryGetValue(out var text) ? text : null; + + private static string GetResourceMimeType(string? lang) + => lang?.ToLowerInvariant() switch + { + "csharp" => "text/x-csharp", + "fsharp" => "text/x-fsharp", + "vb" => "text/x-vb", + "javascript" => "text/javascript", + "typescript" => "text/typescript", + "json" => "application/json", + "markdown" => "text/markdown", + "python" => "text/x-python", + "rust" => "text/x-rust", + "shell" => "text/x-shellscript", + "sql" => "application/sql", + "yaml" => "application/yaml", + "xml" => "application/xml", + _ => "text/plain", + }; + +} diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 504f647c9..fcf69552d 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -470,276 +470,6 @@ internal TimeSpan InFlightPostCancelGracePeriod } - private JsonNode HandlePromptsList(JsonNode? id) - { - var prompts = new JsonArray - { - CreatePromptDefinition("summarize_file", "Summarize the API surface and responsibilities of an indexed file.", "path", "Indexed file path to summarize."), - CreatePromptDefinition("find_unused", "Find likely unused symbols in an optional language or path scope.", "scope", "Optional language, module, or path scope."), - CreatePromptDefinition("impact_of_changing", "Plan impact analysis for changing a symbol.", "symbol", "Symbol name to analyze."), - CreatePromptDefinition("investigate_before_edit", "Investigate relevant code before making edits.", "topic", "Optional feature, symbol, file, or behavior to investigate."), - CreatePromptDefinition("find_existing_pattern", "Find existing implementation and test patterns before adding code.", "topic", "Optional API, behavior, module, or feature pattern to search for."), - CreatePromptDefinition("safe_symbol_change", "Plan a safe symbol rename or behavior change using graph-aware tools.", "symbol", "Symbol or behavior being changed."), - CreatePromptDefinition("debug_failure", "Debug a failing build, test, or runtime error using indexed evidence.", "failure", "Optional error text, test name, or failing behavior."), - }; - return CreateSuccessResponse(true, id, new JsonObject { ["prompts"] = prompts }); - } - - private JsonNode HandlePromptsGet(JsonNode? id, JsonNode? getParams) - { - var name = TryReadStringValue(getParams?["name"]); - if (string.IsNullOrWhiteSpace(name)) - return CreateErrorResponse(hasId: true, id: id, code: -32602, message: "Missing prompt name", - category: McpErrorEnvelope.CategoryMissingParameter, - suggestion: "prompts/get requires `params.name`; call prompts/list to enumerate available names.", - retrySafe: false); - name = name.Trim(); - if (name.Length > McpBoundedText.MaxPromptNameChars) - return CreatePromptStringTooLongError(id, parameterName: "name", value: name, maxChars: McpBoundedText.MaxPromptNameChars, - messagePrefix: "Prompt name is too long", - suggestion: "Use one of the short prompt names returned by prompts/list."); - - var args = getParams?["arguments"] as JsonObject; - string? ReadArg(string key, out JsonNode? error) - { - error = null; - if (args == null - || !args.TryGetPropertyValue(key, out var node) - || node is not JsonValue value - || !value.TryGetValue(out var s)) - { - return null; - } - if (s.Length > McpBoundedText.MaxPromptArgumentChars) - { - error = CreatePromptStringTooLongError(id, parameterName: key, value: s, maxChars: McpBoundedText.MaxPromptArgumentChars, - messagePrefix: $"Prompt argument '{key}' is too long", - suggestion: "Shorten prompt arguments before calling prompts/get; long source or path context should be fetched with tools instead."); - return null; - } - return McpBoundedText.ForDisplay(s, McpBoundedText.MaxPromptArgumentChars).Text; - } - - string text; - switch (name) - { - case "summarize_file": - { - var path = ReadArg("path", out var argumentError); - if (argumentError is not null) - return argumentError; - text = $"Use the `outline` tool for `{path ?? ""}`, then use `excerpt` only for the ranges needed to summarize public API, key symbols, and responsibilities."; - break; - } - case "find_unused": - { - var scope = ReadArg("scope", out var argumentError); - if (argumentError is not null) - return argumentError; - text = $"Use `unused_symbols` with the requested scope `{scope ?? ""}`. Cross-check surprising results with `references` or `callers` before recommending deletions."; - break; - } - case "impact_of_changing": - { - var symbol = ReadArg("symbol", out var argumentError); - if (argumentError is not null) - return argumentError; - text = $"Use `impact_analysis` for `{symbol ?? ""}`. Summarize direct callers, transitive callers, and files that likely need tests."; - break; - } - case "investigate_before_edit": - { - var topic = ReadArg("topic", out var argumentError); - if (argumentError is not null) - return argumentError; - text = $"Before editing `{topic ?? ""}`, use `map` for orientation if needed, `search` for broad discovery, `symbols` or `definition` for declarations, `references` for usage and tests, and focused `excerpt` calls for only the relevant ranges."; - break; - } - case "find_existing_pattern": - { - var topic = ReadArg("topic", out var argumentError); - if (argumentError is not null) - return argumentError; - text = $"Find existing patterns for `{topic ?? ""}` with `search` and `symbols`, inspect representative files with `outline`, then use focused `excerpt` ranges from implementation and tests before adding new code."; - break; - } - case "safe_symbol_change": - { - var symbol = ReadArg("symbol", out var argumentError); - if (argumentError is not null) - return argumentError; - text = $"For `{symbol ?? ""}`, confirm identity with `definition` or `symbols exactName:true`, inspect `references`, `callers`, and `callees`, then read focused `excerpt` ranges for declarations, call sites, and tests before changing behavior or names."; - break; - } - case "debug_failure": - { - var failure = ReadArg("failure", out var argumentError); - if (argumentError is not null) - return argumentError; - text = $"Debug `{failure ?? ""}` by searching exact error text with `search` or `exactSubstring`, finding related symbols with `definition` and `references`, checking callers/callees for the failing path, and reading focused `excerpt` ranges before proposing a fix."; - break; - } - default: - return CreateUnknownPromptError(id, name); - } - - var messages = new JsonArray - { - new JsonObject - { - ["role"] = "user", - ["content"] = new JsonObject - { - ["type"] = "text", - ["text"] = text, - }, - }, - }; - return CreateSuccessResponse(true, id, new JsonObject - { - ["description"] = name, - ["messages"] = messages, - }); - } - - private static JsonNode CreatePromptStringTooLongError(JsonNode? id, string parameterName, string value, int maxChars, string messagePrefix, string suggestion) - { - var display = McpBoundedText.ForDisplay(value, maxChars); - var data = new JsonObject - { - ["parameter"] = parameterName, - ["max_length"] = maxChars, - ["actual_length"] = value.Length, - ["value"] = display.Text, - }; - display.AddMetadata(data, "value"); - return CreateErrorResponse(hasId: true, id: id, code: -32602, message: $"{messagePrefix}: '{display.Text}'", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: suggestion, - retrySafe: false, - extraData: data); - } - - private static JsonNode CreateUnknownPromptError(JsonNode? id, string name) - { - var display = McpBoundedText.ForDisplay(name, McpBoundedText.MaxPromptNameChars); - var data = new JsonObject - { - ["prompt"] = display.Text, - }; - display.AddMetadata(data, "prompt"); - return CreateErrorResponse(hasId: true, id: id, code: -32602, message: $"Unknown prompt: {display.Text}", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Call prompts/list and request one of the advertised prompt names.", - retrySafe: false, - extraData: data); - } - - private async Task HandleLoggingSetLevelAsync(JsonNode? id, JsonNode? setLevelParams) - { - var level = TryReadStringValue(setLevelParams?["level"]); - if (!IsSupportedMcpLogLevel(level)) - return CreateErrorResponse(hasId: true, id: id, code: -32602, message: "Invalid logging level", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "logging/setLevel requires params.level to be one of: debug, info, notice, warning, error, critical, alert, emergency.", - retrySafe: false); - - var previous = Interlocked.Exchange(ref _mcpLogLevel, level!); - await EmitLogNotificationAsync("info", $"MCP logging level changed from {previous} to {level}.").ConfigureAwait(false); - return CreateSuccessResponse(true, id, new JsonObject()); - } - - private static JsonObject CreatePromptDefinition(string name, string description, string argumentName, string argumentDescription) - => new() - { - ["name"] = name, - ["description"] = description, - ["arguments"] = new JsonArray - { - new JsonObject - { - ["name"] = argumentName, - ["description"] = argumentDescription, - ["required"] = false, - }, - }, - }; - - private static string BuildResourceUri(string path) - => "cdidx://file/" + string.Join('/', path.Split('/').Select(Uri.EscapeDataString)); - - private static bool TryParseResourceUri(string uri, out string path) - { - path = string.Empty; - if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed) - || !string.Equals(parsed.Scheme, "cdidx", StringComparison.OrdinalIgnoreCase) - || !TryExtractRawResourcePath(uri, out var rawPath)) - { - return false; - } - - var isCanonicalFile = string.Equals(parsed.Host, "file", StringComparison.OrdinalIgnoreCase); - var isTemplateFilePath = string.Equals(parsed.Host, "file-path", StringComparison.OrdinalIgnoreCase); - if (!isCanonicalFile && !isTemplateFilePath) - return false; - if (isTemplateFilePath - && (!string.IsNullOrEmpty(parsed.Query) || !string.IsNullOrEmpty(parsed.Fragment))) - { - return false; - } - - var decodedSuccessfully = isTemplateFilePath - ? PathUriNormalizer.TryDecodeTemplateRelativeUriPath(rawPath, out var decoded) - : PathUriNormalizer.TryDecodeRelativeUriPath(rawPath, allowBackslash: false, out decoded); - if (!decodedSuccessfully) - return false; - - path = decoded; - return true; - } - - private static bool TryExtractRawResourcePath(string uri, out string rawPath) - { - rawPath = string.Empty; - var schemeSeparator = uri.IndexOf("://", StringComparison.Ordinal); - if (schemeSeparator < 0) - return false; - - var hostStart = schemeSeparator + 3; - var pathStart = uri.IndexOf('/', hostStart); - if (pathStart < 0 || pathStart == uri.Length - 1) - return false; - - rawPath = uri[(pathStart + 1)..]; - var terminator = rawPath.IndexOfAny(['?', '#']); - if (terminator >= 0) - rawPath = rawPath[..terminator]; - - return !string.IsNullOrWhiteSpace(rawPath); - } - - private static string? TryReadStringValue(JsonNode? node) - => node is JsonValue value && value.TryGetValue(out var text) ? text : null; - - private static string GetResourceMimeType(string? lang) - => lang?.ToLowerInvariant() switch - { - "csharp" => "text/x-csharp", - "fsharp" => "text/x-fsharp", - "vb" => "text/x-vb", - "javascript" => "text/javascript", - "typescript" => "text/typescript", - "json" => "application/json", - "markdown" => "text/markdown", - "python" => "text/x-python", - "rust" => "text/x-rust", - "shell" => "text/x-shellscript", - "sql" => "application/sql", - "yaml" => "application/yaml", - "xml" => "application/xml", - _ => "text/plain", - }; - /// /// Resolve the caller identity used by the per-(tool, caller) rate limiter from an /// `initialize` request's `clientInfo`. Falls back to `"unknown"` when the client did From f93665217824be10fbc376165d3624ed0324342b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:26:12 +0900 Subject: [PATCH 006/101] Extract MCP protocol and rate limit policies --- src/CodeIndex/Mcp/McpServer.Protocol.cs | 217 ++++++++++++++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 194 --------------------- 2 files changed, 217 insertions(+), 194 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.Protocol.cs diff --git a/src/CodeIndex/Mcp/McpServer.Protocol.cs b/src/CodeIndex/Mcp/McpServer.Protocol.cs new file mode 100644 index 000000000..8c0d0caf3 --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.Protocol.cs @@ -0,0 +1,217 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Mcp; + +public partial class McpServer : IDisposable +{ + + + /// + /// Resolve the caller identity used by the per-(tool, caller) rate limiter from an + /// `initialize` request's `clientInfo`. Falls back to `"unknown"` when the client did + /// not supply a name so anonymous callers still get a coherent bucket of their own + /// (instead of accidentally sharing one with named clients) (#1560). + /// (tool, caller) ごとのレート制限で使う呼び出し元 ID を `initialize` の `clientInfo` から + /// 解決する。`name` が無い場合は `"unknown"` を返し、匿名クライアントが他の名前付きクライアントと + /// バケットを共有しないようにする(#1560)。 + /// + internal static string ResolveCallerIdentity(JsonNode? initializeParams) + { + if (initializeParams is not JsonObject obj) + return "unknown"; + if (obj["clientInfo"] is not JsonObject clientInfo) + return "unknown"; + + var name = TryReadBoundedClientInfoMember(clientInfo, "name")?.Text; + if (name == null) + return "unknown"; + var version = TryReadBoundedClientInfoMember(clientInfo, "version")?.Text; + return version == null ? name : $"{name}/{version}"; + } + + /// + /// Return the requested protocol version when supported, the preferred version when the + /// field is absent or malformed, and when there is no overlap. + /// 対応する要求バージョン、未指定・不正型なら既定バージョン、対応外なら + /// を返す。 + /// + internal static string? NegotiateProtocolVersion(JsonNode? initializeParams, out BoundedMcpText? requestedVersion) + { + requestedVersion = null; + if (initializeParams is JsonObject obj + && obj.TryGetPropertyValue("protocolVersion", out var node) + && node is JsonValue value + && value.TryGetValue(out var versionString) + && !string.IsNullOrWhiteSpace(versionString)) + { + requestedVersion = BoundProtocolVersionForDisplay(versionString); + foreach (var supported in SupportedProtocolVersions) + { + if (string.Equals(supported, versionString, StringComparison.Ordinal)) + return supported; + } + return null; + } + + // Field absent / null / malformed: fall back to the preferred version so clients + // that omit the field (or send a non-string sentinel) keep working as before. + // 未指定 / null / 不正型: 既定バージョンに fallback して既存クライアントの互換を保つ。 + return ProtocolVersion; + } + + private static JsonObject CreateUnsupportedProtocolError(JsonNode? id, BoundedMcpText? requestedVersion) + { + var supportedArray = new JsonArray(); + foreach (var supported in SupportedProtocolVersions) + supportedArray.Add(JsonValue.Create(supported)); + + // Keep the #1554 version-negotiation fields, then layer the #1581 canonical envelope + // on top via BuildData so this path also carries `category` / `suggestion` / + // `retry_safe` like every other JSON-RPC error. + // #1554 のバージョン交渉用フィールドを保ちつつ、#1581 の canonical envelope を + // BuildData で重ねて、他の JSON-RPC エラーと同様に category / suggestion / retry_safe + // を含めるようにする。 + var extra = new JsonObject + { + ["supportedVersions"] = supportedArray + }; + if (requestedVersion != null) + { + extra["requestedVersion"] = requestedVersion.Value.Text; + requestedVersion.Value.AddMetadata(extra, "requestedVersion"); + } + + var data = McpErrorEnvelope.BuildData( + McpErrorEnvelope.CategoryInvalidArgument, + "Reissue `initialize` with one of `data.supportedVersions` in `params.protocolVersion`, or omit the field to fall back to the server's newest supported version.", + retrySafe: false, + AddCorrelationData(extra)); + + var error = new JsonObject + { + ["code"] = -32602, + ["message"] = BuildUnsupportedProtocolMessage(requestedVersion), + ["data"] = data + }; + var response = new JsonObject + { + ["jsonrpc"] = "2.0", + ["error"] = error, + ["id"] = McpJsonNode.Clone(id) + }; + return response; + } + + internal static string BuildUnsupportedProtocolMessage(string? requestedVersion) + => BuildUnsupportedProtocolMessage(BoundProtocolVersionForDisplay(requestedVersion)); + + private static string BuildUnsupportedProtocolMessage(BoundedMcpText? requestedVersion) + { + var supported = string.Join(", ", SupportedProtocolVersions); + var requested = requestedVersion?.Text ?? "(unspecified)"; + return $"Unsupported MCP protocolVersion '{requested}'. Server supports: {supported}."; + } + + internal static string BuildUnsupportedProtocolLog(string? requestedVersion) + => BuildUnsupportedProtocolLog(BoundProtocolVersionForDisplay(requestedVersion)); + + private static string BuildUnsupportedProtocolLog(BoundedMcpText? requestedVersion) + { + var supported = string.Join(", ", SupportedProtocolVersions); + var requested = requestedVersion?.Text ?? "(unspecified)"; + return $"[cdidx-mcp] Rejecting initialize: client requested protocolVersion '{requested}', server supports {supported}. Upgrade the server or pin a supported version on the client."; + } + + private static BoundedMcpText? BoundProtocolVersionForDisplay(string? requestedVersion) + => string.IsNullOrEmpty(requestedVersion) + ? null + : McpBoundedText.ForDisplay(requestedVersion, McpBoundedText.MaxProtocolVersionChars); + + private static BoundedMcpText BoundClientInfoForDisplay(string value) + => McpBoundedText.ForDisplay(value, McpBoundedText.MaxClientInfoChars); + + private static BoundedMcpText BoundClientIdentityForDisplay(string value) + => McpBoundedText.ForDisplay(value, McpBoundedText.MaxClientIdentityChars); + + private static string? ResolveKnownRateLimitBucketName(string? toolName) + { + // Only canonical known-tool names receive a secondary per-tool bucket. Missing, + // malformed, oversized, case-variant, and unknown names are covered solely by the + // fixed caller-wide pre-validation bucket, so they cannot create name-derived keys + // (#4547). + // canonical な既知ツール名だけに secondary per-tool bucket を割り当てる。missing / + // malformed / oversized / 大文字小文字 variant / unknown は caller-wide の固定 + // pre-validation bucket だけで扱い、名前由来キーを作成させない(#4547)。 + if (toolName is not null) + { + foreach (var knownToolName in McpToolFilter.KnownToolNames) + { + if (string.Equals(knownToolName, toolName, StringComparison.Ordinal)) + return knownToolName; + } + } + + return null; + } + + /// + /// Build a structured `-32000` JSON-RPC error for a rate-limited tool call. Surfacing + /// the limit category in `error.data.error_category` (alongside `tool`, `caller`, and + /// `retry_after_ms`) lets MCP clients branch on the failure type without parsing the + /// human-readable `message` (#1560). + /// レート制限で拒否されたツール呼び出し用の構造化 `-32000` JSON-RPC エラーを構築する。 + /// `error.data.error_category` を併記することでクライアントが `message` 文字列を解析せず + /// 失敗カテゴリで分岐できるようにする(#1560)。 + /// + internal static JsonObject CreateRateLimitedErrorResponse(JsonNode? id, string tool, string caller, long retryAfterMs) + { + var toolDisplay = BoundToolNameForDisplay(tool); + var callerDisplay = BoundClientIdentityForDisplay(caller); + // #1560 contract preserved: `error_category`, `tool`, `caller`, `retry_after_ms`. + // #1581 adds the canonical envelope (`category`, `suggestion`, `retry_safe`) alongside. + // #1560 の契約(`error_category`, `tool`, `caller`, `retry_after_ms`)を維持しつつ、 + // #1581 で導入した canonical envelope(`category`, `suggestion`, `retry_safe`)を併記する。 + var extraData = new JsonObject + { + ["error_category"] = "rate_limited", + ["tool"] = toolDisplay.Text, + ["caller"] = callerDisplay.Text, + ["retry_after_ms"] = retryAfterMs, + }; + toolDisplay.AddMetadata(extraData, "tool"); + callerDisplay.AddMetadata(extraData, "caller"); + var data = McpErrorEnvelope.BuildData( + category: McpErrorEnvelope.CategoryRateLimited, + suggestion: $"Back off for at least {retryAfterMs} ms before retrying this tool, or raise {RateLimiterOptions.RpsEnvVar} / {RateLimiterOptions.BurstEnvVar} on the server.", + retrySafe: true, + extraData: AddCorrelationData(extraData)); + var error = new JsonObject + { + ["code"] = -32000, + ["message"] = $"Rate limit exceeded for tool '{toolDisplay.Text}' (retry after {retryAfterMs} ms).", + ["data"] = data, + }; + var response = new JsonObject + { + ["jsonrpc"] = "2.0", + ["error"] = error, + ["id"] = McpJsonNode.Clone(id) + }; + return response; + } + +} diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index fcf69552d..e069b2b3a 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -470,200 +470,6 @@ internal TimeSpan InFlightPostCancelGracePeriod } - /// - /// Resolve the caller identity used by the per-(tool, caller) rate limiter from an - /// `initialize` request's `clientInfo`. Falls back to `"unknown"` when the client did - /// not supply a name so anonymous callers still get a coherent bucket of their own - /// (instead of accidentally sharing one with named clients) (#1560). - /// (tool, caller) ごとのレート制限で使う呼び出し元 ID を `initialize` の `clientInfo` から - /// 解決する。`name` が無い場合は `"unknown"` を返し、匿名クライアントが他の名前付きクライアントと - /// バケットを共有しないようにする(#1560)。 - /// - internal static string ResolveCallerIdentity(JsonNode? initializeParams) - { - if (initializeParams is not JsonObject obj) - return "unknown"; - if (obj["clientInfo"] is not JsonObject clientInfo) - return "unknown"; - - var name = TryReadBoundedClientInfoMember(clientInfo, "name")?.Text; - if (name == null) - return "unknown"; - var version = TryReadBoundedClientInfoMember(clientInfo, "version")?.Text; - return version == null ? name : $"{name}/{version}"; - } - - /// - /// Return the requested protocol version when supported, the preferred version when the - /// field is absent or malformed, and when there is no overlap. - /// 対応する要求バージョン、未指定・不正型なら既定バージョン、対応外なら - /// を返す。 - /// - internal static string? NegotiateProtocolVersion(JsonNode? initializeParams, out BoundedMcpText? requestedVersion) - { - requestedVersion = null; - if (initializeParams is JsonObject obj - && obj.TryGetPropertyValue("protocolVersion", out var node) - && node is JsonValue value - && value.TryGetValue(out var versionString) - && !string.IsNullOrWhiteSpace(versionString)) - { - requestedVersion = BoundProtocolVersionForDisplay(versionString); - foreach (var supported in SupportedProtocolVersions) - { - if (string.Equals(supported, versionString, StringComparison.Ordinal)) - return supported; - } - return null; - } - - // Field absent / null / malformed: fall back to the preferred version so clients - // that omit the field (or send a non-string sentinel) keep working as before. - // 未指定 / null / 不正型: 既定バージョンに fallback して既存クライアントの互換を保つ。 - return ProtocolVersion; - } - - private static JsonObject CreateUnsupportedProtocolError(JsonNode? id, BoundedMcpText? requestedVersion) - { - var supportedArray = new JsonArray(); - foreach (var supported in SupportedProtocolVersions) - supportedArray.Add(JsonValue.Create(supported)); - - // Keep the #1554 version-negotiation fields, then layer the #1581 canonical envelope - // on top via BuildData so this path also carries `category` / `suggestion` / - // `retry_safe` like every other JSON-RPC error. - // #1554 のバージョン交渉用フィールドを保ちつつ、#1581 の canonical envelope を - // BuildData で重ねて、他の JSON-RPC エラーと同様に category / suggestion / retry_safe - // を含めるようにする。 - var extra = new JsonObject - { - ["supportedVersions"] = supportedArray - }; - if (requestedVersion != null) - { - extra["requestedVersion"] = requestedVersion.Value.Text; - requestedVersion.Value.AddMetadata(extra, "requestedVersion"); - } - - var data = McpErrorEnvelope.BuildData( - McpErrorEnvelope.CategoryInvalidArgument, - "Reissue `initialize` with one of `data.supportedVersions` in `params.protocolVersion`, or omit the field to fall back to the server's newest supported version.", - retrySafe: false, - AddCorrelationData(extra)); - - var error = new JsonObject - { - ["code"] = -32602, - ["message"] = BuildUnsupportedProtocolMessage(requestedVersion), - ["data"] = data - }; - var response = new JsonObject - { - ["jsonrpc"] = "2.0", - ["error"] = error, - ["id"] = McpJsonNode.Clone(id) - }; - return response; - } - - internal static string BuildUnsupportedProtocolMessage(string? requestedVersion) - => BuildUnsupportedProtocolMessage(BoundProtocolVersionForDisplay(requestedVersion)); - - private static string BuildUnsupportedProtocolMessage(BoundedMcpText? requestedVersion) - { - var supported = string.Join(", ", SupportedProtocolVersions); - var requested = requestedVersion?.Text ?? "(unspecified)"; - return $"Unsupported MCP protocolVersion '{requested}'. Server supports: {supported}."; - } - - internal static string BuildUnsupportedProtocolLog(string? requestedVersion) - => BuildUnsupportedProtocolLog(BoundProtocolVersionForDisplay(requestedVersion)); - - private static string BuildUnsupportedProtocolLog(BoundedMcpText? requestedVersion) - { - var supported = string.Join(", ", SupportedProtocolVersions); - var requested = requestedVersion?.Text ?? "(unspecified)"; - return $"[cdidx-mcp] Rejecting initialize: client requested protocolVersion '{requested}', server supports {supported}. Upgrade the server or pin a supported version on the client."; - } - - private static BoundedMcpText? BoundProtocolVersionForDisplay(string? requestedVersion) - => string.IsNullOrEmpty(requestedVersion) - ? null - : McpBoundedText.ForDisplay(requestedVersion, McpBoundedText.MaxProtocolVersionChars); - - private static BoundedMcpText BoundClientInfoForDisplay(string value) - => McpBoundedText.ForDisplay(value, McpBoundedText.MaxClientInfoChars); - - private static BoundedMcpText BoundClientIdentityForDisplay(string value) - => McpBoundedText.ForDisplay(value, McpBoundedText.MaxClientIdentityChars); - - private static string? ResolveKnownRateLimitBucketName(string? toolName) - { - // Only canonical known-tool names receive a secondary per-tool bucket. Missing, - // malformed, oversized, case-variant, and unknown names are covered solely by the - // fixed caller-wide pre-validation bucket, so they cannot create name-derived keys - // (#4547). - // canonical な既知ツール名だけに secondary per-tool bucket を割り当てる。missing / - // malformed / oversized / 大文字小文字 variant / unknown は caller-wide の固定 - // pre-validation bucket だけで扱い、名前由来キーを作成させない(#4547)。 - if (toolName is not null) - { - foreach (var knownToolName in McpToolFilter.KnownToolNames) - { - if (string.Equals(knownToolName, toolName, StringComparison.Ordinal)) - return knownToolName; - } - } - - return null; - } - - /// - /// Build a structured `-32000` JSON-RPC error for a rate-limited tool call. Surfacing - /// the limit category in `error.data.error_category` (alongside `tool`, `caller`, and - /// `retry_after_ms`) lets MCP clients branch on the failure type without parsing the - /// human-readable `message` (#1560). - /// レート制限で拒否されたツール呼び出し用の構造化 `-32000` JSON-RPC エラーを構築する。 - /// `error.data.error_category` を併記することでクライアントが `message` 文字列を解析せず - /// 失敗カテゴリで分岐できるようにする(#1560)。 - /// - internal static JsonObject CreateRateLimitedErrorResponse(JsonNode? id, string tool, string caller, long retryAfterMs) - { - var toolDisplay = BoundToolNameForDisplay(tool); - var callerDisplay = BoundClientIdentityForDisplay(caller); - // #1560 contract preserved: `error_category`, `tool`, `caller`, `retry_after_ms`. - // #1581 adds the canonical envelope (`category`, `suggestion`, `retry_safe`) alongside. - // #1560 の契約(`error_category`, `tool`, `caller`, `retry_after_ms`)を維持しつつ、 - // #1581 で導入した canonical envelope(`category`, `suggestion`, `retry_safe`)を併記する。 - var extraData = new JsonObject - { - ["error_category"] = "rate_limited", - ["tool"] = toolDisplay.Text, - ["caller"] = callerDisplay.Text, - ["retry_after_ms"] = retryAfterMs, - }; - toolDisplay.AddMetadata(extraData, "tool"); - callerDisplay.AddMetadata(extraData, "caller"); - var data = McpErrorEnvelope.BuildData( - category: McpErrorEnvelope.CategoryRateLimited, - suggestion: $"Back off for at least {retryAfterMs} ms before retrying this tool, or raise {RateLimiterOptions.RpsEnvVar} / {RateLimiterOptions.BurstEnvVar} on the server.", - retrySafe: true, - extraData: AddCorrelationData(extraData)); - var error = new JsonObject - { - ["code"] = -32000, - ["message"] = $"Rate limit exceeded for tool '{toolDisplay.Text}' (retry after {retryAfterMs} ms).", - ["data"] = data, - }; - var response = new JsonObject - { - ["jsonrpc"] = "2.0", - ["error"] = error, - ["id"] = McpJsonNode.Clone(id) - }; - return response; - } - // Tool definitions are in McpToolDefinitions.cs / ツール定義は McpToolDefinitions.cs に分離 From 513e202cb54c62aaba2af8aa45a59a6e72aab6e6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:27:53 +0900 Subject: [PATCH 007/101] Separate MCP tool dispatch and telemetry --- src/CodeIndex/Mcp/McpServer.ToolDispatch.cs | 880 ++++++++++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 857 ------------------- 2 files changed, 880 insertions(+), 857 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.ToolDispatch.cs diff --git a/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs b/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs new file mode 100644 index 000000000..c6024d3fc --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.ToolDispatch.cs @@ -0,0 +1,880 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Mcp; + +public partial class McpServer : IDisposable +{ + + + // Tool definitions are in McpToolDefinitions.cs / ツール定義は McpToolDefinitions.cs に分離 + + + /// + /// Execute a tool call. + /// ツール呼び出しを実行。 + /// + private async Task HandleToolsCallAsync(bool hasId, JsonNode? id, JsonNode? callParams) + { + _currentIndexAuditContext.Value = new IndexAuditContext(); + var callParamsObject = callParams as JsonObject; + var args = callParamsObject?["arguments"]; + var toolName = callParamsObject?["name"] is JsonValue toolNameValue + && toolNameValue.TryGetValue(out var parsedToolName) + ? parsedToolName + : null; + var observedToolName = toolName ?? "(missing)"; + + Database.DbDebug.ResetContext(); + var metricsStartedAt = _timeProvider.GetUtcNow(); + var metricsStopwatch = System.Diagnostics.Stopwatch.StartNew(); + string? metricsError = null; + JsonNode response; + JsonObject CreateUnknownToolResponseForMetrics() + { + metricsError = "unknown_tool"; + return CreateUnknownToolErrorResponse(hasId: true, id: id, observedToolName); + } + + try + { + var caller = CurrentInitializeState.Caller; + // Charge every direct tools/call to one caller-wide bucket before detailed + // name, enablement, or argument validation. Canonical known tools then retain + // their existing secondary per-tool limit. This prevents a caller from rotating + // malformed requests across known names to multiply its effective burst (#4547). + // direct tools/call はすべて、名前・enablement・argument の詳細検証前に caller-wide + // bucket へ課金する。canonical な既知 tool は既存の secondary per-tool 制限も維持し、 + // malformed request の既知名ローテーションによる burst 増幅を防ぐ(#4547)。 + var decision = RateLimiter.TryAcquireHierarchy( + RateLimiter.ToolsCallPreValidationBucketName, + ResolveKnownRateLimitBucketName(toolName), + caller); + if (!decision.Allowed) + { + metricsError = "rate_limited"; + DeferFrameLog(BuildRateLimitedLog(observedToolName, caller, decision.RetryAfterMs)); + response = CreateRateLimitedErrorResponse(id, observedToolName, caller, decision.RetryAfterMs); + } + else if (toolName is null) + { + metricsError = "missing_tool_name"; + response = CreateErrorResponse(hasId: true, id: id, code: -32602, message: "Missing tool name", + category: McpErrorEnvelope.CategoryMissingParameter, + suggestion: "tools/call requires `params.name`. Send the tool identifier (e.g. \"search\", \"definition\") as a string.", + retrySafe: false); + } + // Per-deployment enablement gate (#1561). The rate-limit check deliberately runs + // first so disabled-tool retries cannot bypass request-cost protection (#4547). + // デプロイ単位の有効化ゲート (#1561)。disabled tool の再試行で request-cost + // protection を回避できないよう、rate-limit check を先に実行する(#4547)。 + else if (McpToolFilter.IsKnownTool(toolName) && !_toolFilter.IsEnabled(toolName)) + { + metricsError = "tool_disabled"; + response = CreateErrorResponse(hasId: true, id: id, code: -32601, message: $"Tool not enabled: {toolName}", + category: McpErrorEnvelope.CategoryToolDisabled, + suggestion: "This tool is disabled on the server (CDIDX_MCP_TOOLS_ALLOW / CDIDX_MCP_TOOLS_DENY). Ask the operator to enable it or use a different tool.", + retrySafe: false, + extraData: new JsonObject { ["tool"] = toolName }); + } + else + { + var progressToken = TryReadProgressToken(callParamsObject); + var toolNameTooLong = toolName.Length > McpBoundedText.MaxToolNameChars; + if (toolNameTooLong) + { + response = CreateUnknownToolResponseForMetrics(); + } + else if (ValidateToolArguments(toolName, args) is JsonObject argumentError) + { + metricsError = "invalid_argument"; + if (argumentError["jsonrpc_invalid_params"] is JsonValue invalidParamsMarker + && invalidParamsMarker.TryGetValue(out var invalidParams) + && invalidParams) + { + argumentError.Remove("jsonrpc_invalid_params"); + response = CreateErrorResponse(hasId: true, id: id, code: -32602, message: argumentError["message"]!.GetValue(), + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use the JSON types advertised by tools/list for this tool.", + retrySafe: false, + extraData: argumentError); + } + else + { + response = CreateToolErrorResponse(id, argumentError["message"]!.GetValue(), + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use exactly the argument names advertised by tools/list for this tool.", + retrySafe: false, + extraData: argumentError); + } + } + else if (ValidateCommonListArguments(args) is JsonObject listArgumentError) + { + metricsError = "invalid_list_argument"; + response = CreateToolErrorResponse(id, listArgumentError["message"]!.GetValue(), + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Send only non-empty string entries within the documented MCP array bounds.", + retrySafe: false, + extraData: listArgumentError); + } + else if (ValidateProjectFilterArguments(args) is JsonObject projectFilterError) + { + metricsError = "invalid_project_filter"; + response = CreateToolErrorResponse(id, projectFilterError["message"]!.GetValue(), + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use a project name or project path from the current workspace, or correct the solution filter.", + retrySafe: false, + extraData: projectFilterError); + } + else + { + response = await DispatchToolCallAsync( + toolName, + id, + args, + progressToken, + CreateUnknownToolResponseForMetrics).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) when (_currentRequestToken.Value.IsCancellationRequested) + { + metricsError = nameof(OperationCanceledException); + throw; + } + catch (Exception ex) + { + // Stderr keeps a sanitized local diagnostic, while the JSON-RPC tool + // result is reduced to the tool name + exception type. Raw exception + // messages can echo bound parameter values (e.g. SQLite errors quote + // the offending literal), paths, or content fragments, which would + // otherwise leak through the MCP transcript (#1530 / #4124). + // stderr には sanitize 済みのローカル診断だけを残し、JSON-RPC のツール結果は + // tool 名 + 例外型に絞る。SQLite 例外などの生メッセージはバインド値、 + // 該当リテラル、パス、索引内容を含み得るため、MCP transcript へ流さない + // (#1530 / #4124)。 + var dbDebugDump = Database.DbDebug.CaptureDump(ex); + DeferFrameLog(() => + { + WriteMcpLogLine(BuildToolErrorLog(observedToolName, ex)); + Database.DbDebug.WriteCapturedDumpToStderr(dbDebugDump); + }); + metricsError = ex.GetType().Name; + var classification = McpErrorEnvelope.ClassifyException(ex); + response = CreateToolErrorResponse(true, id, BuildSanitizedToolErrorMessage(observedToolName, ex), + category: classification.Category, + suggestion: classification.Suggestion, + retrySafe: classification.RetrySafe, + extraData: BuildToolExceptionData(observedToolName, ex.GetType().Name)); + } + finally + { + Database.DbDebug.ResetContext(); + if (MetricsSink.IsActive) + { + metricsStopwatch.Stop(); + var metricsTool = BoundToolNameForDisplay(observedToolName).Text; + var requestId = CurrentCorrelationContext.Value?.TelemetryRequestId; + MetricsSink.Record(new MetricsEvent( + Timestamp: metricsStartedAt, + Tool: metricsTool, + Source: "mcp", + ElapsedMs: metricsStopwatch.Elapsed.TotalMilliseconds, + ExitCode: metricsError == null ? 0 : 1, + Language: TryReadMetricStringArg(args, "language") ?? TryReadMetricStringArg(args, "lang"), + Error: metricsError, + RequestId: requestId?.Token, + RequestIdType: requestId?.Type, + RequestIdLength: requestId?.Length)); + } + } + + // Audit observes the wire response (for result_count / error_code / isError), + // invocation-scoped authorization identity, and any sanitized exception type, so + // emission happens after the metrics finally block. Stop the stopwatch idempotently + // — the metrics path may have already stopped it. TryEmitAudit is best-effort internally (#1562). + // audit は wire response、invocation-scoped authorization identity、例外型を参照するため + // metrics finally の後で出力する。Stopwatch.Stop は冪等。 + // TryEmitAudit 内部でベストエフォート化済み (#1562)。 + metricsStopwatch.Stop(); + var auditErrorType = metricsError == "unknown_tool" ? null : metricsError; + TryEmitAudit(hasId, observedToolName, id, args, response, metricsStartedAt, metricsStopwatch.Elapsed.TotalMilliseconds, errorType: auditErrorType); + _currentIndexAuditContext.Value = null; + EmitToolInvocationTelemetry(observedToolName, args, response, metricsStartedAt, metricsStopwatch.Elapsed.TotalMilliseconds, metricsError); + return response; + } + + private async Task DispatchToolCallAsync( + string toolName, + JsonNode? id, + JsonNode? args, + JsonNode? progressToken, + Func createUnknownToolResponse) + { + if (toolName is "index" or "backfill_fold") + { + await _sharedDbWriteGate.WaitAsync(_currentRequestToken.Value).ConfigureAwait(false); + try + { + return toolName == "index" + ? await ExecuteIndexAsync(id, args, progressToken).ConfigureAwait(false) + : await ExecuteBackfillFoldAsync(id, args, progressToken).ConfigureAwait(false); + } + finally + { + _sharedDbWriteGate.Release(); + } + } + + return toolName switch + { + "search" => ExecuteSearch(id, args), + "definition" => ExecuteDefinition(id, args), + "references" => ExecuteReferences(id, args), + "callers" => ExecuteCallers(id, args), + "callees" => ExecuteCallees(id, args), + "symbols" => ExecuteSymbols(id, args), + "files" => ExecuteFiles(id, args), + "find_in_file" => ExecuteFindInFile(id, args), + "excerpt" => ExecuteExcerpt(id, args), + "map" => ExecuteMap(id, args), + "analyze_symbol" => ExecuteAnalyzeSymbol(id, args), + "status" => ExecuteStatus(id, args), + "outline" => ExecuteOutline(id, args), + "batch_query" => ExecuteBatchQuery(id, args), + "deps" => ExecuteDeps(id, args), + "impact_analysis" => ExecuteImpactAnalysis(id, args), + "languages" => ExecuteLanguages(id, args), + "validate" => ExecuteValidate(id, args), + "unused_symbols" => ExecuteUnusedSymbols(id, args), + "symbol_hotspots" => ExecuteSymbolHotspots(id, args), + "ping" => ExecutePing(id), + "suggest_improvement" => await ExecuteSuggestImprovementAsync(id, args).ConfigureAwait(false), + _ => createUnknownToolResponse(), + }; + } + + private void EmitToolInvocationTelemetry(string toolName, JsonNode? args, JsonNode response, DateTimeOffset startedAt, double elapsedMs, string? errorType) + { + var context = CurrentCorrelationContext.Value; + var (errorCode, observedErrorType) = ExtractErrorCode(response); + var resultCount = ExtractResultCount(response); + var (argKeys, argLengths, argKeyLengths, _) = SanitizeArgs( + args, + includeValues: false, + out _, + out _, + out _, + out _, + out var argKeysTruncated, + out var argKeyTruncationReasons, + out var argKeysOmittedCount, + out var argKeyNamesTruncatedCount); + var toolDisplay = BoundToolNameForDisplay(toolName); + var argsObject = new JsonObject(); + foreach (var pair in argLengths) + argsObject[pair.Key] = pair.Value; + + var evt = new JsonObject + { + ["event"] = "mcp.tool.invocation", + ["timestamp"] = startedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + ["tool"] = toolDisplay.Text, + ["request_id"] = context?.TelemetryRequestId?.Token, + ["request_id_type"] = context?.TelemetryRequestId?.Type, + ["request_id_length"] = context?.TelemetryRequestId?.Length, + ["correlation_id"] = context?.CorrelationId, + ["elapsed_ms"] = Math.Round(elapsedMs, 3), + ["status"] = errorCode == 0 ? "success" : "error", + ["error_code"] = errorCode == 0 ? null : errorCode, + ["error_type"] = errorType ?? observedErrorType, + ["result_count"] = resultCount, + ["arg_keys"] = JsonSerializer.SerializeToNode(argKeys, _jsonOptions), + ["arg_lengths"] = argsObject, + }; + toolDisplay.AddMetadata(evt, "tool"); + AddArgKeyMetadata(evt, argKeyLengths, argKeysOmittedCount, argKeyNamesTruncatedCount); + if (argKeysTruncated) + evt["arg_keys_truncated"] = true; + if (argKeyTruncationReasons.Count > 0) + evt["arg_key_truncation_reasons"] = JsonSerializer.SerializeToNode(argKeyTruncationReasons, _jsonOptions); + DeferFrameLog(() => WriteMcpLogLine(evt.ToJsonString(_jsonOptions))); + } + + private JsonNode? TryReadProgressToken(JsonNode? callParams) + { + var token = callParams?["_meta"]?["progressToken"]; + if (token is null) + return null; + + if (!IsSupportedProgressToken(token)) + return null; + + return TryMeasureJsonUtf8BytesWithinLimit(token, _jsonOptions, McpBoundedText.MaxProgressTokenJsonBytes, out _) + ? McpJsonNode.Clone(token) + : null; + } + + private static bool IsSupportedProgressToken(JsonNode token) + { + var nodeCount = 0; + return IsSupportedProgressToken(token, depth: 0, ref nodeCount); + } + + private static bool IsSupportedProgressToken(JsonNode token, int depth, ref int nodeCount) + { + if (depth > McpBoundedText.MaxProgressTokenDepth) + return false; + + nodeCount++; + if (nodeCount > McpBoundedText.MaxProgressTokenNodeCount) + return false; + + return token switch + { + JsonValue value => IsSupportedProgressTokenScalar(value), + JsonObject obj => IsSupportedProgressTokenObject(obj, depth, ref nodeCount), + _ => false, + }; + } + + private static bool IsSupportedProgressTokenScalar(JsonValue value) + => value.GetValueKind() switch + { + JsonValueKind.String => value.TryGetValue(out var text) + && text.Length <= McpBoundedText.MaxProgressTokenStringChars, + JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => true, + _ => false, + }; + + private static bool IsSupportedProgressTokenObject(JsonObject obj, int depth, ref int nodeCount) + { + foreach (var pair in obj) + { + if (pair.Key.Length > McpBoundedText.MaxProgressTokenPropertyNameChars) + return false; + if (pair.Value is null) + { + nodeCount++; + if (nodeCount > McpBoundedText.MaxProgressTokenNodeCount) + return false; + continue; + } + + if (!IsSupportedProgressToken(pair.Value, depth + 1, ref nodeCount)) + return false; + } + + return true; + } + + private async Task EmitProgressNotificationAsync(JsonNode? progressToken, long progress, long? total, string? message = null) + { + if (progressToken is null || _currentOutOfBandFrameWriter.Value is not { } writer) + return; + + var parameters = new JsonObject + { + ["progressToken"] = McpJsonNode.Clone(progressToken), + ["progress"] = progress, + }; + if (total.HasValue) + parameters["total"] = total.Value; + if (!string.IsNullOrWhiteSpace(message)) + parameters["message"] = message; + + var notification = new JsonObject + { + ["jsonrpc"] = "2.0", + ["method"] = "notifications/progress", + ["params"] = parameters, + }; + await writer(notification.ToJsonString(_jsonOptions), _currentRequestToken.Value).ConfigureAwait(false); + } + + private async Task EmitLogNotificationAsync(string level, string message) + { + if (_currentOutOfBandFrameWriter.Value is not { } writer) + return; + + var notification = new JsonObject + { + ["jsonrpc"] = "2.0", + ["method"] = "notifications/message", + ["params"] = new JsonObject + { + ["level"] = level, + ["logger"] = "cdidx", + ["data"] = message, + }, + }; + await writer(notification.ToJsonString(_jsonOptions), _currentRequestToken.Value).ConfigureAwait(false); + } + + /// + /// Emit a single audit record for the just-executed tool call. Inspects the wire + /// response to derive the result count and error code, and uses invocation-scoped + /// authorization state when available, so the audit trail preserves checks performed + /// before later error paths build a response (#1562, #4606). Failures are swallowed + /// because audit emission must never break the underlying tool call. + /// 直前に実行したツール呼び出しを 1 レコード分監査出力する。クライアントが実際に観測する + /// 値と一致させるため wire response から result count / error code を抽出し、後続の error + /// path が response を生成する前の検証も残すため invocation-scoped authorization state を使う + /// (#1562, #4606)。 + /// audit 失敗で本体ツール呼び出しを壊さないようベストエフォート化する。 + /// + private void TryEmitAudit(bool hasId, string toolName, JsonNode? id, JsonNode? args, JsonNode response, DateTimeOffset startedAt, double elapsedMs, string? errorType) + { + if (_auditLog is null) + return; + + try + { + var initializeState = CurrentInitializeState; + var (errorCode, observedErrorType) = ExtractErrorCode(response); + var resultCount = ExtractResultCount(response); + var (argKeys, argLengths, argKeyLengths, argValuesEcho) = + SanitizeArgs(args, _auditLog.IncludeValues, + out var argValuesRedacted, + out var argValuesTruncated, + out var argValueTruncationReasons, + out var argValuesSerializedBytes, + out var argKeysTruncated, + out var argKeyTruncationReasons, + out var argKeysOmittedCount, + out var argKeyNamesTruncatedCount); + var toolDisplay = BoundToolNameForDisplay(toolName); + McpRequestIdTelemetryData? requestId = hasId + ? McpRequestIdTelemetry.Create(id) + : null; + var evt = new AuditLogSink.AuditEvent( + Timestamp: startedAt, + Tool: toolDisplay.Text, + CallerName: initializeState.ClientName, + CallerVersion: initializeState.ClientVersion, + RequestId: requestId?.Token, + ArgKeys: argKeys, + ArgLengths: argLengths, + ArgValues: argValuesEcho, + ResultCount: resultCount, + ElapsedMs: elapsedMs, + ErrorCode: errorCode, + ErrorType: errorType ?? observedErrorType, + CheckedRootIdentity: _currentIndexAuditContext.Value?.CheckedRootIdentity ?? ExtractCheckedRootIdentity(response), + ToolLength: toolDisplay.Truncated ? toolDisplay.OriginalLength : null, + ToolTruncated: toolDisplay.Truncated, + ArgKeyLengths: argKeyLengths, + ArgKeysTruncated: argKeysTruncated, + ArgKeyTruncationReasons: argKeyTruncationReasons, + ArgKeysOmittedCount: argKeysOmittedCount, + ArgKeyNamesTruncatedCount: argKeyNamesTruncatedCount, + ArgValuesRedacted: argValuesRedacted, + ArgValuesTruncated: argValuesTruncated, + ArgValueTruncationReasons: argValueTruncationReasons, + ArgValuesSerializedBytes: argValuesSerializedBytes, + RequestIdType: requestId?.Type, + RequestIdLength: requestId?.Length, + CallerNameLength: initializeState.ClientNameDisplay?.Truncated == true ? initializeState.ClientNameDisplay.Value.OriginalLength : null, + CallerNameTruncated: initializeState.ClientNameDisplay?.Truncated == true, + CallerVersionLength: initializeState.ClientVersionDisplay?.Truncated == true ? initializeState.ClientVersionDisplay.Value.OriginalLength : null, + CallerVersionTruncated: initializeState.ClientVersionDisplay?.Truncated == true); + _auditLog.Record(evt); + } + catch + { + // Best-effort: an audit failure must not break the tool call. + // ベストエフォート: audit 失敗で本体ツール呼び出しを壊さない。 + } + } + + private static string? ExtractCheckedRootIdentity(JsonNode response) + { + var node = response["result"]?["structuredContent"]?["checked_root_identity"] + ?? response["error"]?["data"]?["checked_root_identity"]; + return node is JsonValue value && value.TryGetValue(out var identity) + ? identity + : null; + } + + /// + /// Translate the wire response into `(error_code, error_type)` for the audit record. + /// 0 means success, positive means a tool-level error (isError=true), and negative is + /// the verbatim JSON-RPC error code (e.g. -32602 invalid params). + /// レスポンスを audit 用の `(error_code, error_type)` に変換する。0=成功、正値= + /// tool エラー (isError=true)、負値=JSON-RPC エラーコード(例: -32602)。 + /// + internal static (int Code, string? Type) ExtractErrorCode(JsonNode response) + { + if (response is not JsonObject obj) + return (0, null); + if (obj.TryGetPropertyValue("error", out var errorNode) && errorNode is JsonObject errorObj) + { + var code = -32603; + if (errorObj.TryGetPropertyValue("code", out var codeNode) && codeNode is JsonValue codeValue + && codeValue.TryGetValue(out var parsed)) + code = parsed; + return (code, "jsonrpc_error"); + } + if (obj.TryGetPropertyValue("result", out var resultNode) && resultNode is JsonObject resultObj) + { + if (resultObj.TryGetPropertyValue("isError", out var isErrorNode) + && isErrorNode is JsonValue isErrorValue + && isErrorValue.TryGetValue(out var isError) + && isError) + return (1, "tool_error"); + } + return (0, null); + } + + /// + /// Extract the result count from a successful tool response. Prefers + /// `structuredContent.count`, falls back to the length of `structuredContent.results`, + /// and returns null when neither shape is present (e.g. ping). Tool errors and JSON-RPC + /// errors return null because there is no meaningful result-set count for those cases. + /// 成功レスポンスから result count を抽出する。`structuredContent.count` を優先、 + /// `structuredContent.results` の長さに fallback。どちらも無い場合(例: ping)と + /// tool/JSON-RPC エラー時は null を返す。 + /// + internal static int? ExtractResultCount(JsonNode response) + { + if (response is not JsonObject obj) + return null; + if (obj["result"] is not JsonObject result) + return null; + if (result["isError"] is JsonValue isErrorValue + && isErrorValue.TryGetValue(out var isError) && isError) + return null; + if (result["structuredContent"] is not JsonObject structured) + return null; + if (structured["count"] is JsonValue countValue && countValue.TryGetValue(out var count)) + return count; + if (structured["results"] is JsonArray results) + return results.Count; + return null; + } + + /// + /// Build the `(arg_keys, arg_lengths, arg_key_lengths, arg_values?)` audit triple. Values are echoed + /// only when the operator has opted in via `--audit-log-include-values`; otherwise we + /// keep keys + per-key length so AI argument shapes can be reconstructed without + /// persisting query bodies that may contain sensitive substrings (#1562). + /// audit 用の `(arg_keys, arg_lengths, arg_values?)` を組み立てる。値は + /// `--audit-log-include-values` がオンの場合のみ転写し、それ以外はキーと長さだけ残す + /// (secret 風の検索クエリを取り込まないため)。 + /// + internal static (IReadOnlyList Keys, IReadOnlyList> Lengths, IReadOnlyList> KeyLengths, JsonNode? ValuesEcho) + SanitizeArgs(JsonNode? args, bool includeValues) + => SanitizeArgs(args, includeValues, out _, out _, out _, out _, out _, out _, out _, out _); + + private static (IReadOnlyList Keys, IReadOnlyList> Lengths, IReadOnlyList> KeyLengths, JsonNode? ValuesEcho) + SanitizeArgs( + JsonNode? args, + bool includeValues, + out bool argValuesRedacted, + out bool argValuesTruncated, + out IReadOnlyList argValueTruncationReasons, + out int? argValuesSerializedBytes, + out bool argKeysTruncated, + out IReadOnlyList argKeyTruncationReasons, + out int argKeysOmittedCount, + out int argKeyNamesTruncatedCount) + { + argValuesRedacted = false; + argValuesTruncated = false; + argValueTruncationReasons = Array.Empty(); + argValuesSerializedBytes = null; + argKeysTruncated = false; + argKeysOmittedCount = 0; + argKeyNamesTruncatedCount = 0; + var argKeyReasons = new List(); + argKeyTruncationReasons = argKeyReasons; + if (args is not JsonObject argsObj) + return (Array.Empty(), Array.Empty>(), Array.Empty>(), null); + + var keys = new List(argsObj.Count); + var lengths = new List>(argsObj.Count); + var keyLengths = new List>(); + var usedKeys = new HashSet(StringComparer.Ordinal); + JsonObject? echoObject = includeValues ? new JsonObject() : null; + AuditLogSink.ArgValueSanitizationState? valueState = includeValues ? new AuditLogSink.ArgValueSanitizationState() : null; + var argValueBudgetExhausted = false; + var argumentCount = 0; + foreach (var (key, value) in argsObj) + { + if (argumentCount >= AuditLogSink.MaxAuditArgumentCount) + { + argKeysTruncated = true; + argKeysOmittedCount = argsObj.Count - argumentCount; + AddUniqueReason(argKeyReasons, "arg_key_count_limit"); + break; + } + + var keyDisplay = McpBoundedText.ForDisplay(key, AuditLogSink.MaxAuditArgumentKeyChars); + var displayKey = MakeUniqueArgumentDisplayKey(key, keyDisplay, usedKeys); + keys.Add(displayKey); + lengths.Add(new KeyValuePair(displayKey, AuditLogSink.MeasureArgLength(value))); + if (keyDisplay.Truncated) + { + keyLengths.Add(new KeyValuePair(displayKey, keyDisplay.OriginalLength)); + argKeysTruncated = true; + argKeyNamesTruncatedCount++; + AddUniqueReason(argKeyReasons, "arg_key_length_limit"); + } + if (echoObject is not null && !argValueBudgetExhausted) + { + try + { + if (!valueState!.TryReservePropertyName(displayKey)) + { + argValueBudgetExhausted = true; + } + else + { + echoObject[displayKey] = AuditLogSink.SanitizeArgValue(key, value, valueState); + argValuesRedacted = valueState.Redacted; + } + } + catch + { + echoObject = null; + } + } + argumentCount++; + } + if (valueState is not null) + { + argValuesRedacted = valueState.Redacted; + argValuesTruncated = valueState.Truncated; + argValueTruncationReasons = valueState.TruncationReasons; + argValuesSerializedBytes = valueState.SerializedBytes; + } + + return (keys, lengths, keyLengths, includeValues ? echoObject : null); + } + + private static void AddUniqueReason(List reasons, string reason) + { + foreach (var existing in reasons) + { + if (StringComparer.Ordinal.Equals(existing, reason)) + return; + } + reasons.Add(reason); + } + + private static string MakeUniqueArgumentDisplayKey(string rawKey, BoundedMcpText display, ISet usedKeys) + { + if (usedKeys.Add(display.Text)) + return display.Text; + + var hashSuffix = "#" + ShortStableHash(rawKey); + var candidate = ComposeDisplayKeyWithSuffix(rawKey, hashSuffix); + var disambiguator = 2; + while (!usedKeys.Add(candidate)) + { + candidate = ComposeDisplayKeyWithSuffix( + rawKey, + $"{hashSuffix}-{disambiguator.ToString(CultureInfo.InvariantCulture)}"); + disambiguator++; + } + + return candidate; + } + + private static string ComposeDisplayKeyWithSuffix(string rawKey, string suffix) + { + const int maxDisplayTextChars = McpBoundedText.MaxDiagnosticDisplayChars + 3; + var maxPrefixChars = Math.Max(0, maxDisplayTextChars - suffix.Length - 3); + return McpBoundedText.ForDisplay(rawKey, maxPrefixChars).Text + suffix; + } + + private static string ShortStableHash(string value) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + return HexEncoding.ToLowerHexString(bytes, 0, 4); + } + + private static void AddArgKeyMetadata( + JsonObject target, + IReadOnlyList> argKeyLengths, + int argKeysOmittedCount, + int argKeyNamesTruncatedCount) + { + if (argKeyLengths.Count > 0) + { + var lengths = new JsonObject(); + foreach (var pair in argKeyLengths) + lengths[pair.Key] = pair.Value; + target["arg_key_lengths"] = lengths; + target["arg_keys_truncated"] = true; + } + if (argKeysOmittedCount > 0) + target["arg_keys_omitted_count"] = argKeysOmittedCount; + if (argKeyNamesTruncatedCount > 0) + target["arg_key_names_truncated_count"] = argKeyNamesTruncatedCount; + } + + private static string? SerializeRequestId(JsonNode? id) + { + return TrySerializeRequestId(id, out var serialized, out _) ? serialized : null; + } + + private static string? TryReadStringArg(JsonNode? args, string key) + { + if (args is null) + return null; + + try + { + var node = args[key]; + if (node is null) + return null; + if (node is JsonValue value && value.TryGetValue(out var stringValue)) + return string.IsNullOrWhiteSpace(stringValue) ? null : stringValue; + } + catch + { + // Best-effort: any oddity in argument shape just suppresses the language hint. + // ベストエフォート: 引数形状が不正でも language ヒントを抑止するだけ。 + } + return null; + } + + private static string? TryReadMetricStringArg(JsonNode? args, string key) + { + var value = TryReadStringArg(args, key); + return value is null ? null : McpBoundedText.ForDisplay(value).Text; + } + + internal static string BuildOversizedMessageLog(int characterCount, int byteCount) => + $"[cdidx-mcp] Message too large ({characterCount} chars / {byteCount} bytes), rejecting. Split the request into smaller JSON-RPC messages or shorter arguments, then retry."; + + internal static string BuildJsonParseErrorLog(string detail) => + $"[cdidx-mcp] JSON parse error: {DiagnosticRedactor.BoundDiagnosticText(detail, JsonFrameParser.MaxParseDiagnosticChars)}. MCP stdio expects one UTF-8 JSON-RPC object per LF-delimited line; do not send LSP Content-Length framing."; + + internal static string BuildUnhandledLoopErrorLog(string detail) => + $"[cdidx-mcp] Error: {detail}. This request was skipped; fix the request or inspect the server environment, then retry."; + + internal static string BuildResponseSerializationErrorLog(string detail) => + $"[cdidx-mcp] Error serializing response: {detail}. Returning a minimal JSON-RPC error response when possible."; + + internal static string BuildResponseWriteErrorLog(string detail) => + $"[cdidx-mcp] Error writing response: {detail}. The request was handled but the client connection may already be closed."; + + internal static string BuildToolErrorLog(string toolName, Exception ex) => + $"[cdidx-mcp] Tool error ({BoundToolNameForDisplay(toolName).Text}): {BuildSanitizedExceptionLogDetail(ex)}. Fix the tool arguments, refresh the index if needed, then retry."; + + internal static string BuildSanitizedExceptionLogDetail(Exception ex) + { + var exceptionType = McpBoundedText.ForDisplay(ex.GetType().Name).Text; + if (ex is CodeIndexException codeIndexEx) + { + var code = McpBoundedText.ForDisplay(codeIndexEx.Code).Text; + var category = McpBoundedText.ForDisplay(codeIndexEx.Category).Text; + return $"{exceptionType} code={code} category={category}{BuildPathFragment(codeIndexEx)}{BuildHintFragment(codeIndexEx)}"; + } + + return exceptionType; + } + + internal static string BuildClientResponseTooLargeLog(string member, int bytesWritten) => + $"[cdidx-mcp] Client response {member} exceeded the server byte limit ({bytesWritten} > {MaxClientResponseJsonBytes}); rejecting without retaining the payload."; + + private static string BuildClientResponseTooLargeMessage(int bytesWritten) => + $"MCP client response exceeded the server byte limit ({bytesWritten} > {MaxClientResponseJsonBytes})."; + + // Stderr log emitted when the rate limiter denies a tool call. Mirrors the JSON-RPC + // `-32000` payload (tool + caller + retry_after_ms) so operators tailing the MCP log + // can correlate spikes with the structured error returned on the wire (#1560). + // レート制限で拒否されたツール呼び出しを stderr に記録する。配線上の JSON-RPC `-32000` + // ペイロードと内容を揃え、運用側がログ追跡から状況把握できるようにする(#1560)。 + internal static string BuildRateLimitedLog(string toolName, string caller, long retryAfterMs) => + $"[cdidx-mcp] Rate limit exceeded: tool='{BoundToolNameForDisplay(toolName).Text}', caller='{BoundClientIdentityForDisplay(caller).Text}', retry_after_ms={retryAfterMs}. Increase {RateLimiterOptions.RpsEnvVar} / {RateLimiterOptions.BurstEnvVar} on the server, or back off and retry."; + + internal static string BuildCallerSwapRejectionLog(string current, string attempted) => + $"[cdidx-mcp] Ignoring re-initialize with new clientInfo identity '{BoundClientIdentityForDisplay(attempted).Text}': retaining original caller '{BoundClientIdentityForDisplay(current).Text}' so rate-limit buckets cannot be reset mid-session."; + + internal static string BuildUnknownNotificationLog(string method) => + $"[cdidx-mcp] Ignoring unknown notification: {method}"; + + internal static bool IsSupportedMcpLogLevel(string? level) + => level is "debug" or "info" or "notice" or "warning" or "error" or "critical" or "alert" or "emergency"; + + internal static bool IsUnsafeDebugEnabled() + => McpEnvironment.IsUnsafeDebugEnabled(DebugEnvironmentVariable); + + internal static string FormatDbPathForLog(string dbPath) + { + if (IsUnsafeDebugEnabled()) + return dbPath; + + try + { + var path = dbPath; + if (Uri.TryCreate(dbPath, UriKind.Absolute, out var uri) && uri.IsFile) + path = uri.LocalPath; + var fileName = Path.GetFileName(path); + return string.IsNullOrWhiteSpace(fileName) ? "(configured db)" : fileName; + } + catch + { + return "(configured db)"; + } + } + + // Wire-safe error body for the tool catch-all. Mentions the tool and the + // exception type so the client can branch (retry vs. surface to user) + // while keeping bound values or matched content out of the response (#1530). + // For CodeIndexException (#1580) the Code / Category / Path / Hint fields + // are author-controlled and therefore safe to echo verbatim, so the client + // gets the structured failure metadata it needs without re-introducing the + // ex.Message leak vector #1530 closed. + // ツール catch-all のワイヤー向け本文。クライアントが分岐できるよう tool 名と + // 例外型は残し、バインド値や一致内容は含めない(#1530)。CodeIndexException (#1580) + // の Code / Category / Path / Hint は実装側で固定したフィールドなのでそのまま転写し、 + // #1530 で封じた ex.Message 漏れを再現させずに失敗詳細をクライアントへ届ける。 + internal static string BuildSanitizedToolErrorMessage(string toolName, Exception ex) + { + var toolDisplay = BoundToolNameForDisplay(toolName).Text; + if (!IsUnsafeDebugEnabled()) + return $"Tool '{toolDisplay}' failed. See cdidx server stderr for details."; + if (ex is CodeIndexException codeIndexEx) + return $"Error executing {toolDisplay} ({ex.GetType().Name}) [{codeIndexEx.Code}/{codeIndexEx.Category}]{BuildPathFragment(codeIndexEx)}{BuildHintFragment(codeIndexEx)}. See cdidx server stderr for details."; + return $"Error executing {toolDisplay} ({ex.GetType().Name}). See cdidx server stderr for details."; + } + + // Wire-safe error body for the JSON-RPC loop catch-all. Same rationale as + // the tool catch-all (#1530, #1580). + // JSON-RPC ループ catch-all のワイヤー向け本文。理由はツール catch-all と同じ(#1530, #1580)。 + internal static string BuildSanitizedLoopErrorMessage(Exception ex) + { + if (!IsUnsafeDebugEnabled()) + return "Internal MCP error. See cdidx server stderr for details."; + if (ex is CodeIndexException codeIndexEx) + return $"Internal error ({ex.GetType().Name}) [{codeIndexEx.Code}/{codeIndexEx.Category}]{BuildPathFragment(codeIndexEx)}{BuildHintFragment(codeIndexEx)}. See cdidx server stderr for details."; + return $"Internal error ({ex.GetType().Name}). See cdidx server stderr for details."; + } + + // Quote so paths/hints with spaces stay one token. Single quotes are kept + // for human readability — this is a display contract, not a shell-parsing one. + // 空白を含む path / hint が 2 トークンに見えないよう単引用符でラップする。 + private static string BuildPathFragment(CodeIndexException ex) => + string.IsNullOrEmpty(ex.Path) ? string.Empty : $" path='{ex.Path}'"; + + private static string BuildHintFragment(CodeIndexException ex) => + string.IsNullOrEmpty(ex.Hint) ? string.Empty : $" hint='{ex.Hint}'"; + +} diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index e069b2b3a..8299df319 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -470,863 +470,6 @@ internal TimeSpan InFlightPostCancelGracePeriod } - // Tool definitions are in McpToolDefinitions.cs / ツール定義は McpToolDefinitions.cs に分離 - - - /// - /// Execute a tool call. - /// ツール呼び出しを実行。 - /// - private async Task HandleToolsCallAsync(bool hasId, JsonNode? id, JsonNode? callParams) - { - _currentIndexAuditContext.Value = new IndexAuditContext(); - var callParamsObject = callParams as JsonObject; - var args = callParamsObject?["arguments"]; - var toolName = callParamsObject?["name"] is JsonValue toolNameValue - && toolNameValue.TryGetValue(out var parsedToolName) - ? parsedToolName - : null; - var observedToolName = toolName ?? "(missing)"; - - Database.DbDebug.ResetContext(); - var metricsStartedAt = _timeProvider.GetUtcNow(); - var metricsStopwatch = System.Diagnostics.Stopwatch.StartNew(); - string? metricsError = null; - JsonNode response; - JsonObject CreateUnknownToolResponseForMetrics() - { - metricsError = "unknown_tool"; - return CreateUnknownToolErrorResponse(hasId: true, id: id, observedToolName); - } - - try - { - var caller = CurrentInitializeState.Caller; - // Charge every direct tools/call to one caller-wide bucket before detailed - // name, enablement, or argument validation. Canonical known tools then retain - // their existing secondary per-tool limit. This prevents a caller from rotating - // malformed requests across known names to multiply its effective burst (#4547). - // direct tools/call はすべて、名前・enablement・argument の詳細検証前に caller-wide - // bucket へ課金する。canonical な既知 tool は既存の secondary per-tool 制限も維持し、 - // malformed request の既知名ローテーションによる burst 増幅を防ぐ(#4547)。 - var decision = RateLimiter.TryAcquireHierarchy( - RateLimiter.ToolsCallPreValidationBucketName, - ResolveKnownRateLimitBucketName(toolName), - caller); - if (!decision.Allowed) - { - metricsError = "rate_limited"; - DeferFrameLog(BuildRateLimitedLog(observedToolName, caller, decision.RetryAfterMs)); - response = CreateRateLimitedErrorResponse(id, observedToolName, caller, decision.RetryAfterMs); - } - else if (toolName is null) - { - metricsError = "missing_tool_name"; - response = CreateErrorResponse(hasId: true, id: id, code: -32602, message: "Missing tool name", - category: McpErrorEnvelope.CategoryMissingParameter, - suggestion: "tools/call requires `params.name`. Send the tool identifier (e.g. \"search\", \"definition\") as a string.", - retrySafe: false); - } - // Per-deployment enablement gate (#1561). The rate-limit check deliberately runs - // first so disabled-tool retries cannot bypass request-cost protection (#4547). - // デプロイ単位の有効化ゲート (#1561)。disabled tool の再試行で request-cost - // protection を回避できないよう、rate-limit check を先に実行する(#4547)。 - else if (McpToolFilter.IsKnownTool(toolName) && !_toolFilter.IsEnabled(toolName)) - { - metricsError = "tool_disabled"; - response = CreateErrorResponse(hasId: true, id: id, code: -32601, message: $"Tool not enabled: {toolName}", - category: McpErrorEnvelope.CategoryToolDisabled, - suggestion: "This tool is disabled on the server (CDIDX_MCP_TOOLS_ALLOW / CDIDX_MCP_TOOLS_DENY). Ask the operator to enable it or use a different tool.", - retrySafe: false, - extraData: new JsonObject { ["tool"] = toolName }); - } - else - { - var progressToken = TryReadProgressToken(callParamsObject); - var toolNameTooLong = toolName.Length > McpBoundedText.MaxToolNameChars; - if (toolNameTooLong) - { - response = CreateUnknownToolResponseForMetrics(); - } - else if (ValidateToolArguments(toolName, args) is JsonObject argumentError) - { - metricsError = "invalid_argument"; - if (argumentError["jsonrpc_invalid_params"] is JsonValue invalidParamsMarker - && invalidParamsMarker.TryGetValue(out var invalidParams) - && invalidParams) - { - argumentError.Remove("jsonrpc_invalid_params"); - response = CreateErrorResponse(hasId: true, id: id, code: -32602, message: argumentError["message"]!.GetValue(), - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Use the JSON types advertised by tools/list for this tool.", - retrySafe: false, - extraData: argumentError); - } - else - { - response = CreateToolErrorResponse(id, argumentError["message"]!.GetValue(), - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Use exactly the argument names advertised by tools/list for this tool.", - retrySafe: false, - extraData: argumentError); - } - } - else if (ValidateCommonListArguments(args) is JsonObject listArgumentError) - { - metricsError = "invalid_list_argument"; - response = CreateToolErrorResponse(id, listArgumentError["message"]!.GetValue(), - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Send only non-empty string entries within the documented MCP array bounds.", - retrySafe: false, - extraData: listArgumentError); - } - else if (ValidateProjectFilterArguments(args) is JsonObject projectFilterError) - { - metricsError = "invalid_project_filter"; - response = CreateToolErrorResponse(id, projectFilterError["message"]!.GetValue(), - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Use a project name or project path from the current workspace, or correct the solution filter.", - retrySafe: false, - extraData: projectFilterError); - } - else - { - response = await DispatchToolCallAsync( - toolName, - id, - args, - progressToken, - CreateUnknownToolResponseForMetrics).ConfigureAwait(false); - } - } - } - catch (OperationCanceledException) when (_currentRequestToken.Value.IsCancellationRequested) - { - metricsError = nameof(OperationCanceledException); - throw; - } - catch (Exception ex) - { - // Stderr keeps a sanitized local diagnostic, while the JSON-RPC tool - // result is reduced to the tool name + exception type. Raw exception - // messages can echo bound parameter values (e.g. SQLite errors quote - // the offending literal), paths, or content fragments, which would - // otherwise leak through the MCP transcript (#1530 / #4124). - // stderr には sanitize 済みのローカル診断だけを残し、JSON-RPC のツール結果は - // tool 名 + 例外型に絞る。SQLite 例外などの生メッセージはバインド値、 - // 該当リテラル、パス、索引内容を含み得るため、MCP transcript へ流さない - // (#1530 / #4124)。 - var dbDebugDump = Database.DbDebug.CaptureDump(ex); - DeferFrameLog(() => - { - WriteMcpLogLine(BuildToolErrorLog(observedToolName, ex)); - Database.DbDebug.WriteCapturedDumpToStderr(dbDebugDump); - }); - metricsError = ex.GetType().Name; - var classification = McpErrorEnvelope.ClassifyException(ex); - response = CreateToolErrorResponse(true, id, BuildSanitizedToolErrorMessage(observedToolName, ex), - category: classification.Category, - suggestion: classification.Suggestion, - retrySafe: classification.RetrySafe, - extraData: BuildToolExceptionData(observedToolName, ex.GetType().Name)); - } - finally - { - Database.DbDebug.ResetContext(); - if (MetricsSink.IsActive) - { - metricsStopwatch.Stop(); - var metricsTool = BoundToolNameForDisplay(observedToolName).Text; - var requestId = CurrentCorrelationContext.Value?.TelemetryRequestId; - MetricsSink.Record(new MetricsEvent( - Timestamp: metricsStartedAt, - Tool: metricsTool, - Source: "mcp", - ElapsedMs: metricsStopwatch.Elapsed.TotalMilliseconds, - ExitCode: metricsError == null ? 0 : 1, - Language: TryReadMetricStringArg(args, "language") ?? TryReadMetricStringArg(args, "lang"), - Error: metricsError, - RequestId: requestId?.Token, - RequestIdType: requestId?.Type, - RequestIdLength: requestId?.Length)); - } - } - - // Audit observes the wire response (for result_count / error_code / isError), - // invocation-scoped authorization identity, and any sanitized exception type, so - // emission happens after the metrics finally block. Stop the stopwatch idempotently - // — the metrics path may have already stopped it. TryEmitAudit is best-effort internally (#1562). - // audit は wire response、invocation-scoped authorization identity、例外型を参照するため - // metrics finally の後で出力する。Stopwatch.Stop は冪等。 - // TryEmitAudit 内部でベストエフォート化済み (#1562)。 - metricsStopwatch.Stop(); - var auditErrorType = metricsError == "unknown_tool" ? null : metricsError; - TryEmitAudit(hasId, observedToolName, id, args, response, metricsStartedAt, metricsStopwatch.Elapsed.TotalMilliseconds, errorType: auditErrorType); - _currentIndexAuditContext.Value = null; - EmitToolInvocationTelemetry(observedToolName, args, response, metricsStartedAt, metricsStopwatch.Elapsed.TotalMilliseconds, metricsError); - return response; - } - - private async Task DispatchToolCallAsync( - string toolName, - JsonNode? id, - JsonNode? args, - JsonNode? progressToken, - Func createUnknownToolResponse) - { - if (toolName is "index" or "backfill_fold") - { - await _sharedDbWriteGate.WaitAsync(_currentRequestToken.Value).ConfigureAwait(false); - try - { - return toolName == "index" - ? await ExecuteIndexAsync(id, args, progressToken).ConfigureAwait(false) - : await ExecuteBackfillFoldAsync(id, args, progressToken).ConfigureAwait(false); - } - finally - { - _sharedDbWriteGate.Release(); - } - } - - return toolName switch - { - "search" => ExecuteSearch(id, args), - "definition" => ExecuteDefinition(id, args), - "references" => ExecuteReferences(id, args), - "callers" => ExecuteCallers(id, args), - "callees" => ExecuteCallees(id, args), - "symbols" => ExecuteSymbols(id, args), - "files" => ExecuteFiles(id, args), - "find_in_file" => ExecuteFindInFile(id, args), - "excerpt" => ExecuteExcerpt(id, args), - "map" => ExecuteMap(id, args), - "analyze_symbol" => ExecuteAnalyzeSymbol(id, args), - "status" => ExecuteStatus(id, args), - "outline" => ExecuteOutline(id, args), - "batch_query" => ExecuteBatchQuery(id, args), - "deps" => ExecuteDeps(id, args), - "impact_analysis" => ExecuteImpactAnalysis(id, args), - "languages" => ExecuteLanguages(id, args), - "validate" => ExecuteValidate(id, args), - "unused_symbols" => ExecuteUnusedSymbols(id, args), - "symbol_hotspots" => ExecuteSymbolHotspots(id, args), - "ping" => ExecutePing(id), - "suggest_improvement" => await ExecuteSuggestImprovementAsync(id, args).ConfigureAwait(false), - _ => createUnknownToolResponse(), - }; - } - - private void EmitToolInvocationTelemetry(string toolName, JsonNode? args, JsonNode response, DateTimeOffset startedAt, double elapsedMs, string? errorType) - { - var context = CurrentCorrelationContext.Value; - var (errorCode, observedErrorType) = ExtractErrorCode(response); - var resultCount = ExtractResultCount(response); - var (argKeys, argLengths, argKeyLengths, _) = SanitizeArgs( - args, - includeValues: false, - out _, - out _, - out _, - out _, - out var argKeysTruncated, - out var argKeyTruncationReasons, - out var argKeysOmittedCount, - out var argKeyNamesTruncatedCount); - var toolDisplay = BoundToolNameForDisplay(toolName); - var argsObject = new JsonObject(); - foreach (var pair in argLengths) - argsObject[pair.Key] = pair.Value; - - var evt = new JsonObject - { - ["event"] = "mcp.tool.invocation", - ["timestamp"] = startedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), - ["tool"] = toolDisplay.Text, - ["request_id"] = context?.TelemetryRequestId?.Token, - ["request_id_type"] = context?.TelemetryRequestId?.Type, - ["request_id_length"] = context?.TelemetryRequestId?.Length, - ["correlation_id"] = context?.CorrelationId, - ["elapsed_ms"] = Math.Round(elapsedMs, 3), - ["status"] = errorCode == 0 ? "success" : "error", - ["error_code"] = errorCode == 0 ? null : errorCode, - ["error_type"] = errorType ?? observedErrorType, - ["result_count"] = resultCount, - ["arg_keys"] = JsonSerializer.SerializeToNode(argKeys, _jsonOptions), - ["arg_lengths"] = argsObject, - }; - toolDisplay.AddMetadata(evt, "tool"); - AddArgKeyMetadata(evt, argKeyLengths, argKeysOmittedCount, argKeyNamesTruncatedCount); - if (argKeysTruncated) - evt["arg_keys_truncated"] = true; - if (argKeyTruncationReasons.Count > 0) - evt["arg_key_truncation_reasons"] = JsonSerializer.SerializeToNode(argKeyTruncationReasons, _jsonOptions); - DeferFrameLog(() => WriteMcpLogLine(evt.ToJsonString(_jsonOptions))); - } - - private JsonNode? TryReadProgressToken(JsonNode? callParams) - { - var token = callParams?["_meta"]?["progressToken"]; - if (token is null) - return null; - - if (!IsSupportedProgressToken(token)) - return null; - - return TryMeasureJsonUtf8BytesWithinLimit(token, _jsonOptions, McpBoundedText.MaxProgressTokenJsonBytes, out _) - ? McpJsonNode.Clone(token) - : null; - } - - private static bool IsSupportedProgressToken(JsonNode token) - { - var nodeCount = 0; - return IsSupportedProgressToken(token, depth: 0, ref nodeCount); - } - - private static bool IsSupportedProgressToken(JsonNode token, int depth, ref int nodeCount) - { - if (depth > McpBoundedText.MaxProgressTokenDepth) - return false; - - nodeCount++; - if (nodeCount > McpBoundedText.MaxProgressTokenNodeCount) - return false; - - return token switch - { - JsonValue value => IsSupportedProgressTokenScalar(value), - JsonObject obj => IsSupportedProgressTokenObject(obj, depth, ref nodeCount), - _ => false, - }; - } - - private static bool IsSupportedProgressTokenScalar(JsonValue value) - => value.GetValueKind() switch - { - JsonValueKind.String => value.TryGetValue(out var text) - && text.Length <= McpBoundedText.MaxProgressTokenStringChars, - JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => true, - _ => false, - }; - - private static bool IsSupportedProgressTokenObject(JsonObject obj, int depth, ref int nodeCount) - { - foreach (var pair in obj) - { - if (pair.Key.Length > McpBoundedText.MaxProgressTokenPropertyNameChars) - return false; - if (pair.Value is null) - { - nodeCount++; - if (nodeCount > McpBoundedText.MaxProgressTokenNodeCount) - return false; - continue; - } - - if (!IsSupportedProgressToken(pair.Value, depth + 1, ref nodeCount)) - return false; - } - - return true; - } - - private async Task EmitProgressNotificationAsync(JsonNode? progressToken, long progress, long? total, string? message = null) - { - if (progressToken is null || _currentOutOfBandFrameWriter.Value is not { } writer) - return; - - var parameters = new JsonObject - { - ["progressToken"] = McpJsonNode.Clone(progressToken), - ["progress"] = progress, - }; - if (total.HasValue) - parameters["total"] = total.Value; - if (!string.IsNullOrWhiteSpace(message)) - parameters["message"] = message; - - var notification = new JsonObject - { - ["jsonrpc"] = "2.0", - ["method"] = "notifications/progress", - ["params"] = parameters, - }; - await writer(notification.ToJsonString(_jsonOptions), _currentRequestToken.Value).ConfigureAwait(false); - } - - private async Task EmitLogNotificationAsync(string level, string message) - { - if (_currentOutOfBandFrameWriter.Value is not { } writer) - return; - - var notification = new JsonObject - { - ["jsonrpc"] = "2.0", - ["method"] = "notifications/message", - ["params"] = new JsonObject - { - ["level"] = level, - ["logger"] = "cdidx", - ["data"] = message, - }, - }; - await writer(notification.ToJsonString(_jsonOptions), _currentRequestToken.Value).ConfigureAwait(false); - } - - /// - /// Emit a single audit record for the just-executed tool call. Inspects the wire - /// response to derive the result count and error code, and uses invocation-scoped - /// authorization state when available, so the audit trail preserves checks performed - /// before later error paths build a response (#1562, #4606). Failures are swallowed - /// because audit emission must never break the underlying tool call. - /// 直前に実行したツール呼び出しを 1 レコード分監査出力する。クライアントが実際に観測する - /// 値と一致させるため wire response から result count / error code を抽出し、後続の error - /// path が response を生成する前の検証も残すため invocation-scoped authorization state を使う - /// (#1562, #4606)。 - /// audit 失敗で本体ツール呼び出しを壊さないようベストエフォート化する。 - /// - private void TryEmitAudit(bool hasId, string toolName, JsonNode? id, JsonNode? args, JsonNode response, DateTimeOffset startedAt, double elapsedMs, string? errorType) - { - if (_auditLog is null) - return; - - try - { - var initializeState = CurrentInitializeState; - var (errorCode, observedErrorType) = ExtractErrorCode(response); - var resultCount = ExtractResultCount(response); - var (argKeys, argLengths, argKeyLengths, argValuesEcho) = - SanitizeArgs(args, _auditLog.IncludeValues, - out var argValuesRedacted, - out var argValuesTruncated, - out var argValueTruncationReasons, - out var argValuesSerializedBytes, - out var argKeysTruncated, - out var argKeyTruncationReasons, - out var argKeysOmittedCount, - out var argKeyNamesTruncatedCount); - var toolDisplay = BoundToolNameForDisplay(toolName); - McpRequestIdTelemetryData? requestId = hasId - ? McpRequestIdTelemetry.Create(id) - : null; - var evt = new AuditLogSink.AuditEvent( - Timestamp: startedAt, - Tool: toolDisplay.Text, - CallerName: initializeState.ClientName, - CallerVersion: initializeState.ClientVersion, - RequestId: requestId?.Token, - ArgKeys: argKeys, - ArgLengths: argLengths, - ArgValues: argValuesEcho, - ResultCount: resultCount, - ElapsedMs: elapsedMs, - ErrorCode: errorCode, - ErrorType: errorType ?? observedErrorType, - CheckedRootIdentity: _currentIndexAuditContext.Value?.CheckedRootIdentity ?? ExtractCheckedRootIdentity(response), - ToolLength: toolDisplay.Truncated ? toolDisplay.OriginalLength : null, - ToolTruncated: toolDisplay.Truncated, - ArgKeyLengths: argKeyLengths, - ArgKeysTruncated: argKeysTruncated, - ArgKeyTruncationReasons: argKeyTruncationReasons, - ArgKeysOmittedCount: argKeysOmittedCount, - ArgKeyNamesTruncatedCount: argKeyNamesTruncatedCount, - ArgValuesRedacted: argValuesRedacted, - ArgValuesTruncated: argValuesTruncated, - ArgValueTruncationReasons: argValueTruncationReasons, - ArgValuesSerializedBytes: argValuesSerializedBytes, - RequestIdType: requestId?.Type, - RequestIdLength: requestId?.Length, - CallerNameLength: initializeState.ClientNameDisplay?.Truncated == true ? initializeState.ClientNameDisplay.Value.OriginalLength : null, - CallerNameTruncated: initializeState.ClientNameDisplay?.Truncated == true, - CallerVersionLength: initializeState.ClientVersionDisplay?.Truncated == true ? initializeState.ClientVersionDisplay.Value.OriginalLength : null, - CallerVersionTruncated: initializeState.ClientVersionDisplay?.Truncated == true); - _auditLog.Record(evt); - } - catch - { - // Best-effort: an audit failure must not break the tool call. - // ベストエフォート: audit 失敗で本体ツール呼び出しを壊さない。 - } - } - - private static string? ExtractCheckedRootIdentity(JsonNode response) - { - var node = response["result"]?["structuredContent"]?["checked_root_identity"] - ?? response["error"]?["data"]?["checked_root_identity"]; - return node is JsonValue value && value.TryGetValue(out var identity) - ? identity - : null; - } - - /// - /// Translate the wire response into `(error_code, error_type)` for the audit record. - /// 0 means success, positive means a tool-level error (isError=true), and negative is - /// the verbatim JSON-RPC error code (e.g. -32602 invalid params). - /// レスポンスを audit 用の `(error_code, error_type)` に変換する。0=成功、正値= - /// tool エラー (isError=true)、負値=JSON-RPC エラーコード(例: -32602)。 - /// - internal static (int Code, string? Type) ExtractErrorCode(JsonNode response) - { - if (response is not JsonObject obj) - return (0, null); - if (obj.TryGetPropertyValue("error", out var errorNode) && errorNode is JsonObject errorObj) - { - var code = -32603; - if (errorObj.TryGetPropertyValue("code", out var codeNode) && codeNode is JsonValue codeValue - && codeValue.TryGetValue(out var parsed)) - code = parsed; - return (code, "jsonrpc_error"); - } - if (obj.TryGetPropertyValue("result", out var resultNode) && resultNode is JsonObject resultObj) - { - if (resultObj.TryGetPropertyValue("isError", out var isErrorNode) - && isErrorNode is JsonValue isErrorValue - && isErrorValue.TryGetValue(out var isError) - && isError) - return (1, "tool_error"); - } - return (0, null); - } - - /// - /// Extract the result count from a successful tool response. Prefers - /// `structuredContent.count`, falls back to the length of `structuredContent.results`, - /// and returns null when neither shape is present (e.g. ping). Tool errors and JSON-RPC - /// errors return null because there is no meaningful result-set count for those cases. - /// 成功レスポンスから result count を抽出する。`structuredContent.count` を優先、 - /// `structuredContent.results` の長さに fallback。どちらも無い場合(例: ping)と - /// tool/JSON-RPC エラー時は null を返す。 - /// - internal static int? ExtractResultCount(JsonNode response) - { - if (response is not JsonObject obj) - return null; - if (obj["result"] is not JsonObject result) - return null; - if (result["isError"] is JsonValue isErrorValue - && isErrorValue.TryGetValue(out var isError) && isError) - return null; - if (result["structuredContent"] is not JsonObject structured) - return null; - if (structured["count"] is JsonValue countValue && countValue.TryGetValue(out var count)) - return count; - if (structured["results"] is JsonArray results) - return results.Count; - return null; - } - - /// - /// Build the `(arg_keys, arg_lengths, arg_key_lengths, arg_values?)` audit triple. Values are echoed - /// only when the operator has opted in via `--audit-log-include-values`; otherwise we - /// keep keys + per-key length so AI argument shapes can be reconstructed without - /// persisting query bodies that may contain sensitive substrings (#1562). - /// audit 用の `(arg_keys, arg_lengths, arg_values?)` を組み立てる。値は - /// `--audit-log-include-values` がオンの場合のみ転写し、それ以外はキーと長さだけ残す - /// (secret 風の検索クエリを取り込まないため)。 - /// - internal static (IReadOnlyList Keys, IReadOnlyList> Lengths, IReadOnlyList> KeyLengths, JsonNode? ValuesEcho) - SanitizeArgs(JsonNode? args, bool includeValues) - => SanitizeArgs(args, includeValues, out _, out _, out _, out _, out _, out _, out _, out _); - - private static (IReadOnlyList Keys, IReadOnlyList> Lengths, IReadOnlyList> KeyLengths, JsonNode? ValuesEcho) - SanitizeArgs( - JsonNode? args, - bool includeValues, - out bool argValuesRedacted, - out bool argValuesTruncated, - out IReadOnlyList argValueTruncationReasons, - out int? argValuesSerializedBytes, - out bool argKeysTruncated, - out IReadOnlyList argKeyTruncationReasons, - out int argKeysOmittedCount, - out int argKeyNamesTruncatedCount) - { - argValuesRedacted = false; - argValuesTruncated = false; - argValueTruncationReasons = Array.Empty(); - argValuesSerializedBytes = null; - argKeysTruncated = false; - argKeysOmittedCount = 0; - argKeyNamesTruncatedCount = 0; - var argKeyReasons = new List(); - argKeyTruncationReasons = argKeyReasons; - if (args is not JsonObject argsObj) - return (Array.Empty(), Array.Empty>(), Array.Empty>(), null); - - var keys = new List(argsObj.Count); - var lengths = new List>(argsObj.Count); - var keyLengths = new List>(); - var usedKeys = new HashSet(StringComparer.Ordinal); - JsonObject? echoObject = includeValues ? new JsonObject() : null; - AuditLogSink.ArgValueSanitizationState? valueState = includeValues ? new AuditLogSink.ArgValueSanitizationState() : null; - var argValueBudgetExhausted = false; - var argumentCount = 0; - foreach (var (key, value) in argsObj) - { - if (argumentCount >= AuditLogSink.MaxAuditArgumentCount) - { - argKeysTruncated = true; - argKeysOmittedCount = argsObj.Count - argumentCount; - AddUniqueReason(argKeyReasons, "arg_key_count_limit"); - break; - } - - var keyDisplay = McpBoundedText.ForDisplay(key, AuditLogSink.MaxAuditArgumentKeyChars); - var displayKey = MakeUniqueArgumentDisplayKey(key, keyDisplay, usedKeys); - keys.Add(displayKey); - lengths.Add(new KeyValuePair(displayKey, AuditLogSink.MeasureArgLength(value))); - if (keyDisplay.Truncated) - { - keyLengths.Add(new KeyValuePair(displayKey, keyDisplay.OriginalLength)); - argKeysTruncated = true; - argKeyNamesTruncatedCount++; - AddUniqueReason(argKeyReasons, "arg_key_length_limit"); - } - if (echoObject is not null && !argValueBudgetExhausted) - { - try - { - if (!valueState!.TryReservePropertyName(displayKey)) - { - argValueBudgetExhausted = true; - } - else - { - echoObject[displayKey] = AuditLogSink.SanitizeArgValue(key, value, valueState); - argValuesRedacted = valueState.Redacted; - } - } - catch - { - echoObject = null; - } - } - argumentCount++; - } - if (valueState is not null) - { - argValuesRedacted = valueState.Redacted; - argValuesTruncated = valueState.Truncated; - argValueTruncationReasons = valueState.TruncationReasons; - argValuesSerializedBytes = valueState.SerializedBytes; - } - - return (keys, lengths, keyLengths, includeValues ? echoObject : null); - } - - private static void AddUniqueReason(List reasons, string reason) - { - foreach (var existing in reasons) - { - if (StringComparer.Ordinal.Equals(existing, reason)) - return; - } - reasons.Add(reason); - } - - private static string MakeUniqueArgumentDisplayKey(string rawKey, BoundedMcpText display, ISet usedKeys) - { - if (usedKeys.Add(display.Text)) - return display.Text; - - var hashSuffix = "#" + ShortStableHash(rawKey); - var candidate = ComposeDisplayKeyWithSuffix(rawKey, hashSuffix); - var disambiguator = 2; - while (!usedKeys.Add(candidate)) - { - candidate = ComposeDisplayKeyWithSuffix( - rawKey, - $"{hashSuffix}-{disambiguator.ToString(CultureInfo.InvariantCulture)}"); - disambiguator++; - } - - return candidate; - } - - private static string ComposeDisplayKeyWithSuffix(string rawKey, string suffix) - { - const int maxDisplayTextChars = McpBoundedText.MaxDiagnosticDisplayChars + 3; - var maxPrefixChars = Math.Max(0, maxDisplayTextChars - suffix.Length - 3); - return McpBoundedText.ForDisplay(rawKey, maxPrefixChars).Text + suffix; - } - - private static string ShortStableHash(string value) - { - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value)); - return HexEncoding.ToLowerHexString(bytes, 0, 4); - } - - private static void AddArgKeyMetadata( - JsonObject target, - IReadOnlyList> argKeyLengths, - int argKeysOmittedCount, - int argKeyNamesTruncatedCount) - { - if (argKeyLengths.Count > 0) - { - var lengths = new JsonObject(); - foreach (var pair in argKeyLengths) - lengths[pair.Key] = pair.Value; - target["arg_key_lengths"] = lengths; - target["arg_keys_truncated"] = true; - } - if (argKeysOmittedCount > 0) - target["arg_keys_omitted_count"] = argKeysOmittedCount; - if (argKeyNamesTruncatedCount > 0) - target["arg_key_names_truncated_count"] = argKeyNamesTruncatedCount; - } - - private static string? SerializeRequestId(JsonNode? id) - { - return TrySerializeRequestId(id, out var serialized, out _) ? serialized : null; - } - - private static string? TryReadStringArg(JsonNode? args, string key) - { - if (args is null) - return null; - - try - { - var node = args[key]; - if (node is null) - return null; - if (node is JsonValue value && value.TryGetValue(out var stringValue)) - return string.IsNullOrWhiteSpace(stringValue) ? null : stringValue; - } - catch - { - // Best-effort: any oddity in argument shape just suppresses the language hint. - // ベストエフォート: 引数形状が不正でも language ヒントを抑止するだけ。 - } - return null; - } - - private static string? TryReadMetricStringArg(JsonNode? args, string key) - { - var value = TryReadStringArg(args, key); - return value is null ? null : McpBoundedText.ForDisplay(value).Text; - } - - internal static string BuildOversizedMessageLog(int characterCount, int byteCount) => - $"[cdidx-mcp] Message too large ({characterCount} chars / {byteCount} bytes), rejecting. Split the request into smaller JSON-RPC messages or shorter arguments, then retry."; - - internal static string BuildJsonParseErrorLog(string detail) => - $"[cdidx-mcp] JSON parse error: {DiagnosticRedactor.BoundDiagnosticText(detail, JsonFrameParser.MaxParseDiagnosticChars)}. MCP stdio expects one UTF-8 JSON-RPC object per LF-delimited line; do not send LSP Content-Length framing."; - - internal static string BuildUnhandledLoopErrorLog(string detail) => - $"[cdidx-mcp] Error: {detail}. This request was skipped; fix the request or inspect the server environment, then retry."; - - internal static string BuildResponseSerializationErrorLog(string detail) => - $"[cdidx-mcp] Error serializing response: {detail}. Returning a minimal JSON-RPC error response when possible."; - - internal static string BuildResponseWriteErrorLog(string detail) => - $"[cdidx-mcp] Error writing response: {detail}. The request was handled but the client connection may already be closed."; - - internal static string BuildToolErrorLog(string toolName, Exception ex) => - $"[cdidx-mcp] Tool error ({BoundToolNameForDisplay(toolName).Text}): {BuildSanitizedExceptionLogDetail(ex)}. Fix the tool arguments, refresh the index if needed, then retry."; - - internal static string BuildSanitizedExceptionLogDetail(Exception ex) - { - var exceptionType = McpBoundedText.ForDisplay(ex.GetType().Name).Text; - if (ex is CodeIndexException codeIndexEx) - { - var code = McpBoundedText.ForDisplay(codeIndexEx.Code).Text; - var category = McpBoundedText.ForDisplay(codeIndexEx.Category).Text; - return $"{exceptionType} code={code} category={category}{BuildPathFragment(codeIndexEx)}{BuildHintFragment(codeIndexEx)}"; - } - - return exceptionType; - } - - internal static string BuildClientResponseTooLargeLog(string member, int bytesWritten) => - $"[cdidx-mcp] Client response {member} exceeded the server byte limit ({bytesWritten} > {MaxClientResponseJsonBytes}); rejecting without retaining the payload."; - - private static string BuildClientResponseTooLargeMessage(int bytesWritten) => - $"MCP client response exceeded the server byte limit ({bytesWritten} > {MaxClientResponseJsonBytes})."; - - // Stderr log emitted when the rate limiter denies a tool call. Mirrors the JSON-RPC - // `-32000` payload (tool + caller + retry_after_ms) so operators tailing the MCP log - // can correlate spikes with the structured error returned on the wire (#1560). - // レート制限で拒否されたツール呼び出しを stderr に記録する。配線上の JSON-RPC `-32000` - // ペイロードと内容を揃え、運用側がログ追跡から状況把握できるようにする(#1560)。 - internal static string BuildRateLimitedLog(string toolName, string caller, long retryAfterMs) => - $"[cdidx-mcp] Rate limit exceeded: tool='{BoundToolNameForDisplay(toolName).Text}', caller='{BoundClientIdentityForDisplay(caller).Text}', retry_after_ms={retryAfterMs}. Increase {RateLimiterOptions.RpsEnvVar} / {RateLimiterOptions.BurstEnvVar} on the server, or back off and retry."; - - internal static string BuildCallerSwapRejectionLog(string current, string attempted) => - $"[cdidx-mcp] Ignoring re-initialize with new clientInfo identity '{BoundClientIdentityForDisplay(attempted).Text}': retaining original caller '{BoundClientIdentityForDisplay(current).Text}' so rate-limit buckets cannot be reset mid-session."; - - internal static string BuildUnknownNotificationLog(string method) => - $"[cdidx-mcp] Ignoring unknown notification: {method}"; - - internal static bool IsSupportedMcpLogLevel(string? level) - => level is "debug" or "info" or "notice" or "warning" or "error" or "critical" or "alert" or "emergency"; - - internal static bool IsUnsafeDebugEnabled() - => McpEnvironment.IsUnsafeDebugEnabled(DebugEnvironmentVariable); - - internal static string FormatDbPathForLog(string dbPath) - { - if (IsUnsafeDebugEnabled()) - return dbPath; - - try - { - var path = dbPath; - if (Uri.TryCreate(dbPath, UriKind.Absolute, out var uri) && uri.IsFile) - path = uri.LocalPath; - var fileName = Path.GetFileName(path); - return string.IsNullOrWhiteSpace(fileName) ? "(configured db)" : fileName; - } - catch - { - return "(configured db)"; - } - } - - // Wire-safe error body for the tool catch-all. Mentions the tool and the - // exception type so the client can branch (retry vs. surface to user) - // while keeping bound values or matched content out of the response (#1530). - // For CodeIndexException (#1580) the Code / Category / Path / Hint fields - // are author-controlled and therefore safe to echo verbatim, so the client - // gets the structured failure metadata it needs without re-introducing the - // ex.Message leak vector #1530 closed. - // ツール catch-all のワイヤー向け本文。クライアントが分岐できるよう tool 名と - // 例外型は残し、バインド値や一致内容は含めない(#1530)。CodeIndexException (#1580) - // の Code / Category / Path / Hint は実装側で固定したフィールドなのでそのまま転写し、 - // #1530 で封じた ex.Message 漏れを再現させずに失敗詳細をクライアントへ届ける。 - internal static string BuildSanitizedToolErrorMessage(string toolName, Exception ex) - { - var toolDisplay = BoundToolNameForDisplay(toolName).Text; - if (!IsUnsafeDebugEnabled()) - return $"Tool '{toolDisplay}' failed. See cdidx server stderr for details."; - if (ex is CodeIndexException codeIndexEx) - return $"Error executing {toolDisplay} ({ex.GetType().Name}) [{codeIndexEx.Code}/{codeIndexEx.Category}]{BuildPathFragment(codeIndexEx)}{BuildHintFragment(codeIndexEx)}. See cdidx server stderr for details."; - return $"Error executing {toolDisplay} ({ex.GetType().Name}). See cdidx server stderr for details."; - } - - // Wire-safe error body for the JSON-RPC loop catch-all. Same rationale as - // the tool catch-all (#1530, #1580). - // JSON-RPC ループ catch-all のワイヤー向け本文。理由はツール catch-all と同じ(#1530, #1580)。 - internal static string BuildSanitizedLoopErrorMessage(Exception ex) - { - if (!IsUnsafeDebugEnabled()) - return "Internal MCP error. See cdidx server stderr for details."; - if (ex is CodeIndexException codeIndexEx) - return $"Internal error ({ex.GetType().Name}) [{codeIndexEx.Code}/{codeIndexEx.Category}]{BuildPathFragment(codeIndexEx)}{BuildHintFragment(codeIndexEx)}. See cdidx server stderr for details."; - return $"Internal error ({ex.GetType().Name}). See cdidx server stderr for details."; - } - - // Quote so paths/hints with spaces stay one token. Single quotes are kept - // for human readability — this is a display contract, not a shell-parsing one. - // 空白を含む path / hint が 2 トークンに見えないよう単引用符でラップする。 - private static string BuildPathFragment(CodeIndexException ex) => - string.IsNullOrEmpty(ex.Path) ? string.Empty : $" path='{ex.Path}'"; - - private static string BuildHintFragment(CodeIndexException ex) => - string.IsNullOrEmpty(ex.Hint) ? string.Empty : $" hint='{ex.Hint}'"; - // Tool implementations are in McpToolHandlers.cs / ツール実装は McpToolHandlers.cs に分離 // --- DB helper / DBヘルパー --- From cac3b38500425b27535de52505ce4125dc6cd6ba Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:29:41 +0900 Subject: [PATCH 008/101] Isolate MCP database and shutdown lifecycle --- .../Mcp/McpServer.DatabaseLifecycle.cs | 197 ++++++++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 174 ---------------- 2 files changed, 197 insertions(+), 174 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.DatabaseLifecycle.cs diff --git a/src/CodeIndex/Mcp/McpServer.DatabaseLifecycle.cs b/src/CodeIndex/Mcp/McpServer.DatabaseLifecycle.cs new file mode 100644 index 000000000..eb39ab006 --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.DatabaseLifecycle.cs @@ -0,0 +1,197 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Mcp; + +public partial class McpServer : IDisposable +{ + + + // Tool implementations are in McpToolHandlers.cs / ツール実装は McpToolHandlers.cs に分離 + + // --- DB helper / DBヘルパー --- + + private JsonNode WithDbReader(JsonNode? id, JsonNode? args, Func action) + { + var isolateRequestDb = _isolateDbForCurrentRequest.Value; + // Accept SQLite file: URIs the same way the CLI does (QueryCommandRunner.WithDb), + // so AI agents on read-only mounts can pass `--db file:///abs/path?immutable=1` and + // reach the read-only escape hatch in DbContext. File.Exists is skipped for URI- + // shaped values because they may carry query params meaningless to the filesystem. + // CLI と同じく file: URI を受け付け、サンドボックス用の escape hatch に到達できるようにする。 + var isUri = _dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase); + if (!isUri && !File.Exists(LongPath.EnsureWindowsPrefix(_dbPath))) + { + // Drop any stale cached context so the next tool call can re-open after the user + // creates the DB (e.g. via an external `cdidx index`). Without this, a missed + // file lookup would leave a closed/disposed handle blocking later open attempts. + // ユーザーが後から DB を作った場合に再オープンできるよう、キャッシュをここで破棄。 + if (!isolateRequestDb) + CloseSharedDb(); + return CreateToolErrorResponse(true, id, $"Database not found: {_dbPath}. Run 'cdidx index ' first.", + category: McpErrorEnvelope.CategoryIndexMissing, + suggestion: "Run `cdidx index ` to build the index before retrying. The DB lives at `.cdidx/codeindex.db` by default.", + retrySafe: true); + } + + var requestToken = _currentRequestToken.Value; + requestToken.ThrowIfCancellationRequested(); + if (isolateRequestDb) + { + using var isolatedDb = new DbContext(DbOpenIntent.QueryOnly, _dbPath, requestToken); + using var isolatedReader = new DbReader(isolatedDb, requestToken); + isolatedReader.IncludeGenerated = args?["includeGenerated"]?.GetValue() ?? false; + return RunWithSqliteDiagnostics(isolatedReader, action); + } + + // Artifact-preserving WAL reads use detached private snapshots. Refresh them between + // MCP calls when the source generation changes so a long-lived server + // observes commits made after the previous call while each individual call keeps one + // stable SQLite snapshot. + // artifact-preserving WAL read は切り離した private snapshot を使う。各呼び出し内の + // 一貫性を保ちつつ、長時間動作する MCP が source generation の変更後に新しい + // commit を観測できるよう、呼び出し間でそれらの handle を更新する。 + if (_sharedDb?.OpenIntent == DbOpenIntent.QueryOnly + && _sharedDb.QueryOnlySnapshotRequiresRefresh + && !_sharedDb.IsQueryOnlySnapshotCurrent(requestToken)) + { + CloseSharedDb(); + } + + var db = GetOrOpenSharedDb(DbOpenIntent.QueryOnly); + // Reuse the connection-scoped schema cache for single-threaded direct callers so each + // call no longer re-runs PRAGMA table_info / PRAGMA index_list per DbReader (issue #1565), + // and hand the per-request cancellation token to the reader so SQLite work + // the tool kicks off can observe shutdown / client-disconnect cancellation + // (#1567). The token is `CancellationToken.None` outside an in-flight request, + // preserving the existing behaviour for ad-hoc callers like tests that drive + // `WithDbReader` through internals. + // MCP ツール呼び出しごとの schema 再走査を排除し (issue #1565)、 + // per-request cancellation token を reader に渡して SQLite 作業が + // shutdown / 切断を観測できるようにする (#1567)。 + using var reader = new DbReader(db, requestToken); + reader.IncludeGenerated = args?["includeGenerated"]?.GetValue() ?? false; + return RunWithSqliteDiagnostics(reader, action); + } + + private JsonNode RunWithSqliteDiagnostics(DbReader reader, Func action) + { + var previousReader = _activeSqliteDiagnosticsReader.Value; + _activeSqliteDiagnosticsReader.Value = reader; + try + { + return reader.RunWithGeneratedScope(() => action(reader)); + } + finally + { + _activeSqliteDiagnosticsReader.Value = previousReader; + } + } + + private void AddConfiguredSqliteDiagnostics(JsonObject payload) + { + var diagnosticsReader = _activeSqliteDiagnosticsReader.Value; + if (diagnosticsReader != null) + { + QueryCommandRunner.AddReadOnlyFallbackDiagnostics(payload, diagnosticsReader); + return; + } + + if (!SqliteFileUri.RequestsImmutableSnapshot(_dbPath)) + return; + + payload["wal_stale_snapshot_risk"] = true; + payload["wal_stale_snapshot_reason"] = "explicit_immutable_read_only"; + } + + /// + /// Open the per-session DbContext on first use and reuse it while the requested intent matches. + /// Centralising the open lets us pay the connection setup, pragma application, and SQL + /// function registration once per direct session instead of once per tool invocation + /// (#1494). Transport requests that may time out independently use isolated DB contexts. + /// 直接呼び出しセッション初回に DbContext を開き、以後は再利用する。timeout 後も独立して + /// 継続し得る transport リクエストは、共有接続を避けるためリクエスト単位の DB context を使う。 + /// + internal DbContext GetOrOpenSharedDb(DbOpenIntent openIntent) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_sharedDb?.OpenIntent == openIntent) + return _sharedDb; + + CloseSharedDb(); + _sharedDb = new DbContext(openIntent, _dbPath, _currentRequestToken.Value); + return _sharedDb; + } + + private void CloseSharedDb() + { + _sharedDb?.Dispose(); + _sharedDb = null; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + CloseSharedDb(); + var shutdownCancellationTask = RequestShutdownCancellation(); + if (shutdownCancellationTask.IsCompleted) + { + CompleteShutdownCleanup(); + } + else + { + _ = shutdownCancellationTask.ContinueWith( + static (_, state) => ((McpServer)state!).CompleteShutdownCleanup(), + this, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + // Bounded transport teardown can intentionally leave a late task that still releases + // this gate. As with `_sharedDbWriteGate`, keep the managed semaphore undisposed so + // eventual completion cannot fail with ObjectDisposedException (#3999, #4543). + // bounded transport teardown 後も late task がこの gate を release し得るため、 + // `_sharedDbWriteGate` と同様に dispose せず、遅延完了時の例外を防ぐ (#3999, #4543)。 + _textWriterGate.Dispose(); + GC.SuppressFinalize(this); + } + + private void CompleteShutdownCleanup() + { + lock (s_serverLifecycleGate) + { + s_activeServerCount--; + if (s_activeServerCount == 0) + ExtractorPluginRegistry.ReleaseWorkspaceSnapshots(); + } + DisposeShutdownCtsOnce(); + } + + internal static int ActiveServerCountForTests() + { + lock (s_serverLifecycleGate) + return s_activeServerCount; + } + + private void DisposeShutdownCtsOnce() + { + if (Interlocked.Exchange(ref _shutdownCtsDisposed, 1) == 0) + _shutdownCts.Dispose(); + } + +} diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 8299df319..966efc10c 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -470,180 +470,6 @@ internal TimeSpan InFlightPostCancelGracePeriod } - // Tool implementations are in McpToolHandlers.cs / ツール実装は McpToolHandlers.cs に分離 - - // --- DB helper / DBヘルパー --- - - private JsonNode WithDbReader(JsonNode? id, JsonNode? args, Func action) - { - var isolateRequestDb = _isolateDbForCurrentRequest.Value; - // Accept SQLite file: URIs the same way the CLI does (QueryCommandRunner.WithDb), - // so AI agents on read-only mounts can pass `--db file:///abs/path?immutable=1` and - // reach the read-only escape hatch in DbContext. File.Exists is skipped for URI- - // shaped values because they may carry query params meaningless to the filesystem. - // CLI と同じく file: URI を受け付け、サンドボックス用の escape hatch に到達できるようにする。 - var isUri = _dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase); - if (!isUri && !File.Exists(LongPath.EnsureWindowsPrefix(_dbPath))) - { - // Drop any stale cached context so the next tool call can re-open after the user - // creates the DB (e.g. via an external `cdidx index`). Without this, a missed - // file lookup would leave a closed/disposed handle blocking later open attempts. - // ユーザーが後から DB を作った場合に再オープンできるよう、キャッシュをここで破棄。 - if (!isolateRequestDb) - CloseSharedDb(); - return CreateToolErrorResponse(true, id, $"Database not found: {_dbPath}. Run 'cdidx index ' first.", - category: McpErrorEnvelope.CategoryIndexMissing, - suggestion: "Run `cdidx index ` to build the index before retrying. The DB lives at `.cdidx/codeindex.db` by default.", - retrySafe: true); - } - - var requestToken = _currentRequestToken.Value; - requestToken.ThrowIfCancellationRequested(); - if (isolateRequestDb) - { - using var isolatedDb = new DbContext(DbOpenIntent.QueryOnly, _dbPath, requestToken); - using var isolatedReader = new DbReader(isolatedDb, requestToken); - isolatedReader.IncludeGenerated = args?["includeGenerated"]?.GetValue() ?? false; - return RunWithSqliteDiagnostics(isolatedReader, action); - } - - // Artifact-preserving WAL reads use detached private snapshots. Refresh them between - // MCP calls when the source generation changes so a long-lived server - // observes commits made after the previous call while each individual call keeps one - // stable SQLite snapshot. - // artifact-preserving WAL read は切り離した private snapshot を使う。各呼び出し内の - // 一貫性を保ちつつ、長時間動作する MCP が source generation の変更後に新しい - // commit を観測できるよう、呼び出し間でそれらの handle を更新する。 - if (_sharedDb?.OpenIntent == DbOpenIntent.QueryOnly - && _sharedDb.QueryOnlySnapshotRequiresRefresh - && !_sharedDb.IsQueryOnlySnapshotCurrent(requestToken)) - { - CloseSharedDb(); - } - - var db = GetOrOpenSharedDb(DbOpenIntent.QueryOnly); - // Reuse the connection-scoped schema cache for single-threaded direct callers so each - // call no longer re-runs PRAGMA table_info / PRAGMA index_list per DbReader (issue #1565), - // and hand the per-request cancellation token to the reader so SQLite work - // the tool kicks off can observe shutdown / client-disconnect cancellation - // (#1567). The token is `CancellationToken.None` outside an in-flight request, - // preserving the existing behaviour for ad-hoc callers like tests that drive - // `WithDbReader` through internals. - // MCP ツール呼び出しごとの schema 再走査を排除し (issue #1565)、 - // per-request cancellation token を reader に渡して SQLite 作業が - // shutdown / 切断を観測できるようにする (#1567)。 - using var reader = new DbReader(db, requestToken); - reader.IncludeGenerated = args?["includeGenerated"]?.GetValue() ?? false; - return RunWithSqliteDiagnostics(reader, action); - } - - private JsonNode RunWithSqliteDiagnostics(DbReader reader, Func action) - { - var previousReader = _activeSqliteDiagnosticsReader.Value; - _activeSqliteDiagnosticsReader.Value = reader; - try - { - return reader.RunWithGeneratedScope(() => action(reader)); - } - finally - { - _activeSqliteDiagnosticsReader.Value = previousReader; - } - } - - private void AddConfiguredSqliteDiagnostics(JsonObject payload) - { - var diagnosticsReader = _activeSqliteDiagnosticsReader.Value; - if (diagnosticsReader != null) - { - QueryCommandRunner.AddReadOnlyFallbackDiagnostics(payload, diagnosticsReader); - return; - } - - if (!SqliteFileUri.RequestsImmutableSnapshot(_dbPath)) - return; - - payload["wal_stale_snapshot_risk"] = true; - payload["wal_stale_snapshot_reason"] = "explicit_immutable_read_only"; - } - - /// - /// Open the per-session DbContext on first use and reuse it while the requested intent matches. - /// Centralising the open lets us pay the connection setup, pragma application, and SQL - /// function registration once per direct session instead of once per tool invocation - /// (#1494). Transport requests that may time out independently use isolated DB contexts. - /// 直接呼び出しセッション初回に DbContext を開き、以後は再利用する。timeout 後も独立して - /// 継続し得る transport リクエストは、共有接続を避けるためリクエスト単位の DB context を使う。 - /// - internal DbContext GetOrOpenSharedDb(DbOpenIntent openIntent) - { - ObjectDisposedException.ThrowIf(_disposed, this); - if (_sharedDb?.OpenIntent == openIntent) - return _sharedDb; - - CloseSharedDb(); - _sharedDb = new DbContext(openIntent, _dbPath, _currentRequestToken.Value); - return _sharedDb; - } - - private void CloseSharedDb() - { - _sharedDb?.Dispose(); - _sharedDb = null; - } - - public void Dispose() - { - if (_disposed) - return; - _disposed = true; - CloseSharedDb(); - var shutdownCancellationTask = RequestShutdownCancellation(); - if (shutdownCancellationTask.IsCompleted) - { - CompleteShutdownCleanup(); - } - else - { - _ = shutdownCancellationTask.ContinueWith( - static (_, state) => ((McpServer)state!).CompleteShutdownCleanup(), - this, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - } - // Bounded transport teardown can intentionally leave a late task that still releases - // this gate. As with `_sharedDbWriteGate`, keep the managed semaphore undisposed so - // eventual completion cannot fail with ObjectDisposedException (#3999, #4543). - // bounded transport teardown 後も late task がこの gate を release し得るため、 - // `_sharedDbWriteGate` と同様に dispose せず、遅延完了時の例外を防ぐ (#3999, #4543)。 - _textWriterGate.Dispose(); - GC.SuppressFinalize(this); - } - - private void CompleteShutdownCleanup() - { - lock (s_serverLifecycleGate) - { - s_activeServerCount--; - if (s_activeServerCount == 0) - ExtractorPluginRegistry.ReleaseWorkspaceSnapshots(); - } - DisposeShutdownCtsOnce(); - } - - internal static int ActiveServerCountForTests() - { - lock (s_serverLifecycleGate) - return s_activeServerCount; - } - - private void DisposeShutdownCtsOnce() - { - if (Interlocked.Exchange(ref _shutdownCtsDisposed, 1) == 0) - _shutdownCts.Dispose(); - } - // --- JSON-RPC helpers / JSON-RPCヘルパー --- private enum RequestIdValidationError From 8c431e4dc1d50961bb99bcb1bbdf4256d89b1c12 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:31:31 +0900 Subject: [PATCH 009/101] Extract MCP response shaping and annotations --- src/CodeIndex/Mcp/McpServer.Responses.cs | 664 +++++++++++++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 641 ---------------------- 2 files changed, 664 insertions(+), 641 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.Responses.cs diff --git a/src/CodeIndex/Mcp/McpServer.Responses.cs b/src/CodeIndex/Mcp/McpServer.Responses.cs new file mode 100644 index 000000000..0551e92fd --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.Responses.cs @@ -0,0 +1,664 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Mcp; + +public partial class McpServer : IDisposable +{ + + + // --- JSON-RPC helpers / JSON-RPCヘルパー --- + + private enum RequestIdValidationError + { + None, + InvalidType, + TooLong, + } + + private static bool TryGetRequestId(JsonObject request, out bool hasId, out JsonNode? id) + => TryGetRequestId(request, out hasId, out id, out _); + + private static bool TryGetRequestId(JsonObject request, out bool hasId, out JsonNode? id, out RequestIdValidationError error) + { + error = RequestIdValidationError.None; + hasId = request.TryGetPropertyValue("id", out id); + if (!hasId) + return true; + + if (id is null) + return true; + + return TrySerializeRequestId(id, out _, out error); + } + + private static bool TrySerializeRequestId(JsonNode? id, out string? serialized, out RequestIdValidationError error) + { + serialized = null; + error = RequestIdValidationError.None; + if (id is null) + return true; + + if (id is not JsonValue value) + { + error = RequestIdValidationError.InvalidType; + return false; + } + + return TrySerializeRequestIdValue(value, out serialized, out error); + } + + private static bool TrySerializeRequestIdValue(JsonValue value, out string? serialized, out RequestIdValidationError error) + { + serialized = null; + error = RequestIdValidationError.None; + JsonValueKind kind; + try + { + kind = value.GetValueKind(); + } + catch + { + error = RequestIdValidationError.InvalidType; + return false; + } + + switch (kind) + { + case JsonValueKind.String: + try + { + var requestId = value.GetValue(); + if (!IsRequestIdWithinBounds(requestId)) + { + error = RequestIdValidationError.TooLong; + return false; + } + + serialized = JsonSerializer.Serialize(requestId); + return true; + } + catch + { + error = RequestIdValidationError.InvalidType; + return false; + } + + case JsonValueKind.Number: + try + { + serialized = value.TryGetValue(out var element) && element.ValueKind == JsonValueKind.Number + ? element.GetRawText() + : value.ToJsonString(); + } + catch + { + error = RequestIdValidationError.InvalidType; + return false; + } + + if (serialized.Length == 0 || !(serialized[0] == '-' || char.IsDigit(serialized[0]))) + { + error = RequestIdValidationError.InvalidType; + serialized = null; + return false; + } + + if (!IsRequestIdWithinBounds(serialized)) + { + error = RequestIdValidationError.TooLong; + serialized = null; + return false; + } + + return true; + + case JsonValueKind.Null: + return true; + + default: + error = RequestIdValidationError.InvalidType; + return false; + } + } + + private static bool IsRequestIdWithinBounds(string value) + => value.Length <= MaxRequestIdCharacterCount + && Encoding.UTF8.GetByteCount(value) <= MaxRequestIdByteLength; + + private static string BuildInvalidRequestIdMessage(RequestIdValidationError error) + => error == RequestIdValidationError.TooLong + ? "Invalid request: id exceeds the request-id length limit" + : "Invalid request: id must be string, number, or null"; + + private static string BuildInvalidRequestIdSuggestion(RequestIdValidationError error) + => error == RequestIdValidationError.TooLong + ? $"JSON-RPC 2.0 `id` must be no more than {MaxRequestIdCharacterCount} characters and {MaxRequestIdByteLength} UTF-8 bytes. Use a compact string or number id." + : "JSON-RPC 2.0 `id` must be a string, integer, or null. Booleans/objects/arrays are not allowed."; + + private static JsonObject? BuildInvalidRequestIdData(RequestIdValidationError error) + => error == RequestIdValidationError.TooLong + ? new JsonObject + { + ["max_request_id_chars"] = MaxRequestIdCharacterCount, + ["max_request_id_bytes"] = MaxRequestIdByteLength, + } + : null; + + private static JsonObject CreateSuccessResponse(JsonNode? id, JsonNode result) + => CreateSuccessResponse(id is not null, id, result); + + private static JsonObject CreateSuccessResponse(bool hasId, JsonNode? id, JsonNode result) + { + AddResponseMeta(result); + var response = new JsonObject + { + ["jsonrpc"] = "2.0", + ["result"] = result + }; + if (hasId) + response["id"] = McpJsonNode.Clone(id); + return response; + } + + private static void AddResponseMeta(JsonNode result) + { + var context = CurrentCorrelationContext.Value; + if (context is null || result is not JsonObject obj) + return; + + var meta = obj["_meta"] as JsonObject ?? new JsonObject(); + meta["correlation_id"] = context.CorrelationId; + if (context.WireRequestId != null) + meta["request_id"] = context.WireRequestId; + obj["_meta"] = meta; + } + + private static JsonObject? AddCorrelationData(JsonObject? extraData) + { + var context = CurrentCorrelationContext.Value; + if (context is null) + return extraData; + + var data = extraData is null ? new JsonObject() : (JsonObject)extraData.DeepClone(); + data["correlation_id"] = context.CorrelationId; + if (context.WireRequestId != null) + data["request_id"] = context.WireRequestId; + return data; + } + + private static JsonObject CreateErrorResponse(JsonNode? id, int code, string message, + string category, string suggestion, bool retrySafe, JsonObject? extraData = null) + => CreateErrorResponse(id is not null, id, code, message, category, suggestion, retrySafe, extraData); + + private static BoundedMcpText BoundToolNameForDisplay(string toolName) + => McpBoundedText.ForDisplay(toolName, McpBoundedText.MaxToolNameChars); + + private static void AddToolDisplayData(JsonObject target, string? toolName) + { + if (toolName is null) + { + target["tool"] = null; + return; + } + + var display = BoundToolNameForDisplay(toolName); + target["tool"] = display.Text; + display.AddMetadata(target, "tool"); + } + + internal static string BuildUnknownToolMessage(string toolName) + => $"Unknown tool: {BoundToolNameForDisplay(toolName).Text}"; + + private static JsonObject BuildUnknownToolData(string toolName) + { + var data = new JsonObject(); + AddToolDisplayData(data, toolName); + return data; + } + + private static JsonObject BuildToolExceptionData(string toolName, string exceptionType) + { + var data = new JsonObject + { + ["exception_type"] = exceptionType, + }; + AddToolDisplayData(data, toolName); + return data; + } + + private static JsonObject CreateUnknownToolErrorResponse(bool hasId, JsonNode? id, string toolName) + => CreateErrorResponse(hasId: hasId, id: id, code: -32602, message: BuildUnknownToolMessage(toolName), + category: McpErrorEnvelope.CategoryToolUnknown, + suggestion: "Call tools/list to enumerate the available tool names for this server. Tool name match is case-sensitive.", + retrySafe: false, + extraData: BuildUnknownToolData(toolName)); + + // Issue #1581: every MCP error response carries a structured `data` envelope + // (`category` / `suggestion` / `retry_safe`) so clients can branch on a stable + // category instead of parsing the human-readable `message`. Category-specific + // extras (e.g. rate-limited's `retry_after_ms`) merge in via `extraData`. + // #1581: すべての MCP エラー応答に `category` / `suggestion` / `retry_safe` を含む + // 構造化 `data` を載せ、クライアントが文字列解析せず分岐できるようにする。カテゴリ + // 固有フィールド(rate-limited の `retry_after_ms` 等)は `extraData` で合流する。 + private static JsonObject CreateErrorResponse(bool hasId, JsonNode? id, int code, string message, + string category, string suggestion, bool retrySafe, JsonObject? extraData = null) + { + var response = new JsonObject + { + ["jsonrpc"] = "2.0", + ["error"] = new JsonObject + { + ["code"] = code, + ["message"] = message, + ["data"] = McpErrorEnvelope.BuildData(category, suggestion, retrySafe, AddCorrelationData(extraData)), + } + }; + if (hasId) + response["id"] = McpJsonNode.Clone(id); + return response; + } + + private static JsonObject CreateCancelledResponse(JsonNode? id) + => CreateErrorResponse(hasId: true, id: id, code: McpErrorEnvelope.CodeRequestCancelled, + message: "Request cancelled", + category: McpErrorEnvelope.CategoryRequestCancelled, + suggestion: "The client cancelled this request before completion. Reissue the call if the work is still needed.", + retrySafe: true); + + /// + /// Create a tool result response (MCP format). + /// ツール結果レスポンスを作成(MCP形式)。 + /// + private JsonObject CreateToolResult( + JsonNode? id, + string text, + JsonNode? structuredContent = null, + string? mimeType = null, + bool enrichStructuredContent = true) + { + mimeType ??= structuredContent is null ? "text/plain" : "application/json"; + var result = new JsonObject + { + ["content"] = new JsonArray + { + new JsonObject + { + ["type"] = "text", + ["mimeType"] = mimeType, + ["text"] = text + } + } + }; + if (structuredContent is JsonObject structuredObject) + { + if (enrichStructuredContent) + EnrichToolStructuredContent(structuredObject); + result["structuredContent"] = structuredContent; + } + else if (structuredContent != null) + { + ClearProjectFilterRootDiagnostics(); + result["structuredContent"] = structuredContent; + } + else + { + ClearProjectFilterRootDiagnostics(); + } + var response = CreateSuccessResponse(true, id, result); + var responseLimit = GetMaxResponseBytes(); + if (TryMeasureJsonUtf8BytesWithinLimit(response, _jsonOptions, responseLimit, out var responseBytes)) + return response; + + return CreateResponseTooLargeError(true, id, responseBytes, responseLimit, actualBytesExact: false); + } + + private void EnrichToolStructuredContent(JsonObject structuredContent) + { + structuredContent.TryAdd("api_version", JsonOutputContract.ApiVersion); + AddProjectFilterRootDiagnostics(structuredContent); + AddConfiguredSqliteDiagnostics(structuredContent); + } + + internal bool TrySerializeJsonNodeWithinByteLimitForTests(JsonNode node, int maxBytes, out string? serialized, out int bytesWritten) + => TrySerializeJsonNodeWithinByteLimit(node, _jsonOptions, maxBytes, captureSerialized: true, out serialized, out bytesWritten); + + private static bool TryMeasureJsonUtf8BytesWithinLimit(JsonNode node, JsonSerializerOptions options, int maxBytes, out int bytesWritten) + => TrySerializeJsonNodeWithinByteLimit(node, options, maxBytes, captureSerialized: false, out _, out bytesWritten); + + private static bool TrySerializeJsonNodeWithinByteLimit(JsonNode node, JsonSerializerOptions options, int maxBytes, bool captureSerialized, out string? serialized, out int bytesWritten) + { + if (maxBytes < 0) + throw new ArgumentOutOfRangeException(nameof(maxBytes), maxBytes, "JSON byte limit must be non-negative."); + + serialized = null; + using var stream = new BoundedJsonUtf8Stream( + maxBytes, + captureSerialized, + bytes => new JsonResponseByteLimitExceededException(bytes)); + var writerOptions = new JsonWriterOptions + { + Encoder = options.Encoder, + Indented = options.WriteIndented, + }; + + try + { + using var writer = new Utf8JsonWriter(stream, writerOptions); + node.WriteTo(writer, options); + writer.Flush(); + bytesWritten = stream.BytesWritten; + serialized = stream.GetCapturedString(); + return true; + } + catch (JsonResponseByteLimitExceededException ex) + { + bytesWritten = ex.BytesWritten; + return false; + } + } + + private sealed class JsonResponseByteLimitExceededException(int bytesWritten) : Exception + { + public int BytesWritten { get; } = bytesWritten; + } + + private JsonObject CreateResponseTooLargeError(bool hasId, JsonNode? id, int responseBytes, int responseLimit, bool actualBytesExact = true) + { + var response = CreateErrorResponse( + hasId: hasId, + id: id, + code: -32603, + message: $"MCP response exceeded the server byte limit ({responseBytes} > {responseLimit}). Narrow the query or lower the result limit.", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Narrow the query, add path/language filters, lower limit, or use countOnly for a summary-first probe.", + retrySafe: false, + extraData: new JsonObject + { + ["reason"] = "response_too_large", + ["limit_bytes"] = responseLimit, + ["actual_bytes"] = responseBytes, + ["actual_bytes_exact"] = actualBytesExact, + }); + AddConfiguredSqliteDiagnostics((JsonObject)response["error"]!["data"]!); + return response; + } + + private static int GetMaxResponseBytes() + => ReadPositiveIntEnvironmentLimit( + MaxResponseBytesEnvVar, + DefaultMaxResponseBytes, + MaxConfiguredResponseBytes, + "MCP response byte limit"); + + private static int ReadPositiveIntEnvironmentLimit(string envVar, int defaultValue, int maximumValue, string description) + { + var raw = global::CodeIndex.EnvironmentAccess.GetProcessEnvironmentVariable(envVar); + if (string.IsNullOrWhiteSpace(raw)) + return defaultValue; + + if (!int.TryParse(raw, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var limit) + || limit <= 0) + { + var displayValue = DiagnosticRedactor.FormatEnvironmentValue(envVar, raw); + CommandErrorWriter.WriteStderr($"[cdidx-mcp] Ignoring invalid {envVar}='{displayValue}'. Expected a positive integer for {description}. Using default {defaultValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}."); + return defaultValue; + } + + if (limit > maximumValue) + { + var displayValue = DiagnosticRedactor.FormatEnvironmentValue(envVar, raw); + CommandErrorWriter.WriteStderr($"[cdidx-mcp] Clamping {envVar}='{displayValue}' to maximum {maximumValue.ToString(System.Globalization.CultureInfo.InvariantCulture)} for {description}."); + return maximumValue; + } + + return limit; + } + + /// + /// Create a tool error response (MCP format with isError flag). + /// Optional attach a structured + /// data.similar_values array to the result so MCP clients can offer + /// recovery alternatives without parsing the human-readable message (#1582). + /// ツールエラーレスポンスを作成(isError フラグ付き MCP 形式)。 + /// を渡すと結果に構造化された + /// data.similar_values 配列を添えるので、MCP クライアントは + /// 人間向けメッセージを解析せずに代替候補を提示できる (#1582)。 + /// + private JsonObject CreateToolErrorResponse(JsonNode? id, string message, + string category, string suggestion, bool retrySafe, JsonObject? extraData = null, + IReadOnlyList? similarValues = null) + => CreateToolErrorResponse(id is not null, id, message, category, suggestion, retrySafe, extraData, similarValues); + + // Backward-compatible overload for tool handlers that return argument-validation + // failures (#1581). These were all "missing parameter / invalid argument" call sites + // before the envelope was introduced, so the default classification is `invalid_argument` + // / retry_safe=false. The optional `similarValues` carries the structured did-you-mean + // candidates for unknown enum values (#1582). Sites that have richer context should + // call the explicit overload. + // 引数バリデーション失敗を返す既存ツールハンドラ向けの互換オーバーロード(#1581)。 + // envelope 導入前の呼び出しは全て「引数不正」系だったため既定カテゴリを `invalid_argument` + // / retry_safe=false とする。任意の `similarValues` は未知 enum 値に対する構造化された + // did-you-mean 候補 (#1582)。より具体的なカテゴリを持てる呼び出し元は明示オーバーロード + // を使う。 + private JsonObject CreateToolErrorResponse(JsonNode? id, string message, + IReadOnlyList? similarValues = null) + => CreateToolErrorResponse(id, message, + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Tool argument validation failed. Inspect the tool's `inputSchema` via tools/list and adjust the call.", + retrySafe: false, + similarValues: similarValues); + + // Issue #1581: tool-result errors mirror the JSON-RPC error envelope by including + // the same `category` / `suggestion` / `retry_safe` triple under `result.structuredContent`. + // Existing clients that only read `content[0].text` + `isError` keep working; new clients + // can read `structuredContent` to branch on the category. + // #1581: ツール結果エラーにも JSON-RPC エラーと同じ `category` / `suggestion` / `retry_safe` + // を `result.structuredContent` に載せる。既存の `content[0].text` + `isError` だけを読む + // クライアントは互換のまま、新規クライアントは `structuredContent` でカテゴリ分岐できる。 + private JsonObject CreateToolErrorResponse(bool hasId, JsonNode? id, string message, + string category, string suggestion, bool retrySafe, JsonObject? extraData = null, + IReadOnlyList? similarValues = null) + { + ClearProjectFilterRootDiagnostics(); + var structuredContent = McpErrorEnvelope.BuildData(category, suggestion, retrySafe, AddCorrelationData(extraData)); + AddConfiguredSqliteDiagnostics(structuredContent); + var result = new JsonObject + { + ["content"] = new JsonArray + { + new JsonObject + { + ["type"] = "text", + ["text"] = message + } + }, + ["isError"] = true, + ["structuredContent"] = structuredContent, + }; + if (similarValues != null && similarValues.Count > 0) + { + var similarArray = new JsonArray(); + foreach (var value in similarValues) + similarArray.Add(JsonValue.Create(value)); + result["data"] = new JsonObject + { + ["similar_values"] = similarArray, + }; + } + return CreateSuccessResponse(hasId, id, result); + } + + private static JsonObject CreateToolDefinition(string name, string description, JsonObject inputSchema, + JsonObject? annotations = null) + { + var def = new JsonObject + { + ["name"] = name, + ["description"] = AppendLanguageSupportClause(name, description), + ["inputSchema"] = inputSchema, + ["examples"] = BuildToolExamples(name), + }; + if (annotations != null) + def["annotations"] = annotations; + return def; + } + + private static JsonArray BuildToolExamples(string name) + { + var args = name switch + { + "search" => new JsonObject { ["query"] = "Run", ["lang"] = "csharp", ["limit"] = 5 }, + "definition" => new JsonObject { ["query"] = "App", ["exactName"] = true }, + "references" => new JsonObject { ["query"] = "Run", ["kind"] = "call" }, + "callers" => new JsonObject { ["query"] = "Run", ["rankBy"] = "weighted" }, + "callees" => new JsonObject { ["query"] = "App.Run" }, + "symbols" => new JsonObject { ["query"] = "App", ["kind"] = "class" }, + "files" => new JsonObject { ["query"] = "app.cs", ["lang"] = "csharp" }, + "excerpt" => new JsonObject { ["path"] = "src/app.cs", ["startLine"] = 1, ["endLine"] = 5 }, + "find_in_file" => new JsonObject { ["path"] = "src/app.cs", ["query"] = "Run", ["before"] = 1, ["after"] = 1 }, + "map" => new JsonObject { ["limit"] = 5, ["excludeTests"] = true }, + "analyze_symbol" => new JsonObject { ["query"] = "Run", ["includeBody"] = true }, + "impact_analysis" => new JsonObject { ["query"] = "Run", ["maxHops"] = 2, ["withPaths"] = true }, + "status" => new JsonObject(), + "outline" => new JsonObject { ["path"] = "src/app.cs" }, + "deps" => new JsonObject { ["path"] = "src/", ["reverse"] = false, ["limit"] = 10 }, + "languages" => new JsonObject(), + "validate" => new JsonObject { ["kind"] = "line_too_long" }, + "ping" => new JsonObject(), + "batch_query" => new JsonObject + { + ["queries"] = new JsonArray + { + new JsonObject { ["tool"] = "search", ["arguments"] = new JsonObject { ["query"] = "Run", ["limit"] = 3 } }, + new JsonObject { ["tool"] = "definition", ["arguments"] = new JsonObject { ["query"] = "App", ["limit"] = 3 } }, + }, + }, + "index" => new JsonObject { ["path"] = ".", ["rebuild"] = false }, + "backfill_fold" => new JsonObject { ["dry_run"] = false, ["force"] = false }, + "symbol_hotspots" => new JsonObject { ["lang"] = "csharp", ["limit"] = 10 }, + "unused_symbols" => new JsonObject { ["lang"] = "csharp", ["limit"] = 10 }, + "suggest_improvement" => new JsonObject + { + ["category"] = "output_format", + ["description"] = "The tool response should make truncation easier to detect.", + ["evidencePaths"] = new JsonArray { "src/CodeIndex/Mcp/McpToolHandlers.cs" }, + }, + _ => new JsonObject(), + }; + + return new JsonArray + { + new JsonObject + { + ["request"] = new JsonObject + { + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = name, + ["arguments"] = args, + }, + }, + ["response_excerpt"] = "A successful MCP tool result includes content and, when available, structuredContent.", + }, + }; + } + + private static string AppendLanguageSupportClause(string name, string description) + { + var clause = name switch + { + "references" or "callers" or "callees" or "deps" or "impact_analysis" or "unused_symbols" or "symbol_hotspots" + => $"Language support: Supports graph/reference extraction for: {GraphLanguageList()}. Unsupported `lang` values are reported with graph-support metadata when the tool returns graph-support fields; use `search`, `definition`, `excerpt`, or `files` for non-graph languages.", + "definition" or "symbols" or "outline" or "analyze_symbol" + => $"Language support: Supports symbol extraction for: {SymbolLanguageList()}. Search-only languages can still be indexed and filtered by file tools but may have no symbol rows.", + "search" + => "Language support: Supports indexed file/content filters for every detected language; call `languages` for the full catalog.", + "find_in_file" or "files" or "map" + => $"Language support: Supports indexed file/content filters for every detected language listed by `languages`: {DetectedLanguageList()}. Symbol and graph fields are available only for the languages whose capabilities are advertised by `languages`.", + "excerpt" or "status" or "validate" + => $"Language support: Language-agnostic over indexed files and diagnostics for every detected language listed by `languages`: {DetectedLanguageList()}. This tool does not interpret a `lang` filter.", + "languages" + => "Language support: This is the authoritative language catalog for MCP tools; it lists every detected language plus symbol_extraction, reference_extraction, graph_queries, and capability_gaps fields.", + "index" + => $"Language support: Indexes every detected language listed by `languages`: {DetectedLanguageList()}, then extracts symbols and graph references only where the catalog advertises those capabilities.", + "batch_query" + => "Language support: Language behavior is inherited from each nested read-only tool; consult each returned payload and the `languages` tool for capabilities.", + "backfill_fold" or "ping" or "suggest_improvement" + => "Language support: Language-independent tool; it does not interpret `lang` filters.", + _ => "Language support: See the `languages` tool for detected languages and per-language symbol_extraction / reference_extraction / graph_queries capabilities.", + }; + + return $"{description} {clause}"; + } + + private static string DetectedLanguageList() + => string.Join(", ", FileIndexer.GetDetectedLanguageNames()); + + private static string SymbolLanguageList() + => string.Join(", ", SymbolExtractor.GetSupportedLanguages() + .OrderBy(lang => lang, StringComparer.Ordinal)); + + private static string GraphLanguageList() + => string.Join(", ", ReferenceExtractor.GetSupportedLanguages() + .OrderBy(lang => lang, StringComparer.Ordinal)); + + /// + /// Build MCP tool annotations for a read-only query tool. + /// 読み取り専用クエリツール用のMCPツールアノテーションを構築。 + /// + private static JsonObject ReadOnlyAnnotations() => new() + { + ["readOnlyHint"] = true, + ["destructiveHint"] = false, + ["idempotentHint"] = true, + ["openWorldHint"] = false + }; + + /// + /// Build MCP tool annotations for the index (write) tool. + /// index(書き込み)ツール用のMCPツールアノテーションを構築。 + /// Destructive because --rebuild drops the DB; not idempotent because + /// re-indexing replaces chunks/symbols/references per file. + /// --rebuildでDBを削除するため破壊的。再インデックスはファイルごとに + /// チャンク・シンボル・参照を置き換えるため冪等ではない。 + /// + private static JsonObject IndexAnnotations() => new() + { + ["readOnlyHint"] = false, + ["destructiveHint"] = true, + ["idempotentHint"] = false, + ["openWorldHint"] = false + }; + + /// + /// Build MCP tool annotations for the suggest_improvement tool. + /// suggest_improvementツール用のMCPツールアノテーションを構築。 + /// Not read-only (writes suggestion to disk), not destructive, + /// idempotent (duplicate submissions are safely deduplicated). + /// 読み取り専用ではない(提案をディスクに書き込む)、破壊的ではない、 + /// 冪等(重複送信は安全に排除される)。 + /// + private static JsonObject SuggestionAnnotations() => new() + { + ["readOnlyHint"] = false, + ["destructiveHint"] = false, + ["idempotentHint"] = true, + ["openWorldHint"] = false + }; + +} diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 966efc10c..6c4a84841 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -469,645 +469,4 @@ internal TimeSpan InFlightPostCancelGracePeriod : value; } - - // --- JSON-RPC helpers / JSON-RPCヘルパー --- - - private enum RequestIdValidationError - { - None, - InvalidType, - TooLong, - } - - private static bool TryGetRequestId(JsonObject request, out bool hasId, out JsonNode? id) - => TryGetRequestId(request, out hasId, out id, out _); - - private static bool TryGetRequestId(JsonObject request, out bool hasId, out JsonNode? id, out RequestIdValidationError error) - { - error = RequestIdValidationError.None; - hasId = request.TryGetPropertyValue("id", out id); - if (!hasId) - return true; - - if (id is null) - return true; - - return TrySerializeRequestId(id, out _, out error); - } - - private static bool TrySerializeRequestId(JsonNode? id, out string? serialized, out RequestIdValidationError error) - { - serialized = null; - error = RequestIdValidationError.None; - if (id is null) - return true; - - if (id is not JsonValue value) - { - error = RequestIdValidationError.InvalidType; - return false; - } - - return TrySerializeRequestIdValue(value, out serialized, out error); - } - - private static bool TrySerializeRequestIdValue(JsonValue value, out string? serialized, out RequestIdValidationError error) - { - serialized = null; - error = RequestIdValidationError.None; - JsonValueKind kind; - try - { - kind = value.GetValueKind(); - } - catch - { - error = RequestIdValidationError.InvalidType; - return false; - } - - switch (kind) - { - case JsonValueKind.String: - try - { - var requestId = value.GetValue(); - if (!IsRequestIdWithinBounds(requestId)) - { - error = RequestIdValidationError.TooLong; - return false; - } - - serialized = JsonSerializer.Serialize(requestId); - return true; - } - catch - { - error = RequestIdValidationError.InvalidType; - return false; - } - - case JsonValueKind.Number: - try - { - serialized = value.TryGetValue(out var element) && element.ValueKind == JsonValueKind.Number - ? element.GetRawText() - : value.ToJsonString(); - } - catch - { - error = RequestIdValidationError.InvalidType; - return false; - } - - if (serialized.Length == 0 || !(serialized[0] == '-' || char.IsDigit(serialized[0]))) - { - error = RequestIdValidationError.InvalidType; - serialized = null; - return false; - } - - if (!IsRequestIdWithinBounds(serialized)) - { - error = RequestIdValidationError.TooLong; - serialized = null; - return false; - } - - return true; - - case JsonValueKind.Null: - return true; - - default: - error = RequestIdValidationError.InvalidType; - return false; - } - } - - private static bool IsRequestIdWithinBounds(string value) - => value.Length <= MaxRequestIdCharacterCount - && Encoding.UTF8.GetByteCount(value) <= MaxRequestIdByteLength; - - private static string BuildInvalidRequestIdMessage(RequestIdValidationError error) - => error == RequestIdValidationError.TooLong - ? "Invalid request: id exceeds the request-id length limit" - : "Invalid request: id must be string, number, or null"; - - private static string BuildInvalidRequestIdSuggestion(RequestIdValidationError error) - => error == RequestIdValidationError.TooLong - ? $"JSON-RPC 2.0 `id` must be no more than {MaxRequestIdCharacterCount} characters and {MaxRequestIdByteLength} UTF-8 bytes. Use a compact string or number id." - : "JSON-RPC 2.0 `id` must be a string, integer, or null. Booleans/objects/arrays are not allowed."; - - private static JsonObject? BuildInvalidRequestIdData(RequestIdValidationError error) - => error == RequestIdValidationError.TooLong - ? new JsonObject - { - ["max_request_id_chars"] = MaxRequestIdCharacterCount, - ["max_request_id_bytes"] = MaxRequestIdByteLength, - } - : null; - - private static JsonObject CreateSuccessResponse(JsonNode? id, JsonNode result) - => CreateSuccessResponse(id is not null, id, result); - - private static JsonObject CreateSuccessResponse(bool hasId, JsonNode? id, JsonNode result) - { - AddResponseMeta(result); - var response = new JsonObject - { - ["jsonrpc"] = "2.0", - ["result"] = result - }; - if (hasId) - response["id"] = McpJsonNode.Clone(id); - return response; - } - - private static void AddResponseMeta(JsonNode result) - { - var context = CurrentCorrelationContext.Value; - if (context is null || result is not JsonObject obj) - return; - - var meta = obj["_meta"] as JsonObject ?? new JsonObject(); - meta["correlation_id"] = context.CorrelationId; - if (context.WireRequestId != null) - meta["request_id"] = context.WireRequestId; - obj["_meta"] = meta; - } - - private static JsonObject? AddCorrelationData(JsonObject? extraData) - { - var context = CurrentCorrelationContext.Value; - if (context is null) - return extraData; - - var data = extraData is null ? new JsonObject() : (JsonObject)extraData.DeepClone(); - data["correlation_id"] = context.CorrelationId; - if (context.WireRequestId != null) - data["request_id"] = context.WireRequestId; - return data; - } - - private static JsonObject CreateErrorResponse(JsonNode? id, int code, string message, - string category, string suggestion, bool retrySafe, JsonObject? extraData = null) - => CreateErrorResponse(id is not null, id, code, message, category, suggestion, retrySafe, extraData); - - private static BoundedMcpText BoundToolNameForDisplay(string toolName) - => McpBoundedText.ForDisplay(toolName, McpBoundedText.MaxToolNameChars); - - private static void AddToolDisplayData(JsonObject target, string? toolName) - { - if (toolName is null) - { - target["tool"] = null; - return; - } - - var display = BoundToolNameForDisplay(toolName); - target["tool"] = display.Text; - display.AddMetadata(target, "tool"); - } - - internal static string BuildUnknownToolMessage(string toolName) - => $"Unknown tool: {BoundToolNameForDisplay(toolName).Text}"; - - private static JsonObject BuildUnknownToolData(string toolName) - { - var data = new JsonObject(); - AddToolDisplayData(data, toolName); - return data; - } - - private static JsonObject BuildToolExceptionData(string toolName, string exceptionType) - { - var data = new JsonObject - { - ["exception_type"] = exceptionType, - }; - AddToolDisplayData(data, toolName); - return data; - } - - private static JsonObject CreateUnknownToolErrorResponse(bool hasId, JsonNode? id, string toolName) - => CreateErrorResponse(hasId: hasId, id: id, code: -32602, message: BuildUnknownToolMessage(toolName), - category: McpErrorEnvelope.CategoryToolUnknown, - suggestion: "Call tools/list to enumerate the available tool names for this server. Tool name match is case-sensitive.", - retrySafe: false, - extraData: BuildUnknownToolData(toolName)); - - // Issue #1581: every MCP error response carries a structured `data` envelope - // (`category` / `suggestion` / `retry_safe`) so clients can branch on a stable - // category instead of parsing the human-readable `message`. Category-specific - // extras (e.g. rate-limited's `retry_after_ms`) merge in via `extraData`. - // #1581: すべての MCP エラー応答に `category` / `suggestion` / `retry_safe` を含む - // 構造化 `data` を載せ、クライアントが文字列解析せず分岐できるようにする。カテゴリ - // 固有フィールド(rate-limited の `retry_after_ms` 等)は `extraData` で合流する。 - private static JsonObject CreateErrorResponse(bool hasId, JsonNode? id, int code, string message, - string category, string suggestion, bool retrySafe, JsonObject? extraData = null) - { - var response = new JsonObject - { - ["jsonrpc"] = "2.0", - ["error"] = new JsonObject - { - ["code"] = code, - ["message"] = message, - ["data"] = McpErrorEnvelope.BuildData(category, suggestion, retrySafe, AddCorrelationData(extraData)), - } - }; - if (hasId) - response["id"] = McpJsonNode.Clone(id); - return response; - } - - private static JsonObject CreateCancelledResponse(JsonNode? id) - => CreateErrorResponse(hasId: true, id: id, code: McpErrorEnvelope.CodeRequestCancelled, - message: "Request cancelled", - category: McpErrorEnvelope.CategoryRequestCancelled, - suggestion: "The client cancelled this request before completion. Reissue the call if the work is still needed.", - retrySafe: true); - - /// - /// Create a tool result response (MCP format). - /// ツール結果レスポンスを作成(MCP形式)。 - /// - private JsonObject CreateToolResult( - JsonNode? id, - string text, - JsonNode? structuredContent = null, - string? mimeType = null, - bool enrichStructuredContent = true) - { - mimeType ??= structuredContent is null ? "text/plain" : "application/json"; - var result = new JsonObject - { - ["content"] = new JsonArray - { - new JsonObject - { - ["type"] = "text", - ["mimeType"] = mimeType, - ["text"] = text - } - } - }; - if (structuredContent is JsonObject structuredObject) - { - if (enrichStructuredContent) - EnrichToolStructuredContent(structuredObject); - result["structuredContent"] = structuredContent; - } - else if (structuredContent != null) - { - ClearProjectFilterRootDiagnostics(); - result["structuredContent"] = structuredContent; - } - else - { - ClearProjectFilterRootDiagnostics(); - } - var response = CreateSuccessResponse(true, id, result); - var responseLimit = GetMaxResponseBytes(); - if (TryMeasureJsonUtf8BytesWithinLimit(response, _jsonOptions, responseLimit, out var responseBytes)) - return response; - - return CreateResponseTooLargeError(true, id, responseBytes, responseLimit, actualBytesExact: false); - } - - private void EnrichToolStructuredContent(JsonObject structuredContent) - { - structuredContent.TryAdd("api_version", JsonOutputContract.ApiVersion); - AddProjectFilterRootDiagnostics(structuredContent); - AddConfiguredSqliteDiagnostics(structuredContent); - } - - internal bool TrySerializeJsonNodeWithinByteLimitForTests(JsonNode node, int maxBytes, out string? serialized, out int bytesWritten) - => TrySerializeJsonNodeWithinByteLimit(node, _jsonOptions, maxBytes, captureSerialized: true, out serialized, out bytesWritten); - - private static bool TryMeasureJsonUtf8BytesWithinLimit(JsonNode node, JsonSerializerOptions options, int maxBytes, out int bytesWritten) - => TrySerializeJsonNodeWithinByteLimit(node, options, maxBytes, captureSerialized: false, out _, out bytesWritten); - - private static bool TrySerializeJsonNodeWithinByteLimit(JsonNode node, JsonSerializerOptions options, int maxBytes, bool captureSerialized, out string? serialized, out int bytesWritten) - { - if (maxBytes < 0) - throw new ArgumentOutOfRangeException(nameof(maxBytes), maxBytes, "JSON byte limit must be non-negative."); - - serialized = null; - using var stream = new BoundedJsonUtf8Stream( - maxBytes, - captureSerialized, - bytes => new JsonResponseByteLimitExceededException(bytes)); - var writerOptions = new JsonWriterOptions - { - Encoder = options.Encoder, - Indented = options.WriteIndented, - }; - - try - { - using var writer = new Utf8JsonWriter(stream, writerOptions); - node.WriteTo(writer, options); - writer.Flush(); - bytesWritten = stream.BytesWritten; - serialized = stream.GetCapturedString(); - return true; - } - catch (JsonResponseByteLimitExceededException ex) - { - bytesWritten = ex.BytesWritten; - return false; - } - } - - private sealed class JsonResponseByteLimitExceededException(int bytesWritten) : Exception - { - public int BytesWritten { get; } = bytesWritten; - } - - private JsonObject CreateResponseTooLargeError(bool hasId, JsonNode? id, int responseBytes, int responseLimit, bool actualBytesExact = true) - { - var response = CreateErrorResponse( - hasId: hasId, - id: id, - code: -32603, - message: $"MCP response exceeded the server byte limit ({responseBytes} > {responseLimit}). Narrow the query or lower the result limit.", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Narrow the query, add path/language filters, lower limit, or use countOnly for a summary-first probe.", - retrySafe: false, - extraData: new JsonObject - { - ["reason"] = "response_too_large", - ["limit_bytes"] = responseLimit, - ["actual_bytes"] = responseBytes, - ["actual_bytes_exact"] = actualBytesExact, - }); - AddConfiguredSqliteDiagnostics((JsonObject)response["error"]!["data"]!); - return response; - } - - private static int GetMaxResponseBytes() - => ReadPositiveIntEnvironmentLimit( - MaxResponseBytesEnvVar, - DefaultMaxResponseBytes, - MaxConfiguredResponseBytes, - "MCP response byte limit"); - - private static int ReadPositiveIntEnvironmentLimit(string envVar, int defaultValue, int maximumValue, string description) - { - var raw = global::CodeIndex.EnvironmentAccess.GetProcessEnvironmentVariable(envVar); - if (string.IsNullOrWhiteSpace(raw)) - return defaultValue; - - if (!int.TryParse(raw, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var limit) - || limit <= 0) - { - var displayValue = DiagnosticRedactor.FormatEnvironmentValue(envVar, raw); - CommandErrorWriter.WriteStderr($"[cdidx-mcp] Ignoring invalid {envVar}='{displayValue}'. Expected a positive integer for {description}. Using default {defaultValue.ToString(System.Globalization.CultureInfo.InvariantCulture)}."); - return defaultValue; - } - - if (limit > maximumValue) - { - var displayValue = DiagnosticRedactor.FormatEnvironmentValue(envVar, raw); - CommandErrorWriter.WriteStderr($"[cdidx-mcp] Clamping {envVar}='{displayValue}' to maximum {maximumValue.ToString(System.Globalization.CultureInfo.InvariantCulture)} for {description}."); - return maximumValue; - } - - return limit; - } - - /// - /// Create a tool error response (MCP format with isError flag). - /// Optional attach a structured - /// data.similar_values array to the result so MCP clients can offer - /// recovery alternatives without parsing the human-readable message (#1582). - /// ツールエラーレスポンスを作成(isError フラグ付き MCP 形式)。 - /// を渡すと結果に構造化された - /// data.similar_values 配列を添えるので、MCP クライアントは - /// 人間向けメッセージを解析せずに代替候補を提示できる (#1582)。 - /// - private JsonObject CreateToolErrorResponse(JsonNode? id, string message, - string category, string suggestion, bool retrySafe, JsonObject? extraData = null, - IReadOnlyList? similarValues = null) - => CreateToolErrorResponse(id is not null, id, message, category, suggestion, retrySafe, extraData, similarValues); - - // Backward-compatible overload for tool handlers that return argument-validation - // failures (#1581). These were all "missing parameter / invalid argument" call sites - // before the envelope was introduced, so the default classification is `invalid_argument` - // / retry_safe=false. The optional `similarValues` carries the structured did-you-mean - // candidates for unknown enum values (#1582). Sites that have richer context should - // call the explicit overload. - // 引数バリデーション失敗を返す既存ツールハンドラ向けの互換オーバーロード(#1581)。 - // envelope 導入前の呼び出しは全て「引数不正」系だったため既定カテゴリを `invalid_argument` - // / retry_safe=false とする。任意の `similarValues` は未知 enum 値に対する構造化された - // did-you-mean 候補 (#1582)。より具体的なカテゴリを持てる呼び出し元は明示オーバーロード - // を使う。 - private JsonObject CreateToolErrorResponse(JsonNode? id, string message, - IReadOnlyList? similarValues = null) - => CreateToolErrorResponse(id, message, - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Tool argument validation failed. Inspect the tool's `inputSchema` via tools/list and adjust the call.", - retrySafe: false, - similarValues: similarValues); - - // Issue #1581: tool-result errors mirror the JSON-RPC error envelope by including - // the same `category` / `suggestion` / `retry_safe` triple under `result.structuredContent`. - // Existing clients that only read `content[0].text` + `isError` keep working; new clients - // can read `structuredContent` to branch on the category. - // #1581: ツール結果エラーにも JSON-RPC エラーと同じ `category` / `suggestion` / `retry_safe` - // を `result.structuredContent` に載せる。既存の `content[0].text` + `isError` だけを読む - // クライアントは互換のまま、新規クライアントは `structuredContent` でカテゴリ分岐できる。 - private JsonObject CreateToolErrorResponse(bool hasId, JsonNode? id, string message, - string category, string suggestion, bool retrySafe, JsonObject? extraData = null, - IReadOnlyList? similarValues = null) - { - ClearProjectFilterRootDiagnostics(); - var structuredContent = McpErrorEnvelope.BuildData(category, suggestion, retrySafe, AddCorrelationData(extraData)); - AddConfiguredSqliteDiagnostics(structuredContent); - var result = new JsonObject - { - ["content"] = new JsonArray - { - new JsonObject - { - ["type"] = "text", - ["text"] = message - } - }, - ["isError"] = true, - ["structuredContent"] = structuredContent, - }; - if (similarValues != null && similarValues.Count > 0) - { - var similarArray = new JsonArray(); - foreach (var value in similarValues) - similarArray.Add(JsonValue.Create(value)); - result["data"] = new JsonObject - { - ["similar_values"] = similarArray, - }; - } - return CreateSuccessResponse(hasId, id, result); - } - - private static JsonObject CreateToolDefinition(string name, string description, JsonObject inputSchema, - JsonObject? annotations = null) - { - var def = new JsonObject - { - ["name"] = name, - ["description"] = AppendLanguageSupportClause(name, description), - ["inputSchema"] = inputSchema, - ["examples"] = BuildToolExamples(name), - }; - if (annotations != null) - def["annotations"] = annotations; - return def; - } - - private static JsonArray BuildToolExamples(string name) - { - var args = name switch - { - "search" => new JsonObject { ["query"] = "Run", ["lang"] = "csharp", ["limit"] = 5 }, - "definition" => new JsonObject { ["query"] = "App", ["exactName"] = true }, - "references" => new JsonObject { ["query"] = "Run", ["kind"] = "call" }, - "callers" => new JsonObject { ["query"] = "Run", ["rankBy"] = "weighted" }, - "callees" => new JsonObject { ["query"] = "App.Run" }, - "symbols" => new JsonObject { ["query"] = "App", ["kind"] = "class" }, - "files" => new JsonObject { ["query"] = "app.cs", ["lang"] = "csharp" }, - "excerpt" => new JsonObject { ["path"] = "src/app.cs", ["startLine"] = 1, ["endLine"] = 5 }, - "find_in_file" => new JsonObject { ["path"] = "src/app.cs", ["query"] = "Run", ["before"] = 1, ["after"] = 1 }, - "map" => new JsonObject { ["limit"] = 5, ["excludeTests"] = true }, - "analyze_symbol" => new JsonObject { ["query"] = "Run", ["includeBody"] = true }, - "impact_analysis" => new JsonObject { ["query"] = "Run", ["maxHops"] = 2, ["withPaths"] = true }, - "status" => new JsonObject(), - "outline" => new JsonObject { ["path"] = "src/app.cs" }, - "deps" => new JsonObject { ["path"] = "src/", ["reverse"] = false, ["limit"] = 10 }, - "languages" => new JsonObject(), - "validate" => new JsonObject { ["kind"] = "line_too_long" }, - "ping" => new JsonObject(), - "batch_query" => new JsonObject - { - ["queries"] = new JsonArray - { - new JsonObject { ["tool"] = "search", ["arguments"] = new JsonObject { ["query"] = "Run", ["limit"] = 3 } }, - new JsonObject { ["tool"] = "definition", ["arguments"] = new JsonObject { ["query"] = "App", ["limit"] = 3 } }, - }, - }, - "index" => new JsonObject { ["path"] = ".", ["rebuild"] = false }, - "backfill_fold" => new JsonObject { ["dry_run"] = false, ["force"] = false }, - "symbol_hotspots" => new JsonObject { ["lang"] = "csharp", ["limit"] = 10 }, - "unused_symbols" => new JsonObject { ["lang"] = "csharp", ["limit"] = 10 }, - "suggest_improvement" => new JsonObject - { - ["category"] = "output_format", - ["description"] = "The tool response should make truncation easier to detect.", - ["evidencePaths"] = new JsonArray { "src/CodeIndex/Mcp/McpToolHandlers.cs" }, - }, - _ => new JsonObject(), - }; - - return new JsonArray - { - new JsonObject - { - ["request"] = new JsonObject - { - ["method"] = "tools/call", - ["params"] = new JsonObject - { - ["name"] = name, - ["arguments"] = args, - }, - }, - ["response_excerpt"] = "A successful MCP tool result includes content and, when available, structuredContent.", - }, - }; - } - - private static string AppendLanguageSupportClause(string name, string description) - { - var clause = name switch - { - "references" or "callers" or "callees" or "deps" or "impact_analysis" or "unused_symbols" or "symbol_hotspots" - => $"Language support: Supports graph/reference extraction for: {GraphLanguageList()}. Unsupported `lang` values are reported with graph-support metadata when the tool returns graph-support fields; use `search`, `definition`, `excerpt`, or `files` for non-graph languages.", - "definition" or "symbols" or "outline" or "analyze_symbol" - => $"Language support: Supports symbol extraction for: {SymbolLanguageList()}. Search-only languages can still be indexed and filtered by file tools but may have no symbol rows.", - "search" - => "Language support: Supports indexed file/content filters for every detected language; call `languages` for the full catalog.", - "find_in_file" or "files" or "map" - => $"Language support: Supports indexed file/content filters for every detected language listed by `languages`: {DetectedLanguageList()}. Symbol and graph fields are available only for the languages whose capabilities are advertised by `languages`.", - "excerpt" or "status" or "validate" - => $"Language support: Language-agnostic over indexed files and diagnostics for every detected language listed by `languages`: {DetectedLanguageList()}. This tool does not interpret a `lang` filter.", - "languages" - => "Language support: This is the authoritative language catalog for MCP tools; it lists every detected language plus symbol_extraction, reference_extraction, graph_queries, and capability_gaps fields.", - "index" - => $"Language support: Indexes every detected language listed by `languages`: {DetectedLanguageList()}, then extracts symbols and graph references only where the catalog advertises those capabilities.", - "batch_query" - => "Language support: Language behavior is inherited from each nested read-only tool; consult each returned payload and the `languages` tool for capabilities.", - "backfill_fold" or "ping" or "suggest_improvement" - => "Language support: Language-independent tool; it does not interpret `lang` filters.", - _ => "Language support: See the `languages` tool for detected languages and per-language symbol_extraction / reference_extraction / graph_queries capabilities.", - }; - - return $"{description} {clause}"; - } - - private static string DetectedLanguageList() - => string.Join(", ", FileIndexer.GetDetectedLanguageNames()); - - private static string SymbolLanguageList() - => string.Join(", ", SymbolExtractor.GetSupportedLanguages() - .OrderBy(lang => lang, StringComparer.Ordinal)); - - private static string GraphLanguageList() - => string.Join(", ", ReferenceExtractor.GetSupportedLanguages() - .OrderBy(lang => lang, StringComparer.Ordinal)); - - /// - /// Build MCP tool annotations for a read-only query tool. - /// 読み取り専用クエリツール用のMCPツールアノテーションを構築。 - /// - private static JsonObject ReadOnlyAnnotations() => new() - { - ["readOnlyHint"] = true, - ["destructiveHint"] = false, - ["idempotentHint"] = true, - ["openWorldHint"] = false - }; - - /// - /// Build MCP tool annotations for the index (write) tool. - /// index(書き込み)ツール用のMCPツールアノテーションを構築。 - /// Destructive because --rebuild drops the DB; not idempotent because - /// re-indexing replaces chunks/symbols/references per file. - /// --rebuildでDBを削除するため破壊的。再インデックスはファイルごとに - /// チャンク・シンボル・参照を置き換えるため冪等ではない。 - /// - private static JsonObject IndexAnnotations() => new() - { - ["readOnlyHint"] = false, - ["destructiveHint"] = true, - ["idempotentHint"] = false, - ["openWorldHint"] = false - }; - - /// - /// Build MCP tool annotations for the suggest_improvement tool. - /// suggest_improvementツール用のMCPツールアノテーションを構築。 - /// Not read-only (writes suggestion to disk), not destructive, - /// idempotent (duplicate submissions are safely deduplicated). - /// 読み取り専用ではない(提案をディスクに書き込む)、破壊的ではない、 - /// 冪等(重複送信は安全に排除される)。 - /// - private static JsonObject SuggestionAnnotations() => new() - { - ["readOnlyHint"] = false, - ["destructiveHint"] = false, - ["idempotentHint"] = true, - ["openWorldHint"] = false - }; } From c01af7759518816e62a46952b2c2fed38590f2f2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:33:27 +0900 Subject: [PATCH 010/101] Extract SQL supplemental symbol analysis --- .../SymbolExtractor.SqlSupplemental.cs | 791 ++++++++++++++++++ .../Indexer/Symbols/SymbolExtractor.cs | 777 ----------------- 2 files changed, 791 insertions(+), 777 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.SqlSupplemental.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.SqlSupplemental.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.SqlSupplemental.cs new file mode 100644 index 000000000..b1487567a --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.SqlSupplemental.cs @@ -0,0 +1,791 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + + private static void ExtractSqlCteSymbols( + long fileId, + string content, + string[] lines, + List symbols, + SymbolExtractionState extractionState) + { + if (!LinesContain(lines, "WITH", StringComparison.OrdinalIgnoreCase)) + return; + if (!LinesContain(lines, "AS", StringComparison.OrdinalIgnoreCase)) + return; + + List? lineStarts = null; + foreach (Match match in SqlCteDefinitionRegex.Matches(content)) + { + var nameGroup = match.Groups["name"]; + var name = NormalizeSqlIdentifierSegment(nameGroup.Value); + if (string.IsNullOrWhiteSpace(name)) + continue; + + var currentLineStarts = lineStarts ??= BuildLineStartList(lines); + var lineNumber = GetLineNumberFromOffset(currentLineStarts, nameGroup.Index); + AddSymbolRecord( + symbols, + extractionState, + null, + lineNumber, + new SymbolRecord + { + FileId = fileId, + Kind = "class", + Name = name, + Line = lineNumber, + StartLine = lineNumber, + StartColumn = nameGroup.Index - currentLineStarts[lineNumber - 1], + EndLine = lineNumber, + Signature = lines[lineNumber - 1].Trim(), + }); + } + } + + private static bool LinesContain(IReadOnlyList lines, string value, StringComparison comparison) + { + for (var i = 0; i < lines.Count; i++) + { + if (lines[i].IndexOf(value, comparison) >= 0) + return true; + } + + return false; + } + + private static bool LinesContain(IReadOnlyList lines, char value) + { + for (var i = 0; i < lines.Count; i++) + { + if (lines[i].IndexOf(value) >= 0) + return true; + } + + return false; + } + + private static bool LinesContainAny( + IReadOnlyList lines, + string value1, + string value2, + string value3, + StringComparison comparison) + { + for (var i = 0; i < lines.Count; i++) + { + var line = lines[i]; + if (line.IndexOf(value1, comparison) >= 0 + || line.IndexOf(value2, comparison) >= 0 + || line.IndexOf(value3, comparison) >= 0) + { + return true; + } + } + + return false; + } + + private static bool LinesContainAny( + IReadOnlyList lines, + string value1, + string value2, + string value3, + string value4, + StringComparison comparison) + { + for (var i = 0; i < lines.Count; i++) + { + var line = lines[i]; + if (line.IndexOf(value1, comparison) >= 0 + || line.IndexOf(value2, comparison) >= 0 + || line.IndexOf(value3, comparison) >= 0 + || line.IndexOf(value4, comparison) >= 0) + { + return true; + } + } + + return false; + } + + private static bool LinesContainAny( + IReadOnlyList lines, + char value1, + string value2, + StringComparison comparison) + { + for (var i = 0; i < lines.Count; i++) + { + var line = lines[i]; + if (line.IndexOf(value1) >= 0 + || line.IndexOf(value2, comparison) >= 0) + { + return true; + } + } + + return false; + } + + private static List BuildLineStartList(IReadOnlyList lines) + { + if (lines.Count <= 1) + return [0]; + + var starts = new List(Math.Max(1, lines.Count)) { 0 }; + var offset = 0; + for (var i = 0; i < lines.Count - 1; i++) + { + offset += lines[i].Length + 1; + starts.Add(offset); + } + + return starts; + } + + private static int GetLineNumberFromOffset(List lineStarts, int offset) + { + var index = lineStarts.BinarySearch(offset); + if (index >= 0) + return index + 1; + + return ~index; + } + + private static void ExtractSqlGeneratedColumnSymbols( + long fileId, + string[] lines, + string[] structuralLines, + List symbols, + SymbolExtractionState extractionState) + { + if (!LinesContainAny( + structuralLines, + "GENERATED", + "NEXT VALUE FOR", + " AS ", + StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!TryGetSqlGeneratedColumnContainerMarkers( + structuralLines, + out var hasAlterAdd, + out var hasCreateTable)) + { + return; + } + + var structuralContent = string.Join('\n', structuralLines); + List? lineStarts = null; + if (hasAlterAdd) + { + foreach (Match match in SqlAlterTableAddGeneratedColumnRegex.Matches(structuralContent)) + { + var nameGroup = match.Groups["name"]; + var currentLineStarts = lineStarts ??= BuildLineStartList(structuralLines); + AddSqlGeneratedColumnSymbol( + fileId, + lines, + currentLineStarts, + new GroupProxy(nameGroup.Value, nameGroup.Index), + match.Groups["table"].Value, + symbols, + extractionState); + } + } + + if (!hasCreateTable) + return; + + foreach (Match tableMatch in SqlCreateTableBodyRegex.Matches(structuralContent)) + { + var tableName = tableMatch.Groups["table"].Value; + var bodyGroup = tableMatch.Groups["body"]; + foreach (var column in EnumerateSqlColumnDefinitions(bodyGroup.Value, bodyGroup.Index)) + { + if (!SqlGeneratedColumnDefinitionMarkerRegex.IsMatch(column.Text)) + continue; + + var nameMatch = SqlColumnDefinitionNameRegex.Match(column.Text); + if (!nameMatch.Success) + continue; + + var currentLineStarts = lineStarts ??= BuildLineStartList(structuralLines); + AddSqlGeneratedColumnSymbol( + fileId, + lines, + currentLineStarts, + new GroupProxy(nameMatch.Groups["name"].Value, column.StartIndex + nameMatch.Groups["name"].Index), + tableName, + symbols, + extractionState); + } + } + } + + private static bool TryGetSqlGeneratedColumnContainerMarkers( + IReadOnlyList lines, + out bool hasAlterAdd, + out bool hasCreateTable) + { + var hasAlter = false; + var hasAdd = false; + var hasCreate = false; + var hasTable = false; + + for (var i = 0; i < lines.Count; i++) + { + var line = lines[i]; + hasAlter |= line.IndexOf("ALTER", StringComparison.OrdinalIgnoreCase) >= 0; + hasAdd |= line.IndexOf("ADD", StringComparison.OrdinalIgnoreCase) >= 0; + hasCreate |= line.IndexOf("CREATE", StringComparison.OrdinalIgnoreCase) >= 0; + hasTable |= line.IndexOf("TABLE", StringComparison.OrdinalIgnoreCase) >= 0; + if (hasAlter && hasAdd && hasCreate && hasTable) + break; + } + + hasAlterAdd = hasAlter && hasAdd; + hasCreateTable = hasCreate && hasTable; + return hasAlterAdd || hasCreateTable; + } + + private static void AddSqlGeneratedColumnSymbol( + long fileId, + string[] lines, + List lineStarts, + IGroupLike nameGroup, + string rawTableName, + List symbols, + SymbolExtractionState extractionState) + { + var name = NormalizeSqlIdentifierSegment(nameGroup.Value); + if (string.IsNullOrWhiteSpace(name)) + return; + + var lineNumber = GetLineNumberFromOffset(lineStarts, nameGroup.Index); + AddSymbolRecord( + symbols, + extractionState, + null, + lineNumber, + new SymbolRecord + { + FileId = fileId, + Kind = "property", + SubKind = "generated_column", + Name = name, + Line = lineNumber, + StartLine = lineNumber, + StartColumn = nameGroup.Index - lineStarts[lineNumber - 1], + EndLine = lineNumber, + Signature = lines[lineNumber - 1].Trim(), + ContainerKind = "class", + ContainerName = NormalizeSqlIdentifierSegment(SqlNameResolver.GetLeafName(rawTableName)), + }, + lines[lineNumber - 1]); + } + + private interface IGroupLike + { + string Value { get; } + int Index { get; } + } + + private readonly record struct GroupProxy(string Value, int Index) : IGroupLike; + + private readonly record struct SqlColumnDefinitionSlice(string Text, int StartIndex); + + private static IEnumerable EnumerateSqlColumnDefinitions(string body, int bodyStartIndex) + { + var start = 0; + var depth = 0; + for (var i = 0; i <= body.Length; i++) + { + if (i == body.Length || (body[i] == ',' && depth == 0)) + { + var text = body[start..i].Trim(); + if (text.Length > 0) + yield return new SqlColumnDefinitionSlice(text, bodyStartIndex + start + body[start..i].Length - body[start..i].TrimStart().Length); + start = i + 1; + continue; + } + + if (body[i] == '(') + depth++; + else if (body[i] == ')' && depth > 0) + depth--; + } + } + + private static string NormalizeSqlIdentifierSegment(string value) + { + if (value.Length >= 2 && value[0] == '[' && value[^1] == ']') + return value[1..^1].Replace("]]", "]", StringComparison.Ordinal); + if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') + return value[1..^1].Replace("\"\"", "\"", StringComparison.Ordinal); + if (value.Length >= 2 && value[0] == '`' && value[^1] == '`') + return value[1..^1]; + + return value; + } + + private static void ExtractSqlDefinerSymbols( + long fileId, + string[] lines, + string[] structuralLines, + List symbols, + SymbolExtractionState extractionState) + { + for (var i = 0; i < lines.Length; i++) + { + if (structuralLines[i].IndexOf("DEFINER", StringComparison.OrdinalIgnoreCase) < 0) + continue; + + if (!SqlDefinerMarkerRegex.IsMatch(structuralLines[i])) + continue; + + if (structuralLines[i].IndexOf('@') < 0) + continue; + + var match = SqlDefinerRegex.Match(lines[i]); + if (!match.Success) + continue; + + var user = FirstSuccessfulGroupValue(match, "user1", "user2", "user3"); + var host = FirstSuccessfulGroupValue(match, "host1", "host2", "host3"); + if (string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(host)) + continue; + + var name = $"{user}@{host}"; + var lineNumber = i + 1; + AddSymbolRecord( + symbols, + extractionState, + null, + lineNumber, + new SymbolRecord + { + FileId = fileId, + Kind = "definer", + Name = name, + Line = lineNumber, + StartLine = lineNumber, + StartColumn = match.Index, + EndLine = lineNumber, + Signature = lines[i].Trim(), + }, + lines[i]); + } + } + + private static string[] MaskSqlSyntheticSymbolLines(string[] lines) + { + string[]? masked = null; + var inBlockComment = false; + for (var i = 0; i < lines.Length; i++) + { + var maskedLine = MaskSqlSyntheticSymbolLine(lines[i], ref inBlockComment); + if (masked != null) + { + masked[i] = maskedLine; + continue; + } + + if (ReferenceEquals(maskedLine, lines[i])) + continue; + + masked = (string[])lines.Clone(); + masked[i] = maskedLine; + } + + return masked ?? lines; + } + + private static string MaskSqlSyntheticSymbolLine(string line, ref bool inBlockComment) + { + char[]? chars = null; + + void MaskAt(int index) => + (chars ??= line.ToCharArray())[index] = ' '; + + void MaskToEnd(int start) + { + var masked = chars ??= line.ToCharArray(); + for (var index = start; index < line.Length; index++) + masked[index] = ' '; + } + + var inSingleQuote = false; + for (var i = 0; i < line.Length; i++) + { + if (inBlockComment) + { + if (line[i] == '*' && i + 1 < line.Length && line[i + 1] == '/') + { + MaskAt(i); + MaskAt(i + 1); + i++; + inBlockComment = false; + } + else + { + MaskAt(i); + } + continue; + } + + if (inSingleQuote) + { + if (line[i] == '\'' && i + 1 < line.Length && line[i + 1] == '\'') + { + MaskAt(i); + MaskAt(i + 1); + i++; + continue; + } + + if (line[i] == '\'') + inSingleQuote = false; + MaskAt(i); + continue; + } + + if (line[i] == '-' && i + 1 < line.Length && line[i + 1] == '-') + { + MaskToEnd(i); + break; + } + + if (line[i] == '/' && i + 1 < line.Length && line[i + 1] == '*') + { + MaskAt(i); + MaskAt(i + 1); + i++; + inBlockComment = true; + continue; + } + + if (line[i] == '\'') + { + MaskAt(i); + inSingleQuote = true; + } + } + + return chars is null ? line : new string(chars); + } + + private static void ExtractSqlRoutineResultColumnSymbols( + long fileId, + string[] lines, + string[] structuralLines, + List symbols, + SymbolExtractionState extractionState) + { + for (var i = 0; i < lines.Length; i++) + { + if (structuralLines[i].IndexOf("CREATE", StringComparison.OrdinalIgnoreCase) < 0) + continue; + + if (!SqlCreateRoutineHeaderRegex.IsMatch(structuralLines[i])) + continue; + + var headerEnd = FindSqlRoutineHeaderEndLine(structuralLines, i); + var header = LineRangeText.Join(structuralLines, i, headerEnd); + var owner = FindSqlRoutineOwnerSymbol(symbols, i + 1, headerEnd + 1); + var ownerName = owner?.Name; + var ownerBodyStart = owner?.BodyStartLine; + var ownerBodyEnd = owner?.BodyEndLine; + var lineNumber = i + 1; + + if (header.Contains("RETURNS", StringComparison.OrdinalIgnoreCase) + && header.Contains("TABLE", StringComparison.OrdinalIgnoreCase)) + { + foreach (var columns in EnumerateSqlReturnsTableColumnLists(header)) + { + foreach (var column in EnumerateSqlColumnDefinitions(columns)) + AddSqlRoutineFieldSymbol(fileId, lines, symbols, extractionState, lineNumber, column.Name, column.Type, ownerName, ownerBodyStart, ownerBodyEnd); + } + } + + var parameterList = ExtractSqlRoutineParameterList(header); + if (parameterList != null + && parameterList.Contains("OUT", StringComparison.OrdinalIgnoreCase)) + { + foreach (Match match in SqlOutParameterRegex.Matches(parameterList)) + { + var rawName = match.Groups["name"].Value; + var name = NormalizeSqlSymbolSegment(rawName); + if (name.Length > 0) + AddSqlRoutineFieldSymbol(fileId, lines, symbols, extractionState, lineNumber, name, null, ownerName, ownerBodyStart, ownerBodyEnd); + } + } + } + } + + private static SymbolRecord? FindSqlRoutineOwnerSymbol(List symbols, int startLine, int endLine) + { + SymbolRecord? owner = null; + foreach (var symbol in symbols) + { + if (symbol.Kind != "function" || symbol.Line < startLine || symbol.Line > endLine) + continue; + + if (owner == null || symbol.Line < owner.Line) + owner = symbol; + } + + return owner; + } + + private static int FindSqlRoutineHeaderEndLine(string[] lines, int startLineIndex) + { + for (var i = startLineIndex; i < lines.Length; i++) + { + var line = lines[i]; + if (line.Contains(" AS ", StringComparison.OrdinalIgnoreCase) + || line.Contains(" LANGUAGE ", StringComparison.OrdinalIgnoreCase) + || line.Contains(';')) + { + return i; + } + } + + return startLineIndex; + } + + private static string? ExtractSqlRoutineParameterList(string header) + { + var open = header.IndexOf('('); + if (open < 0) + return null; + + var depth = 0; + for (var i = open; i < header.Length; i++) + { + if (header[i] == '(') + depth++; + else if (header[i] == ')') + { + depth--; + if (depth == 0) + return header[(open + 1)..i]; + } + } + + return null; + } + + private static IEnumerable<(string Name, string? Type)> EnumerateSqlColumnDefinitions(string columns) + { + foreach (var part in SplitSqlTopLevelComma(columns)) + { + var trimmed = part.Trim(); + if (trimmed.Length == 0) + continue; + + var nameEnd = ScanSqlIdentifierEnd(trimmed, 0); + if (nameEnd <= 0) + continue; + + var rawName = trimmed[..nameEnd]; + var name = NormalizeSqlSymbolSegment(rawName); + if (name.Length == 0) + continue; + + var type = trimmed[nameEnd..].Trim(); + yield return (name, type.Length == 0 ? null : type); + } + } + + private static IEnumerable EnumerateSqlReturnsTableColumnLists(string header) + { + foreach (Match marker in SqlReturnsTableMarkerRegex.Matches(header)) + { + var openParen = marker.Index + marker.Length - 1; + if (TryFindSqlClosingParen(header, openParen, out var closeParen)) + yield return header[(openParen + 1)..closeParen]; + } + } + + private static bool TryFindSqlClosingParen(string value, int openParen, out int closeParen) + { + closeParen = -1; + var depth = 0; + char quote = '\0'; + for (var i = openParen; i < value.Length; i++) + { + var current = value[i]; + if (quote != '\0') + { + var quoteEnd = quote == '[' ? ']' : quote; + if (current != quoteEnd) + continue; + + if (i + 1 < value.Length && value[i + 1] == quoteEnd) + { + i++; + continue; + } + + quote = '\0'; + continue; + } + + if (current is '\'' or '"' or '`' or '[') + { + quote = current; + continue; + } + + if (current == '(') + { + depth++; + } + else if (current == ')' && --depth == 0) + { + closeParen = i; + return true; + } + } + + return false; + } + + private static IEnumerable SplitSqlTopLevelComma(string value) + { + var start = 0; + var depth = 0; + for (var i = 0; i < value.Length; i++) + { + if (value[i] == '(') + depth++; + else if (value[i] == ')' && depth > 0) + depth--; + else if (value[i] == ',' && depth == 0) + { + yield return value[start..i]; + start = i + 1; + } + } + + yield return value[start..]; + } + + private static int ScanSqlIdentifierEnd(string value, int start) + { + if (start >= value.Length) + return start; + + if (value[start] == '[') + { + for (var i = start + 1; i < value.Length; i++) + { + if (value[i] == ']' && (i + 1 >= value.Length || value[i + 1] != ']')) + return i + 1; + if (value[i] == ']' && i + 1 < value.Length && value[i + 1] == ']') + i++; + } + } + else if (value[start] is '"' or '`') + { + var quote = value[start]; + for (var i = start + 1; i < value.Length; i++) + { + if (value[i] == quote && (i + 1 >= value.Length || value[i + 1] != quote)) + return i + 1; + if (value[i] == quote && i + 1 < value.Length && value[i + 1] == quote) + i++; + } + } + else + { + var i = start; + while (i < value.Length + && (char.IsLetterOrDigit(value[i]) || value[i] == '_' || value[i] == '$')) + { + i++; + } + + return i; + } + + return value.Length; + } + + private static string NormalizeSqlSymbolSegment(string rawName) + { + var normalized = SqlSymbolNameNormalizer.Normalize(rawName).Trim(); + if (normalized.Length >= 2 + && ((normalized[0] == '[' && normalized[^1] == ']') + || (normalized[0] == '`' && normalized[^1] == '`') + || (normalized[0] == '"' && normalized[^1] == '"'))) + { + normalized = normalized[1..^1]; + } + + return normalized + .Replace("]]", "]", StringComparison.Ordinal) + .Replace("\"\"", "\"", StringComparison.Ordinal) + .Replace("``", "`", StringComparison.Ordinal); + } + + private static void AddSqlRoutineFieldSymbol( + long fileId, + string[] lines, + List symbols, + SymbolExtractionState extractionState, + int lineNumber, + string name, + string? returnType, + string? ownerName, + int? ownerBodyStart, + int? ownerBodyEnd) + { + AddSymbolRecord( + symbols, + extractionState, + null, + lineNumber, + new SymbolRecord + { + FileId = fileId, + Kind = "field", + Name = name, + Line = lineNumber, + StartLine = lineNumber, + EndLine = ownerBodyEnd ?? lineNumber, + BodyStartLine = ownerBodyStart, + BodyEndLine = ownerBodyEnd, + Signature = lines[lineNumber - 1].Trim(), + ContainerKind = ownerName == null ? null : "function", + ContainerName = ownerName, + ReturnType = NormalizeMetadata(returnType), + }, + lines[lineNumber - 1]); + } + + private static string? FirstSuccessfulGroupValue(Match match, params string[] names) + { + foreach (var name in names) + { + var group = match.Groups[name]; + if (group.Success) + return group.Value; + } + + return null; + } + +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 5e699f779..31765df21 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -3027,783 +3027,6 @@ private static void ClassifyJavaScriptTypeScriptReactHooks(List sy } } - private static void ExtractSqlCteSymbols( - long fileId, - string content, - string[] lines, - List symbols, - SymbolExtractionState extractionState) - { - if (!LinesContain(lines, "WITH", StringComparison.OrdinalIgnoreCase)) - return; - if (!LinesContain(lines, "AS", StringComparison.OrdinalIgnoreCase)) - return; - - List? lineStarts = null; - foreach (Match match in SqlCteDefinitionRegex.Matches(content)) - { - var nameGroup = match.Groups["name"]; - var name = NormalizeSqlIdentifierSegment(nameGroup.Value); - if (string.IsNullOrWhiteSpace(name)) - continue; - - var currentLineStarts = lineStarts ??= BuildLineStartList(lines); - var lineNumber = GetLineNumberFromOffset(currentLineStarts, nameGroup.Index); - AddSymbolRecord( - symbols, - extractionState, - null, - lineNumber, - new SymbolRecord - { - FileId = fileId, - Kind = "class", - Name = name, - Line = lineNumber, - StartLine = lineNumber, - StartColumn = nameGroup.Index - currentLineStarts[lineNumber - 1], - EndLine = lineNumber, - Signature = lines[lineNumber - 1].Trim(), - }); - } - } - - private static bool LinesContain(IReadOnlyList lines, string value, StringComparison comparison) - { - for (var i = 0; i < lines.Count; i++) - { - if (lines[i].IndexOf(value, comparison) >= 0) - return true; - } - - return false; - } - - private static bool LinesContain(IReadOnlyList lines, char value) - { - for (var i = 0; i < lines.Count; i++) - { - if (lines[i].IndexOf(value) >= 0) - return true; - } - - return false; - } - - private static bool LinesContainAny( - IReadOnlyList lines, - string value1, - string value2, - string value3, - StringComparison comparison) - { - for (var i = 0; i < lines.Count; i++) - { - var line = lines[i]; - if (line.IndexOf(value1, comparison) >= 0 - || line.IndexOf(value2, comparison) >= 0 - || line.IndexOf(value3, comparison) >= 0) - { - return true; - } - } - - return false; - } - - private static bool LinesContainAny( - IReadOnlyList lines, - string value1, - string value2, - string value3, - string value4, - StringComparison comparison) - { - for (var i = 0; i < lines.Count; i++) - { - var line = lines[i]; - if (line.IndexOf(value1, comparison) >= 0 - || line.IndexOf(value2, comparison) >= 0 - || line.IndexOf(value3, comparison) >= 0 - || line.IndexOf(value4, comparison) >= 0) - { - return true; - } - } - - return false; - } - - private static bool LinesContainAny( - IReadOnlyList lines, - char value1, - string value2, - StringComparison comparison) - { - for (var i = 0; i < lines.Count; i++) - { - var line = lines[i]; - if (line.IndexOf(value1) >= 0 - || line.IndexOf(value2, comparison) >= 0) - { - return true; - } - } - - return false; - } - - private static List BuildLineStartList(IReadOnlyList lines) - { - if (lines.Count <= 1) - return [0]; - - var starts = new List(Math.Max(1, lines.Count)) { 0 }; - var offset = 0; - for (var i = 0; i < lines.Count - 1; i++) - { - offset += lines[i].Length + 1; - starts.Add(offset); - } - - return starts; - } - - private static int GetLineNumberFromOffset(List lineStarts, int offset) - { - var index = lineStarts.BinarySearch(offset); - if (index >= 0) - return index + 1; - - return ~index; - } - - private static void ExtractSqlGeneratedColumnSymbols( - long fileId, - string[] lines, - string[] structuralLines, - List symbols, - SymbolExtractionState extractionState) - { - if (!LinesContainAny( - structuralLines, - "GENERATED", - "NEXT VALUE FOR", - " AS ", - StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (!TryGetSqlGeneratedColumnContainerMarkers( - structuralLines, - out var hasAlterAdd, - out var hasCreateTable)) - { - return; - } - - var structuralContent = string.Join('\n', structuralLines); - List? lineStarts = null; - if (hasAlterAdd) - { - foreach (Match match in SqlAlterTableAddGeneratedColumnRegex.Matches(structuralContent)) - { - var nameGroup = match.Groups["name"]; - var currentLineStarts = lineStarts ??= BuildLineStartList(structuralLines); - AddSqlGeneratedColumnSymbol( - fileId, - lines, - currentLineStarts, - new GroupProxy(nameGroup.Value, nameGroup.Index), - match.Groups["table"].Value, - symbols, - extractionState); - } - } - - if (!hasCreateTable) - return; - - foreach (Match tableMatch in SqlCreateTableBodyRegex.Matches(structuralContent)) - { - var tableName = tableMatch.Groups["table"].Value; - var bodyGroup = tableMatch.Groups["body"]; - foreach (var column in EnumerateSqlColumnDefinitions(bodyGroup.Value, bodyGroup.Index)) - { - if (!SqlGeneratedColumnDefinitionMarkerRegex.IsMatch(column.Text)) - continue; - - var nameMatch = SqlColumnDefinitionNameRegex.Match(column.Text); - if (!nameMatch.Success) - continue; - - var currentLineStarts = lineStarts ??= BuildLineStartList(structuralLines); - AddSqlGeneratedColumnSymbol( - fileId, - lines, - currentLineStarts, - new GroupProxy(nameMatch.Groups["name"].Value, column.StartIndex + nameMatch.Groups["name"].Index), - tableName, - symbols, - extractionState); - } - } - } - - private static bool TryGetSqlGeneratedColumnContainerMarkers( - IReadOnlyList lines, - out bool hasAlterAdd, - out bool hasCreateTable) - { - var hasAlter = false; - var hasAdd = false; - var hasCreate = false; - var hasTable = false; - - for (var i = 0; i < lines.Count; i++) - { - var line = lines[i]; - hasAlter |= line.IndexOf("ALTER", StringComparison.OrdinalIgnoreCase) >= 0; - hasAdd |= line.IndexOf("ADD", StringComparison.OrdinalIgnoreCase) >= 0; - hasCreate |= line.IndexOf("CREATE", StringComparison.OrdinalIgnoreCase) >= 0; - hasTable |= line.IndexOf("TABLE", StringComparison.OrdinalIgnoreCase) >= 0; - if (hasAlter && hasAdd && hasCreate && hasTable) - break; - } - - hasAlterAdd = hasAlter && hasAdd; - hasCreateTable = hasCreate && hasTable; - return hasAlterAdd || hasCreateTable; - } - - private static void AddSqlGeneratedColumnSymbol( - long fileId, - string[] lines, - List lineStarts, - IGroupLike nameGroup, - string rawTableName, - List symbols, - SymbolExtractionState extractionState) - { - var name = NormalizeSqlIdentifierSegment(nameGroup.Value); - if (string.IsNullOrWhiteSpace(name)) - return; - - var lineNumber = GetLineNumberFromOffset(lineStarts, nameGroup.Index); - AddSymbolRecord( - symbols, - extractionState, - null, - lineNumber, - new SymbolRecord - { - FileId = fileId, - Kind = "property", - SubKind = "generated_column", - Name = name, - Line = lineNumber, - StartLine = lineNumber, - StartColumn = nameGroup.Index - lineStarts[lineNumber - 1], - EndLine = lineNumber, - Signature = lines[lineNumber - 1].Trim(), - ContainerKind = "class", - ContainerName = NormalizeSqlIdentifierSegment(SqlNameResolver.GetLeafName(rawTableName)), - }, - lines[lineNumber - 1]); - } - - private interface IGroupLike - { - string Value { get; } - int Index { get; } - } - - private readonly record struct GroupProxy(string Value, int Index) : IGroupLike; - - private readonly record struct SqlColumnDefinitionSlice(string Text, int StartIndex); - - private static IEnumerable EnumerateSqlColumnDefinitions(string body, int bodyStartIndex) - { - var start = 0; - var depth = 0; - for (var i = 0; i <= body.Length; i++) - { - if (i == body.Length || (body[i] == ',' && depth == 0)) - { - var text = body[start..i].Trim(); - if (text.Length > 0) - yield return new SqlColumnDefinitionSlice(text, bodyStartIndex + start + body[start..i].Length - body[start..i].TrimStart().Length); - start = i + 1; - continue; - } - - if (body[i] == '(') - depth++; - else if (body[i] == ')' && depth > 0) - depth--; - } - } - - private static string NormalizeSqlIdentifierSegment(string value) - { - if (value.Length >= 2 && value[0] == '[' && value[^1] == ']') - return value[1..^1].Replace("]]", "]", StringComparison.Ordinal); - if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') - return value[1..^1].Replace("\"\"", "\"", StringComparison.Ordinal); - if (value.Length >= 2 && value[0] == '`' && value[^1] == '`') - return value[1..^1]; - - return value; - } - - private static void ExtractSqlDefinerSymbols( - long fileId, - string[] lines, - string[] structuralLines, - List symbols, - SymbolExtractionState extractionState) - { - for (var i = 0; i < lines.Length; i++) - { - if (structuralLines[i].IndexOf("DEFINER", StringComparison.OrdinalIgnoreCase) < 0) - continue; - - if (!SqlDefinerMarkerRegex.IsMatch(structuralLines[i])) - continue; - - if (structuralLines[i].IndexOf('@') < 0) - continue; - - var match = SqlDefinerRegex.Match(lines[i]); - if (!match.Success) - continue; - - var user = FirstSuccessfulGroupValue(match, "user1", "user2", "user3"); - var host = FirstSuccessfulGroupValue(match, "host1", "host2", "host3"); - if (string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(host)) - continue; - - var name = $"{user}@{host}"; - var lineNumber = i + 1; - AddSymbolRecord( - symbols, - extractionState, - null, - lineNumber, - new SymbolRecord - { - FileId = fileId, - Kind = "definer", - Name = name, - Line = lineNumber, - StartLine = lineNumber, - StartColumn = match.Index, - EndLine = lineNumber, - Signature = lines[i].Trim(), - }, - lines[i]); - } - } - - private static string[] MaskSqlSyntheticSymbolLines(string[] lines) - { - string[]? masked = null; - var inBlockComment = false; - for (var i = 0; i < lines.Length; i++) - { - var maskedLine = MaskSqlSyntheticSymbolLine(lines[i], ref inBlockComment); - if (masked != null) - { - masked[i] = maskedLine; - continue; - } - - if (ReferenceEquals(maskedLine, lines[i])) - continue; - - masked = (string[])lines.Clone(); - masked[i] = maskedLine; - } - - return masked ?? lines; - } - - private static string MaskSqlSyntheticSymbolLine(string line, ref bool inBlockComment) - { - char[]? chars = null; - - void MaskAt(int index) => - (chars ??= line.ToCharArray())[index] = ' '; - - void MaskToEnd(int start) - { - var masked = chars ??= line.ToCharArray(); - for (var index = start; index < line.Length; index++) - masked[index] = ' '; - } - - var inSingleQuote = false; - for (var i = 0; i < line.Length; i++) - { - if (inBlockComment) - { - if (line[i] == '*' && i + 1 < line.Length && line[i + 1] == '/') - { - MaskAt(i); - MaskAt(i + 1); - i++; - inBlockComment = false; - } - else - { - MaskAt(i); - } - continue; - } - - if (inSingleQuote) - { - if (line[i] == '\'' && i + 1 < line.Length && line[i + 1] == '\'') - { - MaskAt(i); - MaskAt(i + 1); - i++; - continue; - } - - if (line[i] == '\'') - inSingleQuote = false; - MaskAt(i); - continue; - } - - if (line[i] == '-' && i + 1 < line.Length && line[i + 1] == '-') - { - MaskToEnd(i); - break; - } - - if (line[i] == '/' && i + 1 < line.Length && line[i + 1] == '*') - { - MaskAt(i); - MaskAt(i + 1); - i++; - inBlockComment = true; - continue; - } - - if (line[i] == '\'') - { - MaskAt(i); - inSingleQuote = true; - } - } - - return chars is null ? line : new string(chars); - } - - private static void ExtractSqlRoutineResultColumnSymbols( - long fileId, - string[] lines, - string[] structuralLines, - List symbols, - SymbolExtractionState extractionState) - { - for (var i = 0; i < lines.Length; i++) - { - if (structuralLines[i].IndexOf("CREATE", StringComparison.OrdinalIgnoreCase) < 0) - continue; - - if (!SqlCreateRoutineHeaderRegex.IsMatch(structuralLines[i])) - continue; - - var headerEnd = FindSqlRoutineHeaderEndLine(structuralLines, i); - var header = LineRangeText.Join(structuralLines, i, headerEnd); - var owner = FindSqlRoutineOwnerSymbol(symbols, i + 1, headerEnd + 1); - var ownerName = owner?.Name; - var ownerBodyStart = owner?.BodyStartLine; - var ownerBodyEnd = owner?.BodyEndLine; - var lineNumber = i + 1; - - if (header.Contains("RETURNS", StringComparison.OrdinalIgnoreCase) - && header.Contains("TABLE", StringComparison.OrdinalIgnoreCase)) - { - foreach (var columns in EnumerateSqlReturnsTableColumnLists(header)) - { - foreach (var column in EnumerateSqlColumnDefinitions(columns)) - AddSqlRoutineFieldSymbol(fileId, lines, symbols, extractionState, lineNumber, column.Name, column.Type, ownerName, ownerBodyStart, ownerBodyEnd); - } - } - - var parameterList = ExtractSqlRoutineParameterList(header); - if (parameterList != null - && parameterList.Contains("OUT", StringComparison.OrdinalIgnoreCase)) - { - foreach (Match match in SqlOutParameterRegex.Matches(parameterList)) - { - var rawName = match.Groups["name"].Value; - var name = NormalizeSqlSymbolSegment(rawName); - if (name.Length > 0) - AddSqlRoutineFieldSymbol(fileId, lines, symbols, extractionState, lineNumber, name, null, ownerName, ownerBodyStart, ownerBodyEnd); - } - } - } - } - - private static SymbolRecord? FindSqlRoutineOwnerSymbol(List symbols, int startLine, int endLine) - { - SymbolRecord? owner = null; - foreach (var symbol in symbols) - { - if (symbol.Kind != "function" || symbol.Line < startLine || symbol.Line > endLine) - continue; - - if (owner == null || symbol.Line < owner.Line) - owner = symbol; - } - - return owner; - } - - private static int FindSqlRoutineHeaderEndLine(string[] lines, int startLineIndex) - { - for (var i = startLineIndex; i < lines.Length; i++) - { - var line = lines[i]; - if (line.Contains(" AS ", StringComparison.OrdinalIgnoreCase) - || line.Contains(" LANGUAGE ", StringComparison.OrdinalIgnoreCase) - || line.Contains(';')) - { - return i; - } - } - - return startLineIndex; - } - - private static string? ExtractSqlRoutineParameterList(string header) - { - var open = header.IndexOf('('); - if (open < 0) - return null; - - var depth = 0; - for (var i = open; i < header.Length; i++) - { - if (header[i] == '(') - depth++; - else if (header[i] == ')') - { - depth--; - if (depth == 0) - return header[(open + 1)..i]; - } - } - - return null; - } - - private static IEnumerable<(string Name, string? Type)> EnumerateSqlColumnDefinitions(string columns) - { - foreach (var part in SplitSqlTopLevelComma(columns)) - { - var trimmed = part.Trim(); - if (trimmed.Length == 0) - continue; - - var nameEnd = ScanSqlIdentifierEnd(trimmed, 0); - if (nameEnd <= 0) - continue; - - var rawName = trimmed[..nameEnd]; - var name = NormalizeSqlSymbolSegment(rawName); - if (name.Length == 0) - continue; - - var type = trimmed[nameEnd..].Trim(); - yield return (name, type.Length == 0 ? null : type); - } - } - - private static IEnumerable EnumerateSqlReturnsTableColumnLists(string header) - { - foreach (Match marker in SqlReturnsTableMarkerRegex.Matches(header)) - { - var openParen = marker.Index + marker.Length - 1; - if (TryFindSqlClosingParen(header, openParen, out var closeParen)) - yield return header[(openParen + 1)..closeParen]; - } - } - - private static bool TryFindSqlClosingParen(string value, int openParen, out int closeParen) - { - closeParen = -1; - var depth = 0; - char quote = '\0'; - for (var i = openParen; i < value.Length; i++) - { - var current = value[i]; - if (quote != '\0') - { - var quoteEnd = quote == '[' ? ']' : quote; - if (current != quoteEnd) - continue; - - if (i + 1 < value.Length && value[i + 1] == quoteEnd) - { - i++; - continue; - } - - quote = '\0'; - continue; - } - - if (current is '\'' or '"' or '`' or '[') - { - quote = current; - continue; - } - - if (current == '(') - { - depth++; - } - else if (current == ')' && --depth == 0) - { - closeParen = i; - return true; - } - } - - return false; - } - - private static IEnumerable SplitSqlTopLevelComma(string value) - { - var start = 0; - var depth = 0; - for (var i = 0; i < value.Length; i++) - { - if (value[i] == '(') - depth++; - else if (value[i] == ')' && depth > 0) - depth--; - else if (value[i] == ',' && depth == 0) - { - yield return value[start..i]; - start = i + 1; - } - } - - yield return value[start..]; - } - - private static int ScanSqlIdentifierEnd(string value, int start) - { - if (start >= value.Length) - return start; - - if (value[start] == '[') - { - for (var i = start + 1; i < value.Length; i++) - { - if (value[i] == ']' && (i + 1 >= value.Length || value[i + 1] != ']')) - return i + 1; - if (value[i] == ']' && i + 1 < value.Length && value[i + 1] == ']') - i++; - } - } - else if (value[start] is '"' or '`') - { - var quote = value[start]; - for (var i = start + 1; i < value.Length; i++) - { - if (value[i] == quote && (i + 1 >= value.Length || value[i + 1] != quote)) - return i + 1; - if (value[i] == quote && i + 1 < value.Length && value[i + 1] == quote) - i++; - } - } - else - { - var i = start; - while (i < value.Length - && (char.IsLetterOrDigit(value[i]) || value[i] == '_' || value[i] == '$')) - { - i++; - } - - return i; - } - - return value.Length; - } - - private static string NormalizeSqlSymbolSegment(string rawName) - { - var normalized = SqlSymbolNameNormalizer.Normalize(rawName).Trim(); - if (normalized.Length >= 2 - && ((normalized[0] == '[' && normalized[^1] == ']') - || (normalized[0] == '`' && normalized[^1] == '`') - || (normalized[0] == '"' && normalized[^1] == '"'))) - { - normalized = normalized[1..^1]; - } - - return normalized - .Replace("]]", "]", StringComparison.Ordinal) - .Replace("\"\"", "\"", StringComparison.Ordinal) - .Replace("``", "`", StringComparison.Ordinal); - } - - private static void AddSqlRoutineFieldSymbol( - long fileId, - string[] lines, - List symbols, - SymbolExtractionState extractionState, - int lineNumber, - string name, - string? returnType, - string? ownerName, - int? ownerBodyStart, - int? ownerBodyEnd) - { - AddSymbolRecord( - symbols, - extractionState, - null, - lineNumber, - new SymbolRecord - { - FileId = fileId, - Kind = "field", - Name = name, - Line = lineNumber, - StartLine = lineNumber, - EndLine = ownerBodyEnd ?? lineNumber, - BodyStartLine = ownerBodyStart, - BodyEndLine = ownerBodyEnd, - Signature = lines[lineNumber - 1].Trim(), - ContainerKind = ownerName == null ? null : "function", - ContainerName = ownerName, - ReturnType = NormalizeMetadata(returnType), - }, - lines[lineNumber - 1]); - } - - private static string? FirstSuccessfulGroupValue(Match match, params string[] names) - { - foreach (var name in names) - { - var group = match.Groups[name]; - if (group.Success) - return group.Value; - } - - return null; - } internal static bool IsJavaScriptTypeScriptReactHookName(string name) => name.Length >= 4 From ce1b038741ff053de5f5ac06dbc008592c71fa60 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:35:45 +0900 Subject: [PATCH 011/101] Group PHP and Swift property supplements --- .../SymbolExtractor.PropertySupplements.cs | 337 ++++++++++++++++++ .../Indexer/Symbols/SymbolExtractor.cs | 323 ----------------- 2 files changed, 337 insertions(+), 323 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.PropertySupplements.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PropertySupplements.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PropertySupplements.cs new file mode 100644 index 000000000..ead870ef1 --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PropertySupplements.cs @@ -0,0 +1,337 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + + private static HashSet BuildSymbolKindNameIdentities(IReadOnlyList symbols) + { + var existing = new HashSet(symbols.Count); + foreach (var symbol in symbols) + existing.Add(new SymbolKindNameIdentity(symbol.Kind, symbol.Name)); + return existing; + } + + private static IReadOnlyList BuildPropertySymbolSnapshot(IReadOnlyList symbols, int lineCount) + { + List? properties = null; + foreach (var symbol in symbols) + { + if (symbol.Kind == "property" + && symbol.Line >= 1 + && symbol.Line <= lineCount) + { + (properties ??= []).Add(symbol); + } + } + + if (properties is null) + return Array.Empty(); + + return properties; + } + + private static void ExtractPhpPropertyHookSupplementalSymbols( + long fileId, + string[] lines, + string[] structuralLines, + List symbols) + { + var properties = BuildPropertySymbolSnapshot(symbols, lines.Length); + if (properties.Count == 0) + return; + + var existing = BuildSymbolLineIdentities(symbols); + + foreach (var property in properties) + { + var lineIndex = property.Line - 1; + var openBraceColumn = structuralLines[lineIndex].IndexOf('{', StringComparison.Ordinal); + if (openBraceColumn < 0) + continue; + + var closeBraceLine = FindBraceRangeEndLine(structuralLines, lineIndex, openBraceColumn); + if (closeBraceLine <= lineIndex) + continue; + + var sawAccessor = false; + for (var accessorLine = lineIndex + 1; accessorLine <= closeBraceLine; accessorLine++) + { + var accessorMatch = PhpPropertyHookAccessorRegex.Match(structuralLines[accessorLine]); + if (!accessorMatch.Success) + continue; + + var accessorName = accessorMatch.Groups["name"].Value; + var symbolName = $"{property.Name}.{accessorName}"; + var identity = new SymbolLineIdentity(fileId, accessorLine + 1, "accessor", symbolName); + if (!existing.Add(identity)) + continue; + + var accessorBodyEndLine = accessorLine; + var accessorNameEnd = accessorMatch.Groups["name"].Index + accessorMatch.Groups["name"].Length; + var accessorOpenBraceColumn = structuralLines[accessorLine].IndexOf('{', accessorNameEnd); + if (accessorOpenBraceColumn >= 0) + { + var accessorCloseBraceLine = FindBraceRangeEndLine(structuralLines, accessorLine, accessorOpenBraceColumn); + if (accessorCloseBraceLine > accessorLine && accessorCloseBraceLine <= closeBraceLine) + accessorBodyEndLine = accessorCloseBraceLine; + } + + sawAccessor = true; + symbols.Add(new SymbolRecord + { + FileId = fileId, + Kind = "accessor", + Name = symbolName, + Line = accessorLine + 1, + StartLine = accessorLine + 1, + StartColumn = accessorMatch.Groups["name"].Index, + EndLine = accessorBodyEndLine + 1, + BodyStartLine = accessorLine + 1, + BodyEndLine = accessorBodyEndLine + 1, + Signature = lines[accessorLine].Trim(), + ContainerKind = "property", + ContainerName = property.Name, + ContainerQualifiedName = property.ContainerQualifiedName, + }); + } + + if (sawAccessor) + { + property.SubKind = CombineSubKinds(property.SubKind, "php_property_hook"); + property.EndLine = Math.Max(property.EndLine, closeBraceLine + 1); + property.BodyStartLine = lineIndex + 1; + property.BodyEndLine = closeBraceLine + 1; + } + } + } + + private static void ExtractSwiftPropertySupplementalSymbols( + long fileId, + string[] lines, + string[] structuralLines, + List symbols) + { + var properties = BuildPropertySymbolSnapshot(symbols, lines.Length); + if (properties.Count == 0) + return; + + var existing = BuildSymbolLineIdentities(symbols); + + foreach (var property in properties) + { + var lineIndex = property.Line - 1; + var propertyLine = lines[lineIndex]; + var propertyStructuralLine = structuralLines[lineIndex]; + if (propertyLine.IndexOf('@', StringComparison.Ordinal) < 0 + && propertyStructuralLine.IndexOf('{') < 0) + { + continue; + } + + var declarationMatch = SwiftPropertyDeclarationRegex.Match(propertyLine); + if (!declarationMatch.Success) + continue; + + var attributes = declarationMatch.Groups["attributes"].Value; + if (HasSwiftPropertyWrapperAttribute(attributes)) + { + property.SubKind = CombineSubKinds(property.SubKind, "swift_wrapped_property"); + AddSwiftProjectedValueSymbol(fileId, lines, symbols, existing, property, propertyLine); + } + + var openBraceLine = lineIndex; + var openBraceColumn = structuralLines[lineIndex].IndexOf('{', declarationMatch.Index + declarationMatch.Length); + if (openBraceColumn < 0) + continue; + + var closeBraceLine = FindBraceRangeEndLine(structuralLines, openBraceLine, openBraceColumn); + if (closeBraceLine < openBraceLine) + continue; + + var sawAccessor = false; + for (var accessorLine = openBraceLine; accessorLine <= closeBraceLine; accessorLine++) + { + var accessorStructuralLine = structuralLines[accessorLine]; + if (!MayContainSwiftAccessorDeclaration(accessorStructuralLine)) + continue; + + foreach (Match accessorMatch in SwiftAccessorDeclarationRegex.Matches(accessorStructuralLine)) + { + if (!IsSwiftTopLevelAccessor(structuralLines, openBraceLine, openBraceColumn, accessorLine, accessorMatch.Index)) + continue; + + var accessorName = accessorMatch.Groups["name"].Value; + var symbolName = $"{property.Name}.{accessorName}"; + var identity = new SymbolLineIdentity(fileId, accessorLine + 1, "accessor", symbolName); + if (!existing.Add(identity)) + continue; + + sawAccessor = true; + symbols.Add(new SymbolRecord + { + FileId = fileId, + Kind = "accessor", + Name = symbolName, + Line = accessorLine + 1, + StartLine = accessorLine + 1, + StartColumn = accessorMatch.Index, + EndLine = accessorLine + 1, + Signature = lines[accessorLine].Trim(), + ContainerKind = "property", + ContainerName = property.Name, + ContainerQualifiedName = property.ContainerQualifiedName, + }); + } + } + + if (sawAccessor) + { + property.SubKind = CombineSubKinds(property.SubKind, "swift_computed_property"); + property.EndLine = Math.Max(property.EndLine, closeBraceLine + 1); + property.BodyStartLine = openBraceLine + 1; + property.BodyEndLine = closeBraceLine + 1; + } + } + } + + private static bool MayContainSwiftAccessorDeclaration(string line) + => line.IndexOf("get", StringComparison.Ordinal) >= 0 + || line.IndexOf("set", StringComparison.Ordinal) >= 0 + || line.IndexOf("willSet", StringComparison.Ordinal) >= 0 + || line.IndexOf("didSet", StringComparison.Ordinal) >= 0; + + private static void AddSwiftProjectedValueSymbol( + long fileId, + string[] lines, + List symbols, + HashSet existing, + SymbolRecord property, + string propertyLine) + { + var projectedName = "$" + property.Name.Trim('`'); + var identity = new SymbolLineIdentity(fileId, property.Line, "property", projectedName); + if (!existing.Add(identity)) + return; + + symbols.Add(new SymbolRecord + { + FileId = fileId, + Kind = "property", + SubKind = "swift_projected_value", + Name = projectedName, + Line = property.Line, + StartLine = property.StartLine, + StartColumn = property.StartColumn, + EndLine = property.EndLine, + BodyStartLine = property.BodyStartLine, + BodyEndLine = property.BodyEndLine, + Signature = propertyLine.Trim(), + ContainerKind = property.ContainerKind, + ContainerName = property.ContainerName, + ContainerQualifiedName = property.ContainerQualifiedName, + Visibility = property.Visibility, + ReturnType = property.ReturnType, + }); + } + + private static bool HasSwiftPropertyWrapperAttribute(string attributes) + { + if (attributes.IndexOf('@') < 0) + return false; + + foreach (Match match in SwiftPropertyWrapperAttributeRegex.Matches(attributes)) + { + var name = match.Groups["name"].Value; + var shortNameStart = name.LastIndexOf('.') + 1; + var shortName = shortNameStart > 0 ? name[shortNameStart..] : name; + if (!SwiftNonWrapperPropertyAttributes.Contains(shortName)) + return true; + } + + return false; + } + + private static int FindBraceRangeEndLine(string[] structuralLines, int openBraceLine, int openBraceColumn) + { + var depth = 0; + for (var lineIndex = openBraceLine; lineIndex < structuralLines.Length; lineIndex++) + { + var line = structuralLines[lineIndex]; + var column = lineIndex == openBraceLine ? openBraceColumn : 0; + for (; column < line.Length; column++) + { + if (line[column] == '{') + { + depth++; + } + else if (line[column] == '}') + { + depth--; + if (depth == 0) + return lineIndex; + } + } + } + + return -1; + } + + private static bool IsSwiftTopLevelAccessor( + string[] structuralLines, + int openBraceLine, + int openBraceColumn, + int accessorLine, + int accessorColumn) + { + var depth = 0; + for (var lineIndex = openBraceLine; lineIndex <= accessorLine; lineIndex++) + { + var line = structuralLines[lineIndex]; + var startColumn = lineIndex == openBraceLine ? openBraceColumn : 0; + var endColumn = lineIndex == accessorLine ? accessorColumn : line.Length; + for (var column = startColumn; column < endColumn; column++) + { + if (line[column] == '{') + depth++; + else if (line[column] == '}') + depth--; + } + } + + return depth == 1; + } + + private static string CombineSubKinds(string? current, string addition) + { + if (string.IsNullOrWhiteSpace(current)) + return addition; + return ContainsSubKind(current, addition) + ? current + : current + "|" + addition; + } + + private static bool ContainsSubKind(string current, string addition) + { + var remaining = current.AsSpan(); + while (!remaining.IsEmpty) + { + var separatorIndex = remaining.IndexOf('|'); + var candidate = separatorIndex < 0 ? remaining : remaining[..separatorIndex]; + if (!candidate.IsEmpty && candidate.Equals(addition.AsSpan(), StringComparison.Ordinal)) + return true; + if (separatorIndex < 0) + break; + remaining = remaining[(separatorIndex + 1)..]; + } + + return false; + } + +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 31765df21..bf865719d 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -3034,329 +3034,6 @@ internal static bool IsJavaScriptTypeScriptReactHookName(string name) && IsJavaScriptTypeScriptIdentifierStart(name[3]) && char.IsUpper(name[3]); - private static HashSet BuildSymbolKindNameIdentities(IReadOnlyList symbols) - { - var existing = new HashSet(symbols.Count); - foreach (var symbol in symbols) - existing.Add(new SymbolKindNameIdentity(symbol.Kind, symbol.Name)); - return existing; - } - - private static IReadOnlyList BuildPropertySymbolSnapshot(IReadOnlyList symbols, int lineCount) - { - List? properties = null; - foreach (var symbol in symbols) - { - if (symbol.Kind == "property" - && symbol.Line >= 1 - && symbol.Line <= lineCount) - { - (properties ??= []).Add(symbol); - } - } - - if (properties is null) - return Array.Empty(); - - return properties; - } - - private static void ExtractPhpPropertyHookSupplementalSymbols( - long fileId, - string[] lines, - string[] structuralLines, - List symbols) - { - var properties = BuildPropertySymbolSnapshot(symbols, lines.Length); - if (properties.Count == 0) - return; - - var existing = BuildSymbolLineIdentities(symbols); - - foreach (var property in properties) - { - var lineIndex = property.Line - 1; - var openBraceColumn = structuralLines[lineIndex].IndexOf('{', StringComparison.Ordinal); - if (openBraceColumn < 0) - continue; - - var closeBraceLine = FindBraceRangeEndLine(structuralLines, lineIndex, openBraceColumn); - if (closeBraceLine <= lineIndex) - continue; - - var sawAccessor = false; - for (var accessorLine = lineIndex + 1; accessorLine <= closeBraceLine; accessorLine++) - { - var accessorMatch = PhpPropertyHookAccessorRegex.Match(structuralLines[accessorLine]); - if (!accessorMatch.Success) - continue; - - var accessorName = accessorMatch.Groups["name"].Value; - var symbolName = $"{property.Name}.{accessorName}"; - var identity = new SymbolLineIdentity(fileId, accessorLine + 1, "accessor", symbolName); - if (!existing.Add(identity)) - continue; - - var accessorBodyEndLine = accessorLine; - var accessorNameEnd = accessorMatch.Groups["name"].Index + accessorMatch.Groups["name"].Length; - var accessorOpenBraceColumn = structuralLines[accessorLine].IndexOf('{', accessorNameEnd); - if (accessorOpenBraceColumn >= 0) - { - var accessorCloseBraceLine = FindBraceRangeEndLine(structuralLines, accessorLine, accessorOpenBraceColumn); - if (accessorCloseBraceLine > accessorLine && accessorCloseBraceLine <= closeBraceLine) - accessorBodyEndLine = accessorCloseBraceLine; - } - - sawAccessor = true; - symbols.Add(new SymbolRecord - { - FileId = fileId, - Kind = "accessor", - Name = symbolName, - Line = accessorLine + 1, - StartLine = accessorLine + 1, - StartColumn = accessorMatch.Groups["name"].Index, - EndLine = accessorBodyEndLine + 1, - BodyStartLine = accessorLine + 1, - BodyEndLine = accessorBodyEndLine + 1, - Signature = lines[accessorLine].Trim(), - ContainerKind = "property", - ContainerName = property.Name, - ContainerQualifiedName = property.ContainerQualifiedName, - }); - } - - if (sawAccessor) - { - property.SubKind = CombineSubKinds(property.SubKind, "php_property_hook"); - property.EndLine = Math.Max(property.EndLine, closeBraceLine + 1); - property.BodyStartLine = lineIndex + 1; - property.BodyEndLine = closeBraceLine + 1; - } - } - } - - private static void ExtractSwiftPropertySupplementalSymbols( - long fileId, - string[] lines, - string[] structuralLines, - List symbols) - { - var properties = BuildPropertySymbolSnapshot(symbols, lines.Length); - if (properties.Count == 0) - return; - - var existing = BuildSymbolLineIdentities(symbols); - - foreach (var property in properties) - { - var lineIndex = property.Line - 1; - var propertyLine = lines[lineIndex]; - var propertyStructuralLine = structuralLines[lineIndex]; - if (propertyLine.IndexOf('@', StringComparison.Ordinal) < 0 - && propertyStructuralLine.IndexOf('{') < 0) - { - continue; - } - - var declarationMatch = SwiftPropertyDeclarationRegex.Match(propertyLine); - if (!declarationMatch.Success) - continue; - - var attributes = declarationMatch.Groups["attributes"].Value; - if (HasSwiftPropertyWrapperAttribute(attributes)) - { - property.SubKind = CombineSubKinds(property.SubKind, "swift_wrapped_property"); - AddSwiftProjectedValueSymbol(fileId, lines, symbols, existing, property, propertyLine); - } - - var openBraceLine = lineIndex; - var openBraceColumn = structuralLines[lineIndex].IndexOf('{', declarationMatch.Index + declarationMatch.Length); - if (openBraceColumn < 0) - continue; - - var closeBraceLine = FindBraceRangeEndLine(structuralLines, openBraceLine, openBraceColumn); - if (closeBraceLine < openBraceLine) - continue; - - var sawAccessor = false; - for (var accessorLine = openBraceLine; accessorLine <= closeBraceLine; accessorLine++) - { - var accessorStructuralLine = structuralLines[accessorLine]; - if (!MayContainSwiftAccessorDeclaration(accessorStructuralLine)) - continue; - - foreach (Match accessorMatch in SwiftAccessorDeclarationRegex.Matches(accessorStructuralLine)) - { - if (!IsSwiftTopLevelAccessor(structuralLines, openBraceLine, openBraceColumn, accessorLine, accessorMatch.Index)) - continue; - - var accessorName = accessorMatch.Groups["name"].Value; - var symbolName = $"{property.Name}.{accessorName}"; - var identity = new SymbolLineIdentity(fileId, accessorLine + 1, "accessor", symbolName); - if (!existing.Add(identity)) - continue; - - sawAccessor = true; - symbols.Add(new SymbolRecord - { - FileId = fileId, - Kind = "accessor", - Name = symbolName, - Line = accessorLine + 1, - StartLine = accessorLine + 1, - StartColumn = accessorMatch.Index, - EndLine = accessorLine + 1, - Signature = lines[accessorLine].Trim(), - ContainerKind = "property", - ContainerName = property.Name, - ContainerQualifiedName = property.ContainerQualifiedName, - }); - } - } - - if (sawAccessor) - { - property.SubKind = CombineSubKinds(property.SubKind, "swift_computed_property"); - property.EndLine = Math.Max(property.EndLine, closeBraceLine + 1); - property.BodyStartLine = openBraceLine + 1; - property.BodyEndLine = closeBraceLine + 1; - } - } - } - - private static bool MayContainSwiftAccessorDeclaration(string line) - => line.IndexOf("get", StringComparison.Ordinal) >= 0 - || line.IndexOf("set", StringComparison.Ordinal) >= 0 - || line.IndexOf("willSet", StringComparison.Ordinal) >= 0 - || line.IndexOf("didSet", StringComparison.Ordinal) >= 0; - - private static void AddSwiftProjectedValueSymbol( - long fileId, - string[] lines, - List symbols, - HashSet existing, - SymbolRecord property, - string propertyLine) - { - var projectedName = "$" + property.Name.Trim('`'); - var identity = new SymbolLineIdentity(fileId, property.Line, "property", projectedName); - if (!existing.Add(identity)) - return; - - symbols.Add(new SymbolRecord - { - FileId = fileId, - Kind = "property", - SubKind = "swift_projected_value", - Name = projectedName, - Line = property.Line, - StartLine = property.StartLine, - StartColumn = property.StartColumn, - EndLine = property.EndLine, - BodyStartLine = property.BodyStartLine, - BodyEndLine = property.BodyEndLine, - Signature = propertyLine.Trim(), - ContainerKind = property.ContainerKind, - ContainerName = property.ContainerName, - ContainerQualifiedName = property.ContainerQualifiedName, - Visibility = property.Visibility, - ReturnType = property.ReturnType, - }); - } - - private static bool HasSwiftPropertyWrapperAttribute(string attributes) - { - if (attributes.IndexOf('@') < 0) - return false; - - foreach (Match match in SwiftPropertyWrapperAttributeRegex.Matches(attributes)) - { - var name = match.Groups["name"].Value; - var shortNameStart = name.LastIndexOf('.') + 1; - var shortName = shortNameStart > 0 ? name[shortNameStart..] : name; - if (!SwiftNonWrapperPropertyAttributes.Contains(shortName)) - return true; - } - - return false; - } - - private static int FindBraceRangeEndLine(string[] structuralLines, int openBraceLine, int openBraceColumn) - { - var depth = 0; - for (var lineIndex = openBraceLine; lineIndex < structuralLines.Length; lineIndex++) - { - var line = structuralLines[lineIndex]; - var column = lineIndex == openBraceLine ? openBraceColumn : 0; - for (; column < line.Length; column++) - { - if (line[column] == '{') - { - depth++; - } - else if (line[column] == '}') - { - depth--; - if (depth == 0) - return lineIndex; - } - } - } - - return -1; - } - - private static bool IsSwiftTopLevelAccessor( - string[] structuralLines, - int openBraceLine, - int openBraceColumn, - int accessorLine, - int accessorColumn) - { - var depth = 0; - for (var lineIndex = openBraceLine; lineIndex <= accessorLine; lineIndex++) - { - var line = structuralLines[lineIndex]; - var startColumn = lineIndex == openBraceLine ? openBraceColumn : 0; - var endColumn = lineIndex == accessorLine ? accessorColumn : line.Length; - for (var column = startColumn; column < endColumn; column++) - { - if (line[column] == '{') - depth++; - else if (line[column] == '}') - depth--; - } - } - - return depth == 1; - } - - private static string CombineSubKinds(string? current, string addition) - { - if (string.IsNullOrWhiteSpace(current)) - return addition; - return ContainsSubKind(current, addition) - ? current - : current + "|" + addition; - } - - private static bool ContainsSubKind(string current, string addition) - { - var remaining = current.AsSpan(); - while (!remaining.IsEmpty) - { - var separatorIndex = remaining.IndexOf('|'); - var candidate = separatorIndex < 0 ? remaining : remaining[..separatorIndex]; - if (!candidate.IsEmpty && candidate.Equals(addition.AsSpan(), StringComparison.Ordinal)) - return true; - if (separatorIndex < 0) - break; - remaining = remaining[(separatorIndex + 1)..]; - } - - return false; - } private static void ExtractCppFriendDeclarationSymbols( long fileId, From 1c74e963cb8265df1ebe6540d69b514d4920e194 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:40:23 +0900 Subject: [PATCH 012/101] Group C++ and C# declaration metadata --- .../SymbolExtractor.DeclarationMetadata.cs | 226 ++++++++++++++++++ .../Indexer/Symbols/SymbolExtractor.cs | 218 ----------------- 2 files changed, 226 insertions(+), 218 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs new file mode 100644 index 000000000..b8465afee --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs @@ -0,0 +1,226 @@ +using System.Text.RegularExpressions; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private static void ExtractCppFriendDeclarationSymbols( + long fileId, + string[] lines, + List symbols, + SymbolExtractionState extractionState) + { + if (!LinesContain(lines, "friend", StringComparison.Ordinal)) + return; + + var declared = BuildSymbolKindNameIdentities(symbols); + var inBlockComment = false; + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (!inBlockComment + && line.IndexOf("friend", StringComparison.Ordinal) < 0 + && line.IndexOf('/') < 0) + { + continue; + } + + var matchLine = MaskCppFriendDeclarationLine(line, ref inBlockComment); + if (matchLine.IndexOf("friend", StringComparison.Ordinal) < 0) + continue; + + var lineNumber = i + 1; + + foreach (Match match in CppFriendTypeDeclarationRegex.Matches(matchLine)) + { + var kind = NormalizeCppFriendTypeKind(match.Groups["kind"].Value); + var group = match.Groups["name"]; + var name = LastCppDeclarationSegment(group.Value); + AddCppFriendDeclarationSymbol(fileId, symbols, extractionState, declared, kind, name, lineNumber, group.Index, line); + } + + foreach (Match match in CppFriendFunctionDeclarationRegex.Matches(matchLine)) + { + var group = match.Groups["name"]; + var name = LastCppDeclarationSegment(group.Value); + AddCppFriendDeclarationSymbol(fileId, symbols, extractionState, declared, "function", name, lineNumber, group.Index, line); + } + } + } + + private static string MaskCppFriendDeclarationLine(string line, ref bool inBlockComment) + { + char[]? chars = null; + + void MaskAt(int index) => + (chars ??= line.ToCharArray())[index] = ' '; + + void MaskToEnd(int start) + { + var masked = chars ??= line.ToCharArray(); + for (var index = start; index < line.Length; index++) + masked[index] = ' '; + } + + for (var cursor = 0; cursor < line.Length; cursor++) + { + if (inBlockComment) + { + MaskAt(cursor); + if (cursor + 1 < line.Length && line[cursor] == '*' && line[cursor + 1] == '/') + { + MaskAt(++cursor); + inBlockComment = false; + } + + continue; + } + + if (cursor + 1 < line.Length && line[cursor] == '/' && line[cursor + 1] == '/') + { + MaskToEnd(cursor); + break; + } + + if (cursor + 1 < line.Length && line[cursor] == '/' && line[cursor + 1] == '*') + { + MaskAt(cursor++); + MaskAt(cursor); + inBlockComment = true; + continue; + } + + if (line[cursor] is '"' or '\'') + { + var quote = line[cursor]; + MaskAt(cursor++); + while (cursor < line.Length) + { + if (line[cursor] == '\\' && cursor + 1 < line.Length) + { + MaskAt(cursor++); + MaskAt(cursor); + cursor++; + continue; + } + + var closes = line[cursor] == quote; + MaskAt(cursor++); + if (closes) + break; + } + + cursor--; + } + } + + return chars is null ? line : new string(chars); + } + + private static bool IsCSharpTestMethod(string[] lines, int declarationLineIndex) + { + var scannedAttributeLine = false; + for (var lineIndex = declarationLineIndex; lineIndex >= 0; lineIndex--) + { + var trimmed = lines[lineIndex].TrimStart(); + if (trimmed.Length == 0) + return false; + + if (!trimmed.StartsWith('[')) + { + if (lineIndex == declarationLineIndex && !scannedAttributeLine) + continue; + + return false; + } + + scannedAttributeLine = true; + if (CSharpLineHasTestMethodAttribute(trimmed)) + return true; + + var remainderIndex = trimmed.LastIndexOf(']'); + if (remainderIndex < 0) + return false; + + var remainder = trimmed[(remainderIndex + 1)..].TrimStart(); + if (remainder.Length > 0) + return false; + } + + return false; + } + + private static bool CSharpLineHasTestMethodAttribute(string trimmedLine) + { + var cursor = 0; + while (cursor < trimmedLine.Length && trimmedLine[cursor] == '[') + { + var closeIndex = trimmedLine.IndexOf(']', cursor + 1); + if (closeIndex < 0) + return false; + + var content = trimmedLine[(cursor + 1)..closeIndex]; + if (CSharpTestMethodAttributeRegex.IsMatch(content)) + return true; + + cursor = closeIndex + 1; + while (cursor < trimmedLine.Length && char.IsWhiteSpace(trimmedLine[cursor])) + cursor++; + } + + return false; + } + + private static void AddCppFriendDeclarationSymbol( + long fileId, + List symbols, + SymbolExtractionState extractionState, + HashSet declared, + string kind, + string name, + int lineNumber, + int startColumn, + string line) + { + if (name.Length == 0 || !declared.Add(new SymbolKindNameIdentity(kind, name))) + return; + + AddSymbolRecord( + symbols, + extractionState, + cssSeenSymbols: null, + lineNumber, + new SymbolRecord + { + FileId = fileId, + Kind = kind, + Name = name, + Line = lineNumber, + StartLine = lineNumber, + StartColumn = startColumn, + EndLine = lineNumber, + Signature = line.Trim(), + }, + line); + } + + private static string NormalizeCppFriendTypeKind(string kind) + => kind.StartsWith("enum", StringComparison.Ordinal) ? "enum" : kind; + + private static string LastCppDeclarationSegment(string value) + { + var text = value.Trim(); + var qualifierIndex = text.LastIndexOf("::", StringComparison.Ordinal); + var leaf = qualifierIndex >= 0 ? text.AsSpan(qualifierIndex + 2).Trim() : text.AsSpan(); + if (!leaf.StartsWith("operator".AsSpan(), StringComparison.Ordinal)) + { + var genericIndex = text.IndexOf('<'); + if (genericIndex >= 0) + text = text[..genericIndex].TrimEnd(); + } + + return qualifierIndex >= 0 ? text[(qualifierIndex + 2)..].Trim() : text; + } +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index bf865719d..578e9780e 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -3035,224 +3035,6 @@ internal static bool IsJavaScriptTypeScriptReactHookName(string name) && char.IsUpper(name[3]); - private static void ExtractCppFriendDeclarationSymbols( - long fileId, - string[] lines, - List symbols, - SymbolExtractionState extractionState) - { - if (!LinesContain(lines, "friend", StringComparison.Ordinal)) - return; - - var declared = BuildSymbolKindNameIdentities(symbols); - var inBlockComment = false; - - for (var i = 0; i < lines.Length; i++) - { - var line = lines[i]; - if (!inBlockComment - && line.IndexOf("friend", StringComparison.Ordinal) < 0 - && line.IndexOf('/') < 0) - { - continue; - } - - var matchLine = MaskCppFriendDeclarationLine(line, ref inBlockComment); - if (matchLine.IndexOf("friend", StringComparison.Ordinal) < 0) - continue; - - var lineNumber = i + 1; - - foreach (Match match in CppFriendTypeDeclarationRegex.Matches(matchLine)) - { - var kind = NormalizeCppFriendTypeKind(match.Groups["kind"].Value); - var group = match.Groups["name"]; - var name = LastCppDeclarationSegment(group.Value); - AddCppFriendDeclarationSymbol(fileId, symbols, extractionState, declared, kind, name, lineNumber, group.Index, line); - } - - foreach (Match match in CppFriendFunctionDeclarationRegex.Matches(matchLine)) - { - var group = match.Groups["name"]; - var name = LastCppDeclarationSegment(group.Value); - AddCppFriendDeclarationSymbol(fileId, symbols, extractionState, declared, "function", name, lineNumber, group.Index, line); - } - } - } - - private static string MaskCppFriendDeclarationLine(string line, ref bool inBlockComment) - { - char[]? chars = null; - - void MaskAt(int index) => - (chars ??= line.ToCharArray())[index] = ' '; - - void MaskToEnd(int start) - { - var masked = chars ??= line.ToCharArray(); - for (var index = start; index < line.Length; index++) - masked[index] = ' '; - } - - for (var cursor = 0; cursor < line.Length; cursor++) - { - if (inBlockComment) - { - MaskAt(cursor); - if (cursor + 1 < line.Length && line[cursor] == '*' && line[cursor + 1] == '/') - { - MaskAt(++cursor); - inBlockComment = false; - } - - continue; - } - - if (cursor + 1 < line.Length && line[cursor] == '/' && line[cursor + 1] == '/') - { - MaskToEnd(cursor); - break; - } - - if (cursor + 1 < line.Length && line[cursor] == '/' && line[cursor + 1] == '*') - { - MaskAt(cursor++); - MaskAt(cursor); - inBlockComment = true; - continue; - } - - if (line[cursor] is '"' or '\'') - { - var quote = line[cursor]; - MaskAt(cursor++); - while (cursor < line.Length) - { - if (line[cursor] == '\\' && cursor + 1 < line.Length) - { - MaskAt(cursor++); - MaskAt(cursor); - cursor++; - continue; - } - - var closes = line[cursor] == quote; - MaskAt(cursor++); - if (closes) - break; - } - - cursor--; - } - } - - return chars is null ? line : new string(chars); - } - - private static bool IsCSharpTestMethod(string[] lines, int declarationLineIndex) - { - var scannedAttributeLine = false; - for (var lineIndex = declarationLineIndex; lineIndex >= 0; lineIndex--) - { - var trimmed = lines[lineIndex].TrimStart(); - if (trimmed.Length == 0) - return false; - - if (!trimmed.StartsWith('[')) - { - if (lineIndex == declarationLineIndex && !scannedAttributeLine) - continue; - - return false; - } - - scannedAttributeLine = true; - if (CSharpLineHasTestMethodAttribute(trimmed)) - return true; - - var remainderIndex = trimmed.LastIndexOf(']'); - if (remainderIndex < 0) - return false; - - var remainder = trimmed[(remainderIndex + 1)..].TrimStart(); - if (remainder.Length > 0) - return false; - } - - return false; - } - - private static bool CSharpLineHasTestMethodAttribute(string trimmedLine) - { - var cursor = 0; - while (cursor < trimmedLine.Length && trimmedLine[cursor] == '[') - { - var closeIndex = trimmedLine.IndexOf(']', cursor + 1); - if (closeIndex < 0) - return false; - - var content = trimmedLine[(cursor + 1)..closeIndex]; - if (CSharpTestMethodAttributeRegex.IsMatch(content)) - return true; - - cursor = closeIndex + 1; - while (cursor < trimmedLine.Length && char.IsWhiteSpace(trimmedLine[cursor])) - cursor++; - } - - return false; - } - - private static void AddCppFriendDeclarationSymbol( - long fileId, - List symbols, - SymbolExtractionState extractionState, - HashSet declared, - string kind, - string name, - int lineNumber, - int startColumn, - string line) - { - if (name.Length == 0 || !declared.Add(new SymbolKindNameIdentity(kind, name))) - return; - - AddSymbolRecord( - symbols, - extractionState, - cssSeenSymbols: null, - lineNumber, - new SymbolRecord - { - FileId = fileId, - Kind = kind, - Name = name, - Line = lineNumber, - StartLine = lineNumber, - StartColumn = startColumn, - EndLine = lineNumber, - Signature = line.Trim(), - }, - line); - } - - private static string NormalizeCppFriendTypeKind(string kind) - => kind.StartsWith("enum", StringComparison.Ordinal) ? "enum" : kind; - - private static string LastCppDeclarationSegment(string value) - { - var text = value.Trim(); - var qualifierIndex = text.LastIndexOf("::", StringComparison.Ordinal); - var leaf = qualifierIndex >= 0 ? text.AsSpan(qualifierIndex + 2).Trim() : text.AsSpan(); - if (!leaf.StartsWith("operator".AsSpan(), StringComparison.Ordinal)) - { - var genericIndex = text.IndexOf('<'); - if (genericIndex >= 0) - text = text[..genericIndex].TrimEnd(); - } - - return qualifierIndex >= 0 ? text[(qualifierIndex + 2)..].Trim() : text; - } private static int FindFirstNonWhitespaceColumn(string text) From 7024db7da27a8bac1f397c3a7b8563c8a150cc34 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:42:41 +0900 Subject: [PATCH 013/101] Extract shell function range scanning --- .../Symbols/SymbolExtractor.ShellRanges.cs | 146 ++++++++++++++++++ .../Indexer/Symbols/SymbolExtractor.cs | 141 ----------------- 2 files changed, 146 insertions(+), 141 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.ShellRanges.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ShellRanges.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ShellRanges.cs new file mode 100644 index 000000000..dd97de22d --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ShellRanges.cs @@ -0,0 +1,146 @@ +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private static (int EndLine, int? BodyStartLine, int? BodyEndLine) FindShellFunctionRange(string[] lines, int startIndex, int startColumn) + { + var depth = 0; + var opened = false; + int? bodyStartLine = null; + var inSingleQuote = false; + var inDoubleQuote = false; + + for (var i = startIndex; i < lines.Length; i++) + { + var scanLine = i == startIndex && startColumn > 0 && startColumn < lines[i].Length + ? lines[i][startColumn..] + : i == startIndex && startColumn >= lines[i].Length + ? string.Empty + : lines[i]; + + var closeColumn = ScanShellBraceLine( + scanLine, + ref depth, + ref opened, + ref bodyStartLine, + i + 1, + ref inSingleQuote, + ref inDoubleQuote); + if (closeColumn >= 0) + return (i + 1, bodyStartLine, i + 1); + } + + if (!opened) + return (startIndex + 1, null, null); + + var boundedEndLine = bodyStartLine.HasValue + ? Math.Max(startIndex + 1, bodyStartLine.Value) + : startIndex + 1; + return (boundedEndLine, bodyStartLine, boundedEndLine); + } + + private static int FindShellSameLineBraceEndColumn(string line, int startColumn) + { + var depth = 0; + var opened = false; + int? bodyStartLine = null; + var inSingleQuote = false; + var inDoubleQuote = false; + return ScanShellBraceLine( + startColumn > 0 && startColumn < line.Length + ? line[startColumn..] + : startColumn >= line.Length + ? string.Empty + : line, + ref depth, + ref opened, + ref bodyStartLine, + 1, + ref inSingleQuote, + ref inDoubleQuote) is var relativeCloseColumn && relativeCloseColumn >= 0 + ? startColumn + relativeCloseColumn + : -1; + } + + private static int ScanShellBraceLine( + string line, + ref int depth, + ref bool opened, + ref int? bodyStartLine, + int currentLine, + ref bool inSingleQuote, + ref bool inDoubleQuote) + { + for (var i = 0; i < line.Length; i++) + { + var c = line[i]; + if (inSingleQuote) + { + if (c == '\'') + inSingleQuote = false; + continue; + } + + if (inDoubleQuote) + { + if (c == '\\' && i + 1 < line.Length) + { + i++; + continue; + } + + if (c == '"') + inDoubleQuote = false; + continue; + } + + if (c == '\\' && i + 1 < line.Length) + { + i++; + continue; + } + + if (c == '\'') + { + inSingleQuote = true; + continue; + } + + if (c == '"') + { + inDoubleQuote = true; + continue; + } + + if (c == '#' && IsShellCommentStart(line, i)) + break; + + if (c == '{') + { + depth++; + if (!opened) + { + opened = true; + bodyStartLine = currentLine; + } + } + else if (c == '}' && opened) + { + depth--; + if (depth <= 0) + return i; + } + } + + return -1; + } + + private static bool IsShellCommentStart(string line, int index) + { + if (index == 0) + return true; + + var previous = line[index - 1]; + return char.IsWhiteSpace(previous) || previous is ';' or '|' or '&' or '(' or '{'; + } +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 578e9780e..18e5641d8 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -3581,147 +3581,6 @@ private static (int EndLine, int? BodyStartLine, int? BodyEndLine) ResolveRange( }; } - private static (int EndLine, int? BodyStartLine, int? BodyEndLine) FindShellFunctionRange(string[] lines, int startIndex, int startColumn) - { - var depth = 0; - var opened = false; - int? bodyStartLine = null; - var inSingleQuote = false; - var inDoubleQuote = false; - - for (var i = startIndex; i < lines.Length; i++) - { - var scanLine = i == startIndex && startColumn > 0 && startColumn < lines[i].Length - ? lines[i][startColumn..] - : i == startIndex && startColumn >= lines[i].Length - ? string.Empty - : lines[i]; - - var closeColumn = ScanShellBraceLine( - scanLine, - ref depth, - ref opened, - ref bodyStartLine, - i + 1, - ref inSingleQuote, - ref inDoubleQuote); - if (closeColumn >= 0) - return (i + 1, bodyStartLine, i + 1); - } - - if (!opened) - return (startIndex + 1, null, null); - - var boundedEndLine = bodyStartLine.HasValue - ? Math.Max(startIndex + 1, bodyStartLine.Value) - : startIndex + 1; - return (boundedEndLine, bodyStartLine, boundedEndLine); - } - - private static int FindShellSameLineBraceEndColumn(string line, int startColumn) - { - var depth = 0; - var opened = false; - int? bodyStartLine = null; - var inSingleQuote = false; - var inDoubleQuote = false; - return ScanShellBraceLine( - startColumn > 0 && startColumn < line.Length - ? line[startColumn..] - : startColumn >= line.Length - ? string.Empty - : line, - ref depth, - ref opened, - ref bodyStartLine, - 1, - ref inSingleQuote, - ref inDoubleQuote) is var relativeCloseColumn && relativeCloseColumn >= 0 - ? startColumn + relativeCloseColumn - : -1; - } - - private static int ScanShellBraceLine( - string line, - ref int depth, - ref bool opened, - ref int? bodyStartLine, - int currentLine, - ref bool inSingleQuote, - ref bool inDoubleQuote) - { - for (var i = 0; i < line.Length; i++) - { - var c = line[i]; - if (inSingleQuote) - { - if (c == '\'') - inSingleQuote = false; - continue; - } - - if (inDoubleQuote) - { - if (c == '\\' && i + 1 < line.Length) - { - i++; - continue; - } - - if (c == '"') - inDoubleQuote = false; - continue; - } - - if (c == '\\' && i + 1 < line.Length) - { - i++; - continue; - } - - if (c == '\'') - { - inSingleQuote = true; - continue; - } - - if (c == '"') - { - inDoubleQuote = true; - continue; - } - - if (c == '#' && IsShellCommentStart(line, i)) - break; - - if (c == '{') - { - depth++; - if (!opened) - { - opened = true; - bodyStartLine = currentLine; - } - } - else if (c == '}' && opened) - { - depth--; - if (depth <= 0) - return i; - } - } - - return -1; - } - - private static bool IsShellCommentStart(string line, int index) - { - if (index == 0) - return true; - - var previous = line[index - 1]; - return char.IsWhiteSpace(previous) || previous is ';' or '|' or '&' or '(' or '{'; - } // Java-aware variant of FindBraceRange. Tracks strings, char literals, comments, and text blocks // via the same lexer state machine used by the enum member extractor, so a `}` inside a text From acf82c06bbd34c0ba3c97fc1d721fabbb1ba8c93 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:44:58 +0900 Subject: [PATCH 014/101] Separate C# and Dart lexical scopes --- .../Symbols/SymbolExtractor.LexicalScopes.cs | 536 ++++++++++++++++++ .../Indexer/Symbols/SymbolExtractor.cs | 535 ----------------- 2 files changed, 536 insertions(+), 535 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs new file mode 100644 index 000000000..77af442b3 --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs @@ -0,0 +1,536 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private static CSharpLexState[] BuildCSharpLineStartStates(string[] lines) + { + var result = new CSharpLexState[lines.Length]; + var state = new CSharpLexState(); + for (var i = 0; i < lines.Length; i++) + { + result[i] = state; + state = LexCSharpLine(lines[i], state).EndState; + } + + return result; + } + + private static bool IsCSharpRootCodePosition(string line, CSharpLexState lineStartState, int rawColumn) + { + var clampedColumn = Math.Clamp(rawColumn, 0, line.Length); + var stateAtColumn = clampedColumn == 0 + ? lineStartState + : LexCSharpLine(line[..clampedColumn], lineStartState).EndState; + + return stateAtColumn.Mode == CSharpLexMode.Code + && stateAtColumn.InterpolationReturnMode == CSharpLexMode.Code + && stateAtColumn.InterpolationBraceDepth == 0; + } + + // Translate a column in a CollapseCSharpGenericTypeWhitespace-collapsed match line back + // to the matching column in the raw source line. Used by the plain-field scope gate and + // signature clamp so `public class C{int X;}` does not misalign the type-body + // scope lookup when internal generic whitespace has been collapsed away, and so field + // signatures sliced out of the raw line preserve the original separators instead of + // picking up phantom leading `;` from the next declarator on the same line. Closes #400. + // CollapseCSharpGenericTypeWhitespace で空白を詰めた match 行上の列を、元の raw 行の + // 列に戻す。`public class C{int X;}` のような行で CSharpTypeBodyScope の参照列が + // ずれないようにしたり、同一行に続くフィールドを raw から slice したときに + // 先頭に余計な `;` が混入しないようにするため、プレーンフィールドのゲートと + // signature clamp で利用する。Closes #400. + private static int TranslateCSharpCollapsedColumnToRaw(int[]?[] mapPerLine, int lineIndex, int collapsedColumn, int rawLength) + { + if (mapPerLine == null || lineIndex < 0 || lineIndex >= mapPerLine.Length) + return collapsedColumn; + var map = mapPerLine[lineIndex]; + if (map == null) + return collapsedColumn; + if (collapsedColumn < 0) + return 0; + if (collapsedColumn >= map.Length) + return rawLength; + return map[collapsedColumn]; + } + + // Convert a raw-line column back into the per-line collapsed C# match-line domain. + // Same-line brace-bodied generic members now keep raw columns for signature slicing, + // but sibling rescan still runs on `csharpMatchLines[i]` (collapsed). Map the + // closing-brace column back before calling `FindNextSameLineBraceStatementStart`, or + // a raw column shifted right by removed generic whitespace can restart inside/past the + // next compact sibling and make later declarations disappear. Closes #533. + // raw 行の列を、per-line collapsed な C# match 行の列へ戻す。same-line の + // brace-bodied generic member は signature 切り出しのため raw 列を保持するが、 + // sibling 再スキャン自体は `csharpMatchLines[i]`(collapsed)上で動く。そこで + // `FindNextSameLineBraceStatementStart` に渡す前に閉じ brace 列を collapsed 側へ戻し、 + // generic 内で消えた空白ぶん右へずれた raw 列が次 sibling の途中/後ろから再開して + // 後続宣言を落とすのを防ぐ。Closes #533. + private static int TranslateCSharpRawColumnToCollapsed(int[]?[] mapPerLine, int lineIndex, int rawColumn, int collapsedLength, int rawLength) + { + if (mapPerLine == null || lineIndex < 0 || lineIndex >= mapPerLine.Length) + return rawColumn; + var map = mapPerLine[lineIndex]; + if (map == null) + return rawColumn; + if (rawColumn <= 0) + return 0; + if (map.Length == 0) + return Math.Clamp(rawColumn, 0, collapsedLength); + if (rawColumn >= rawLength) + return collapsedLength; + + var lo = 0; + var hi = map.Length - 1; + while (lo <= hi) + { + var mid = lo + ((hi - lo) / 2); + var mappedRaw = map[mid]; + if (mappedRaw == rawColumn) + return mid; + if (mappedRaw < rawColumn) + lo = mid + 1; + else + hi = mid - 1; + } + + if (hi < 0) + return 0; + if (hi >= map.Length) + return collapsedLength; + return hi; + } + + // Gate only the block-bodied property pattern (requires `{ get|set|init ... }`). + // Expression-bodied properties (`Name => expr;`) now also use BodyStyle.Brace so + // FindCSharpBraceRange can detect `=>` and compute a body range, but they never + // carry `{ get|set|init` on the match line — skipping them here would throw away + // every expression-bodied property. Closes #233. + // block-bodied プロパティパターン(`{ get|set|init ... }` を要求)のみガードする。 + // 式本体プロパティ(`Name => expr;`)も FindCSharpBraceRange で '=>' 本体範囲を + // 取るため BodyStyle.Brace を使うが、match 行に `{ get|set|init` は来ないので + // ここで弾くと式本体プロパティが全滅してしまう。Closes #233. + private static bool TrySkipCSharpBracePropertyCandidate( + string? lang, + SymbolPattern pattern, + string matchLine, + int matchStartColumn, + bool matchedExpressionArrow, + out int nextSameLineOffset) + { + nextSameLineOffset = -1; + if (lang != "csharp" + || pattern.Kind != "property" + || pattern.BodyStyle != BodyStyle.Brace) + { + return false; + } + + if (matchStartColumn < 0) + matchStartColumn = 0; + if (matchStartColumn > matchLine.Length) + matchStartColumn = matchLine.Length; + + // Same-line type headers can still false-positive as brace properties because the + // C# property regex accepts omitted visibility/modifier runs. Detect a real + // class/struct/interface/record header up front and restart from the first member + // inside that type body, rather than from the regex match tail. The regex tail can + // overrun into a later sibling expression-bodied property (`A => 1`) or brace-body + // property (`P { get; set; }`), which would otherwise skip the real member that + // should be matched next. Closes #472. + // 同一行の型ヘッダは、visibility / modifier 省略を許す C# property regex により + // brace-property 偽陽性になりうる。ここでは実際の + // class/struct/interface/record ヘッダを先に検出し、regex マッチ末尾ではなく + // 型本体の最初の member 位置から再開する。regex 末尾基準だと後続の + // 式本体 property (`A => 1`) や brace-body property (`P { get; set; }`) まで + // 飛び越してしまい、次に取るべき本物の member をスキップしてしまう。Closes #472. + var matchedDeclaration = matchLine[matchStartColumn..]; + if (CSharpTypeBodyDeclarationMarker.IsMatch(matchedDeclaration)) + { + var typeBodyOpenBrace = matchedDeclaration.IndexOf('{'); + if (typeBodyOpenBrace >= 0) + { + nextSameLineOffset = FindNextSameLineNonClosingBraceStatementStart( + matchLine, + matchStartColumn + typeBodyOpenBrace + 1, + lang); + } + + return true; + } + + return !matchedExpressionArrow + && !HasCSharpPropertyAccessorStart(matchedDeclaration); + } + + // Mark every line that sits directly inside a C# type body (class / struct / + // interface / record / enum). Used to gate the plain-field pattern so that + // local variable declarations inside a method, property accessor, lambda, or + // other non-type body are not misclassified as kind `property`. The scan uses + // `structuralLines` (strings / chars / comments already masked), so it is not + // fooled by braces or type-declaration-looking text inside literals. Only + // brace-delimited types push a type-body frame — `new { ... }`, collection + // initializers, and lambda bodies all carry the `class|struct|interface|record|enum` + // keyword absent from the preceding buffer, so they correctly stay non-type. + // Closes #298 follow-up (codex review blocker). + // C# の「現在この行は型本体(class / struct / interface / record / enum)の + // 直下にあるか」を行単位で事前計算する。新しい通常フィールド抽出パターンが + // メソッド本体・プロパティアクセサ・ラムダなど「非型本体」に含まれる + // ローカル変数宣言を kind `property` として誤抽出しないよう、このフラグで + // ゲートする。走査は既に文字列・文字・コメントを空白化した + // `structuralLines` を使うため、リテラル内の `{` や `class` 相当の文字列に + // 騙されない。`new { ... }` や collection initializer、ラムダ本体の `{` は + // 直前バッファに `class|struct|interface|record|enum` を含まないため + // 非型本体として扱われる。Closes #298 の codex レビュー blocker 対応。 + // Marks `{` that opens a class-like body where C# plain fields are legal. + // `enum` is intentionally excluded: enum bodies contain enum members (not + // fields), and the field regex would otherwise match enum member shapes like + // `[Obsolete] A = (int)B,` as phantom `property` symbols. The column-aware + // scope gate relies on this distinction to reject field candidates inside + // enum bodies while still accepting legitimate fields inside class / struct + // / interface / record bodies. Closes #400. + // 型本体に相当する `{` を識別する正規表現。`enum` を意図的に除外することで、 + // enum 本体内の `[Obsolete] A = (int)B,` のような enum member を plain field + // regex が `property` として拾ってしまう問題を防ぐ。列意識スコープゲートは + // この区別を使って、enum 本体内の field 候補は拒否し、class / struct / + // interface / record 本体内の本物のフィールドは引き続き許容する。Closes #400. + private static readonly Regex CSharpTypeBodyDeclarationMarker = new( + @"\b(?:class|struct|interface|record)\b\s+\w", + RegexOptions.Compiled); + + // Return true when the accumulated field header text reaches a top-level `;`. + // Tracks paren/bracket/brace depth so `;` inside an initializer such as + // `for (; ; ) { … }` never falsely marks the declaration as complete. + // 累積ヘッダが paren/bracket/brace の深さ 0 にある `;` に到達したら true を返す。 + // `for (; ; ) { … }` のような初期化式内の `;` を完了と誤認しないよう深さを追跡する。 + private static bool HasCSharpTopLevelSemicolon(string text) + { + int paren = 0, bracket = 0, brace = 0; + for (int i = 0; i < text.Length; i++) + { + switch (text[i]) + { + case '(': paren++; continue; + case ')' when paren > 0: paren--; continue; + case '[': bracket++; continue; + case ']' when bracket > 0: bracket--; continue; + case '{': brace++; continue; + case '}' when brace > 0: brace--; continue; + case ';' when paren == 0 && bracket == 0 && brace == 0: + return true; + } + } + return false; + } + + private sealed class CSharpCallableParameterScope + { + public static readonly CSharpCallableParameterScope Empty = new(null, null); + + private readonly bool[]? _lineStartInsideParameterList; + private readonly List<(int Column, bool IsInsideParameterList)>?[]? _transitions; + + public CSharpCallableParameterScope(bool[]? lineStartInsideParameterList, List<(int Column, bool IsInsideParameterList)>?[]? transitions) + { + _lineStartInsideParameterList = lineStartInsideParameterList; + _transitions = transitions; + } + + public bool IsInsideParameterListAt(int lineIndex, int column) + { + var state = _lineStartInsideParameterList?[lineIndex] ?? false; + var transitions = _transitions?[lineIndex]; + if (transitions == null) + return state; + + foreach (var (col, isInsideParameterList) in transitions) + { + if (col >= column) + break; + state = isInsideParameterList; + } + + return state; + } + } + + private static CSharpCallableParameterScope BuildCSharpCallableParameterScope( + string[] structuralLines, + CSharpTypeBodyScope typeBodyScope) + { + if (!LinesContain(structuralLines, '(')) + return CSharpCallableParameterScope.Empty; + + bool[]? lineStartInsideParameterList = null; + List<(int Column, bool IsInsideParameterList)>?[]? transitions = null; + var declarationBuffer = new StringBuilder(256); + var parameterParenDepth = 0; + + for (int lineIndex = 0; lineIndex < structuralLines.Length; lineIndex++) + { + if (parameterParenDepth > 0) + (lineStartInsideParameterList ??= new bool[structuralLines.Length])[lineIndex] = true; + var line = structuralLines[lineIndex]; + + for (int cursor = 0; cursor < line.Length; cursor++) + { + var ch = line[cursor]; + if (parameterParenDepth > 0) + { + if (ch == '(') + { + parameterParenDepth++; + } + else if (ch == ')') + { + parameterParenDepth--; + if (parameterParenDepth == 0) + AddCSharpCallableParameterTransition(ref transitions, structuralLines.Length, lineIndex, cursor, false); + } + + declarationBuffer.Append(ch); + continue; + } + + if (ch == '(' + && typeBodyScope.IsInsideTypeBodyAt(lineIndex, cursor) + && IsCSharpCallableHeaderBeforeParameterList(declarationBuffer.ToString())) + { + parameterParenDepth = 1; + AddCSharpCallableParameterTransition(ref transitions, structuralLines.Length, lineIndex, cursor, true); + declarationBuffer.Append(ch); + continue; + } + + if (ch is '{' or '}' or ';') + { + declarationBuffer.Clear(); + continue; + } + + declarationBuffer.Append(ch); + } + } + + return new CSharpCallableParameterScope(lineStartInsideParameterList, transitions); + } + + private static void AddCSharpCallableParameterTransition( + ref List<(int Column, bool IsInsideParameterList)>?[]? transitions, + int lineCount, + int lineIndex, + int column, + bool isInsideParameterList) + { + var transitionsByLine = transitions ??= new List<(int, bool)>?[lineCount]; + (transitionsByLine[lineIndex] ??= []).Add((column, isInsideParameterList)); + } + + private static bool IsCSharpCallableHeaderBeforeParameterList(string header) + { + var text = header.Trim(); + if (text.Length == 0 || ContainsCSharpTopLevelAssignment(text)) + return false; + + var end = SkipCSharpTrailingGenericParameterList(text, text.Length); + while (end > 0 && char.IsWhiteSpace(text[end - 1])) + end--; + if (end <= 0) + return false; + + var tokenEnd = end; + var tokenStart = tokenEnd; + while (tokenStart > 0 && IsCSharpIdentifierPart(text[tokenStart - 1])) + tokenStart--; + if (tokenStart == tokenEnd) + return false; + + var token = text[tokenStart..tokenEnd]; + if (token.StartsWith('@') && token.Length > 1) + return true; + + return token.Length > 0 && !IsCSharpNonCallableHeaderTailToken(token); + } + + private static int SkipCSharpTrailingGenericParameterList(string text, int end) + { + while (end > 0 && char.IsWhiteSpace(text[end - 1])) + end--; + if (end <= 0 || text[end - 1] != '>') + return end; + + var depth = 0; + for (var index = end - 1; index >= 0; index--) + { + if (text[index] == '>') + { + depth++; + continue; + } + + if (text[index] == '<') + { + depth--; + if (depth == 0) + return index; + } + } + + return end; + } + + private static bool ContainsCSharpTopLevelAssignment(string text) + { + var angleDepth = 0; + var parenDepth = 0; + var bracketDepth = 0; + for (var index = 0; index < text.Length; index++) + { + var ch = text[index]; + switch (ch) + { + case '<': + angleDepth++; + continue; + case '>' when angleDepth > 0: + angleDepth--; + continue; + case '(': + parenDepth++; + continue; + case ')' when parenDepth > 0: + parenDepth--; + continue; + case '[': + bracketDepth++; + continue; + case ']' when bracketDepth > 0: + bracketDepth--; + continue; + case '=' when angleDepth == 0 && parenDepth == 0 && bracketDepth == 0: + return true; + } + } + + return false; + } + + private static bool IsCSharpIdentifierPart(char ch) => + char.IsLetterOrDigit(ch) || ch is '_' or '$' or '@'; + + private static bool IsCSharpNonCallableHeaderTailToken(string token) => + token is + "abstract" or + "async" or + "await" or + "base" or + "case" or + "catch" or + "const" or + "continue" or + "default" or + "delegate" or + "else" or + "event" or + "extern" or + "false" or + "file" or + "for" or + "foreach" or + "goto" or + "if" or + "internal" or + "lock" or + "nameof" or + "new" or + "null" or + "override" or + "private" or + "protected" or + "public" or + "readonly" or + "ref" or + "required" or + "return" or + "sealed" or + "sizeof" or + "stackalloc" or + "static" or + "switch" or + "this" or + "throw" or + "true" or + "typeof" or + "unsafe" or + "using" or + "var" or + "virtual" or + "volatile" or + "when" or + "while" or + "yield"; + + private sealed class DartClassBodyScope + { + public static readonly DartClassBodyScope Empty = new(null); + + private readonly bool[]? _lineStartInsideClassBody; + + public DartClassBodyScope(bool[]? lineStartInsideClassBody) + { + _lineStartInsideClassBody = lineStartInsideClassBody; + } + + public bool IsInsideClassBodyAt(int lineIndex) => _lineStartInsideClassBody?[lineIndex] ?? false; + } + + private static DartClassBodyScope BuildDartClassBodyScope(string[] structuralLines) + { + if (!LinesContain(structuralLines, "class", StringComparison.Ordinal)) + return DartClassBodyScope.Empty; + if (!LinesContain(structuralLines, '{')) + return DartClassBodyScope.Empty; + + var lineStartInsideClassBody = new bool[structuralLines.Length]; + var scopeStack = new Stack(); + scopeStack.Push(false); + var declBuffer = new StringBuilder(256); + + for (int lineIndex = 0; lineIndex < structuralLines.Length; lineIndex++) + { + lineStartInsideClassBody[lineIndex] = scopeStack.Peek(); + + var line = structuralLines[lineIndex]; + for (int cursor = 0; cursor < line.Length; cursor++) + { + var ch = line[cursor]; + if (ch == '{') + { + var isClassBody = DartClassDeclarationRegex.IsMatch(declBuffer.ToString()); + scopeStack.Push(isClassBody); + declBuffer.Clear(); + } + else if (ch == '}') + { + if (scopeStack.Count > 1) + scopeStack.Pop(); + declBuffer.Clear(); + } + else if (ch == ';') + { + declBuffer.Clear(); + } + else + { + declBuffer.Append(ch); + } + } + } + + return new DartClassBodyScope(lineStartInsideClassBody); + } +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 18e5641d8..d9ffacc88 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -3580,541 +3580,6 @@ private static (int EndLine, int? BodyStartLine, int? BodyEndLine) ResolveRange( _ => (startIndex + 1, null, null), }; } - - - // Java-aware variant of FindBraceRange. Tracks strings, char literals, comments, and text blocks - // via the same lexer state machine used by the enum member extractor, so a `}` inside a text - // block or quoted string does not prematurely close the containing brace range. - // Java 用の FindBraceRange。文字列 / char / コメント / text block を enum member 抽出と同じ - // lexer で追跡し、text block や文字列内の `}` で本体範囲が早期終了しないようにする。 - - - private static CSharpLexState[] BuildCSharpLineStartStates(string[] lines) - { - var result = new CSharpLexState[lines.Length]; - var state = new CSharpLexState(); - for (var i = 0; i < lines.Length; i++) - { - result[i] = state; - state = LexCSharpLine(lines[i], state).EndState; - } - - return result; - } - - private static bool IsCSharpRootCodePosition(string line, CSharpLexState lineStartState, int rawColumn) - { - var clampedColumn = Math.Clamp(rawColumn, 0, line.Length); - var stateAtColumn = clampedColumn == 0 - ? lineStartState - : LexCSharpLine(line[..clampedColumn], lineStartState).EndState; - - return stateAtColumn.Mode == CSharpLexMode.Code - && stateAtColumn.InterpolationReturnMode == CSharpLexMode.Code - && stateAtColumn.InterpolationBraceDepth == 0; - } - - // Translate a column in a CollapseCSharpGenericTypeWhitespace-collapsed match line back - // to the matching column in the raw source line. Used by the plain-field scope gate and - // signature clamp so `public class C{int X;}` does not misalign the type-body - // scope lookup when internal generic whitespace has been collapsed away, and so field - // signatures sliced out of the raw line preserve the original separators instead of - // picking up phantom leading `;` from the next declarator on the same line. Closes #400. - // CollapseCSharpGenericTypeWhitespace で空白を詰めた match 行上の列を、元の raw 行の - // 列に戻す。`public class C{int X;}` のような行で CSharpTypeBodyScope の参照列が - // ずれないようにしたり、同一行に続くフィールドを raw から slice したときに - // 先頭に余計な `;` が混入しないようにするため、プレーンフィールドのゲートと - // signature clamp で利用する。Closes #400. - private static int TranslateCSharpCollapsedColumnToRaw(int[]?[] mapPerLine, int lineIndex, int collapsedColumn, int rawLength) - { - if (mapPerLine == null || lineIndex < 0 || lineIndex >= mapPerLine.Length) - return collapsedColumn; - var map = mapPerLine[lineIndex]; - if (map == null) - return collapsedColumn; - if (collapsedColumn < 0) - return 0; - if (collapsedColumn >= map.Length) - return rawLength; - return map[collapsedColumn]; - } - - // Convert a raw-line column back into the per-line collapsed C# match-line domain. - // Same-line brace-bodied generic members now keep raw columns for signature slicing, - // but sibling rescan still runs on `csharpMatchLines[i]` (collapsed). Map the - // closing-brace column back before calling `FindNextSameLineBraceStatementStart`, or - // a raw column shifted right by removed generic whitespace can restart inside/past the - // next compact sibling and make later declarations disappear. Closes #533. - // raw 行の列を、per-line collapsed な C# match 行の列へ戻す。same-line の - // brace-bodied generic member は signature 切り出しのため raw 列を保持するが、 - // sibling 再スキャン自体は `csharpMatchLines[i]`(collapsed)上で動く。そこで - // `FindNextSameLineBraceStatementStart` に渡す前に閉じ brace 列を collapsed 側へ戻し、 - // generic 内で消えた空白ぶん右へずれた raw 列が次 sibling の途中/後ろから再開して - // 後続宣言を落とすのを防ぐ。Closes #533. - private static int TranslateCSharpRawColumnToCollapsed(int[]?[] mapPerLine, int lineIndex, int rawColumn, int collapsedLength, int rawLength) - { - if (mapPerLine == null || lineIndex < 0 || lineIndex >= mapPerLine.Length) - return rawColumn; - var map = mapPerLine[lineIndex]; - if (map == null) - return rawColumn; - if (rawColumn <= 0) - return 0; - if (map.Length == 0) - return Math.Clamp(rawColumn, 0, collapsedLength); - if (rawColumn >= rawLength) - return collapsedLength; - - var lo = 0; - var hi = map.Length - 1; - while (lo <= hi) - { - var mid = lo + ((hi - lo) / 2); - var mappedRaw = map[mid]; - if (mappedRaw == rawColumn) - return mid; - if (mappedRaw < rawColumn) - lo = mid + 1; - else - hi = mid - 1; - } - - if (hi < 0) - return 0; - if (hi >= map.Length) - return collapsedLength; - return hi; - } - - // Gate only the block-bodied property pattern (requires `{ get|set|init ... }`). - // Expression-bodied properties (`Name => expr;`) now also use BodyStyle.Brace so - // FindCSharpBraceRange can detect `=>` and compute a body range, but they never - // carry `{ get|set|init` on the match line — skipping them here would throw away - // every expression-bodied property. Closes #233. - // block-bodied プロパティパターン(`{ get|set|init ... }` を要求)のみガードする。 - // 式本体プロパティ(`Name => expr;`)も FindCSharpBraceRange で '=>' 本体範囲を - // 取るため BodyStyle.Brace を使うが、match 行に `{ get|set|init` は来ないので - // ここで弾くと式本体プロパティが全滅してしまう。Closes #233. - private static bool TrySkipCSharpBracePropertyCandidate( - string? lang, - SymbolPattern pattern, - string matchLine, - int matchStartColumn, - bool matchedExpressionArrow, - out int nextSameLineOffset) - { - nextSameLineOffset = -1; - if (lang != "csharp" - || pattern.Kind != "property" - || pattern.BodyStyle != BodyStyle.Brace) - { - return false; - } - - if (matchStartColumn < 0) - matchStartColumn = 0; - if (matchStartColumn > matchLine.Length) - matchStartColumn = matchLine.Length; - - // Same-line type headers can still false-positive as brace properties because the - // C# property regex accepts omitted visibility/modifier runs. Detect a real - // class/struct/interface/record header up front and restart from the first member - // inside that type body, rather than from the regex match tail. The regex tail can - // overrun into a later sibling expression-bodied property (`A => 1`) or brace-body - // property (`P { get; set; }`), which would otherwise skip the real member that - // should be matched next. Closes #472. - // 同一行の型ヘッダは、visibility / modifier 省略を許す C# property regex により - // brace-property 偽陽性になりうる。ここでは実際の - // class/struct/interface/record ヘッダを先に検出し、regex マッチ末尾ではなく - // 型本体の最初の member 位置から再開する。regex 末尾基準だと後続の - // 式本体 property (`A => 1`) や brace-body property (`P { get; set; }`) まで - // 飛び越してしまい、次に取るべき本物の member をスキップしてしまう。Closes #472. - var matchedDeclaration = matchLine[matchStartColumn..]; - if (CSharpTypeBodyDeclarationMarker.IsMatch(matchedDeclaration)) - { - var typeBodyOpenBrace = matchedDeclaration.IndexOf('{'); - if (typeBodyOpenBrace >= 0) - { - nextSameLineOffset = FindNextSameLineNonClosingBraceStatementStart( - matchLine, - matchStartColumn + typeBodyOpenBrace + 1, - lang); - } - - return true; - } - - return !matchedExpressionArrow - && !HasCSharpPropertyAccessorStart(matchedDeclaration); - } - - // Mark every line that sits directly inside a C# type body (class / struct / - // interface / record / enum). Used to gate the plain-field pattern so that - // local variable declarations inside a method, property accessor, lambda, or - // other non-type body are not misclassified as kind `property`. The scan uses - // `structuralLines` (strings / chars / comments already masked), so it is not - // fooled by braces or type-declaration-looking text inside literals. Only - // brace-delimited types push a type-body frame — `new { ... }`, collection - // initializers, and lambda bodies all carry the `class|struct|interface|record|enum` - // keyword absent from the preceding buffer, so they correctly stay non-type. - // Closes #298 follow-up (codex review blocker). - // C# の「現在この行は型本体(class / struct / interface / record / enum)の - // 直下にあるか」を行単位で事前計算する。新しい通常フィールド抽出パターンが - // メソッド本体・プロパティアクセサ・ラムダなど「非型本体」に含まれる - // ローカル変数宣言を kind `property` として誤抽出しないよう、このフラグで - // ゲートする。走査は既に文字列・文字・コメントを空白化した - // `structuralLines` を使うため、リテラル内の `{` や `class` 相当の文字列に - // 騙されない。`new { ... }` や collection initializer、ラムダ本体の `{` は - // 直前バッファに `class|struct|interface|record|enum` を含まないため - // 非型本体として扱われる。Closes #298 の codex レビュー blocker 対応。 - // Marks `{` that opens a class-like body where C# plain fields are legal. - // `enum` is intentionally excluded: enum bodies contain enum members (not - // fields), and the field regex would otherwise match enum member shapes like - // `[Obsolete] A = (int)B,` as phantom `property` symbols. The column-aware - // scope gate relies on this distinction to reject field candidates inside - // enum bodies while still accepting legitimate fields inside class / struct - // / interface / record bodies. Closes #400. - // 型本体に相当する `{` を識別する正規表現。`enum` を意図的に除外することで、 - // enum 本体内の `[Obsolete] A = (int)B,` のような enum member を plain field - // regex が `property` として拾ってしまう問題を防ぐ。列意識スコープゲートは - // この区別を使って、enum 本体内の field 候補は拒否し、class / struct / - // interface / record 本体内の本物のフィールドは引き続き許容する。Closes #400. - private static readonly Regex CSharpTypeBodyDeclarationMarker = new( - @"\b(?:class|struct|interface|record)\b\s+\w", - RegexOptions.Compiled); - - // Return true when the accumulated field header text reaches a top-level `;`. - // Tracks paren/bracket/brace depth so `;` inside an initializer such as - // `for (; ; ) { … }` never falsely marks the declaration as complete. - // 累積ヘッダが paren/bracket/brace の深さ 0 にある `;` に到達したら true を返す。 - // `for (; ; ) { … }` のような初期化式内の `;` を完了と誤認しないよう深さを追跡する。 - private static bool HasCSharpTopLevelSemicolon(string text) - { - int paren = 0, bracket = 0, brace = 0; - for (int i = 0; i < text.Length; i++) - { - switch (text[i]) - { - case '(': paren++; continue; - case ')' when paren > 0: paren--; continue; - case '[': bracket++; continue; - case ']' when bracket > 0: bracket--; continue; - case '{': brace++; continue; - case '}' when brace > 0: brace--; continue; - case ';' when paren == 0 && bracket == 0 && brace == 0: - return true; - } - } - return false; - } - - private sealed class CSharpCallableParameterScope - { - public static readonly CSharpCallableParameterScope Empty = new(null, null); - - private readonly bool[]? _lineStartInsideParameterList; - private readonly List<(int Column, bool IsInsideParameterList)>?[]? _transitions; - - public CSharpCallableParameterScope(bool[]? lineStartInsideParameterList, List<(int Column, bool IsInsideParameterList)>?[]? transitions) - { - _lineStartInsideParameterList = lineStartInsideParameterList; - _transitions = transitions; - } - - public bool IsInsideParameterListAt(int lineIndex, int column) - { - var state = _lineStartInsideParameterList?[lineIndex] ?? false; - var transitions = _transitions?[lineIndex]; - if (transitions == null) - return state; - - foreach (var (col, isInsideParameterList) in transitions) - { - if (col >= column) - break; - state = isInsideParameterList; - } - - return state; - } - } - - private static CSharpCallableParameterScope BuildCSharpCallableParameterScope( - string[] structuralLines, - CSharpTypeBodyScope typeBodyScope) - { - if (!LinesContain(structuralLines, '(')) - return CSharpCallableParameterScope.Empty; - - bool[]? lineStartInsideParameterList = null; - List<(int Column, bool IsInsideParameterList)>?[]? transitions = null; - var declarationBuffer = new StringBuilder(256); - var parameterParenDepth = 0; - - for (int lineIndex = 0; lineIndex < structuralLines.Length; lineIndex++) - { - if (parameterParenDepth > 0) - (lineStartInsideParameterList ??= new bool[structuralLines.Length])[lineIndex] = true; - var line = structuralLines[lineIndex]; - - for (int cursor = 0; cursor < line.Length; cursor++) - { - var ch = line[cursor]; - if (parameterParenDepth > 0) - { - if (ch == '(') - { - parameterParenDepth++; - } - else if (ch == ')') - { - parameterParenDepth--; - if (parameterParenDepth == 0) - AddCSharpCallableParameterTransition(ref transitions, structuralLines.Length, lineIndex, cursor, false); - } - - declarationBuffer.Append(ch); - continue; - } - - if (ch == '(' - && typeBodyScope.IsInsideTypeBodyAt(lineIndex, cursor) - && IsCSharpCallableHeaderBeforeParameterList(declarationBuffer.ToString())) - { - parameterParenDepth = 1; - AddCSharpCallableParameterTransition(ref transitions, structuralLines.Length, lineIndex, cursor, true); - declarationBuffer.Append(ch); - continue; - } - - if (ch is '{' or '}' or ';') - { - declarationBuffer.Clear(); - continue; - } - - declarationBuffer.Append(ch); - } - } - - return new CSharpCallableParameterScope(lineStartInsideParameterList, transitions); - } - - private static void AddCSharpCallableParameterTransition( - ref List<(int Column, bool IsInsideParameterList)>?[]? transitions, - int lineCount, - int lineIndex, - int column, - bool isInsideParameterList) - { - var transitionsByLine = transitions ??= new List<(int, bool)>?[lineCount]; - (transitionsByLine[lineIndex] ??= []).Add((column, isInsideParameterList)); - } - - private static bool IsCSharpCallableHeaderBeforeParameterList(string header) - { - var text = header.Trim(); - if (text.Length == 0 || ContainsCSharpTopLevelAssignment(text)) - return false; - - var end = SkipCSharpTrailingGenericParameterList(text, text.Length); - while (end > 0 && char.IsWhiteSpace(text[end - 1])) - end--; - if (end <= 0) - return false; - - var tokenEnd = end; - var tokenStart = tokenEnd; - while (tokenStart > 0 && IsCSharpIdentifierPart(text[tokenStart - 1])) - tokenStart--; - if (tokenStart == tokenEnd) - return false; - - var token = text[tokenStart..tokenEnd]; - if (token.StartsWith('@') && token.Length > 1) - return true; - - return token.Length > 0 && !IsCSharpNonCallableHeaderTailToken(token); - } - - private static int SkipCSharpTrailingGenericParameterList(string text, int end) - { - while (end > 0 && char.IsWhiteSpace(text[end - 1])) - end--; - if (end <= 0 || text[end - 1] != '>') - return end; - - var depth = 0; - for (var index = end - 1; index >= 0; index--) - { - if (text[index] == '>') - { - depth++; - continue; - } - - if (text[index] == '<') - { - depth--; - if (depth == 0) - return index; - } - } - - return end; - } - - private static bool ContainsCSharpTopLevelAssignment(string text) - { - var angleDepth = 0; - var parenDepth = 0; - var bracketDepth = 0; - for (var index = 0; index < text.Length; index++) - { - var ch = text[index]; - switch (ch) - { - case '<': - angleDepth++; - continue; - case '>' when angleDepth > 0: - angleDepth--; - continue; - case '(': - parenDepth++; - continue; - case ')' when parenDepth > 0: - parenDepth--; - continue; - case '[': - bracketDepth++; - continue; - case ']' when bracketDepth > 0: - bracketDepth--; - continue; - case '=' when angleDepth == 0 && parenDepth == 0 && bracketDepth == 0: - return true; - } - } - - return false; - } - - private static bool IsCSharpIdentifierPart(char ch) => - char.IsLetterOrDigit(ch) || ch is '_' or '$' or '@'; - - private static bool IsCSharpNonCallableHeaderTailToken(string token) => - token is - "abstract" or - "async" or - "await" or - "base" or - "case" or - "catch" or - "const" or - "continue" or - "default" or - "delegate" or - "else" or - "event" or - "extern" or - "false" or - "file" or - "for" or - "foreach" or - "goto" or - "if" or - "internal" or - "lock" or - "nameof" or - "new" or - "null" or - "override" or - "private" or - "protected" or - "public" or - "readonly" or - "ref" or - "required" or - "return" or - "sealed" or - "sizeof" or - "stackalloc" or - "static" or - "switch" or - "this" or - "throw" or - "true" or - "typeof" or - "unsafe" or - "using" or - "var" or - "virtual" or - "volatile" or - "when" or - "while" or - "yield"; - - private sealed class DartClassBodyScope - { - public static readonly DartClassBodyScope Empty = new(null); - - private readonly bool[]? _lineStartInsideClassBody; - - public DartClassBodyScope(bool[]? lineStartInsideClassBody) - { - _lineStartInsideClassBody = lineStartInsideClassBody; - } - - public bool IsInsideClassBodyAt(int lineIndex) => _lineStartInsideClassBody?[lineIndex] ?? false; - } - - private static DartClassBodyScope BuildDartClassBodyScope(string[] structuralLines) - { - if (!LinesContain(structuralLines, "class", StringComparison.Ordinal)) - return DartClassBodyScope.Empty; - if (!LinesContain(structuralLines, '{')) - return DartClassBodyScope.Empty; - - var lineStartInsideClassBody = new bool[structuralLines.Length]; - var scopeStack = new Stack(); - scopeStack.Push(false); - var declBuffer = new StringBuilder(256); - - for (int lineIndex = 0; lineIndex < structuralLines.Length; lineIndex++) - { - lineStartInsideClassBody[lineIndex] = scopeStack.Peek(); - - var line = structuralLines[lineIndex]; - for (int cursor = 0; cursor < line.Length; cursor++) - { - var ch = line[cursor]; - if (ch == '{') - { - var isClassBody = DartClassDeclarationRegex.IsMatch(declBuffer.ToString()); - scopeStack.Push(isClassBody); - declBuffer.Clear(); - } - else if (ch == '}') - { - if (scopeStack.Count > 1) - scopeStack.Pop(); - declBuffer.Clear(); - } - else if (ch == ';') - { - declBuffer.Clear(); - } - else - { - declBuffer.Append(ch); - } - } - } - - return new DartClassBodyScope(lineStartInsideClassBody); - } - private static bool[] FindCSharpSwitchExpressionLines(string[] structuralLines) { var switchExpressionLines = new bool[structuralLines.Length]; From b761b76e90207a7352b840d82df43d1e4a7a4669 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:48:42 +0900 Subject: [PATCH 015/101] Extract cross-language record components --- .../SymbolExtractor.RecordComponents.Types.cs | 86 + .../SymbolExtractor.RecordComponents.cs | 1634 ++++++++++++++++ .../Indexer/Symbols/SymbolExtractor.cs | 1703 ----------------- 3 files changed, 1720 insertions(+), 1703 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.Types.cs create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.Types.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.Types.cs new file mode 100644 index 000000000..571912771 --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.Types.cs @@ -0,0 +1,86 @@ +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private readonly record struct RecordPrimaryComponent( + string Name, + string Type, + string Signature, + int Line, + string? Visibility = null); + + private readonly record struct RecordPrimaryComponentSlice( + string Text, + int Line); + + private readonly record struct PendingRecordPrimaryComponents( + long FileId, + string Kind, + string RecordName, + int RecordStartLine, + List Components); + + private readonly record struct RecordPrimaryComponentParentKey( + long FileId, + string Kind, + string Name, + int StartLine); + + private readonly record struct RecordPrimaryComponentPropertyKey( + long FileId, + string ContainerKind, + string ContainerName); + + private sealed class RecordPrimaryComponentParentIndex + { + private readonly Dictionary _parents = []; + private int _indexedCount; + private SymbolRecord? _lastIndexedSymbol; + + public SymbolRecord? FindLast( + List symbols, + RecordPrimaryComponentParentKey key) + { + Synchronize(symbols); + return _parents.TryGetValue(key, out var parent) ? parent : null; + } + + private void Synchronize(List symbols) + { + // AddSymbolRecord can remove declaration-only functions from the list tail. + // Rebuild if that invalidated the indexed boundary; otherwise consume only + // symbols appended since the previous record declaration. + // AddSymbolRecord は末尾の declaration-only function を除くことがあるため、 + // index境界が無効なら再構築し、それ以外は追加分だけを取り込む。 + if (_indexedCount > symbols.Count + || (_indexedCount > 0 + && !ReferenceEquals(symbols[_indexedCount - 1], _lastIndexedSymbol))) + { + _parents.Clear(); + _indexedCount = 0; + } + + for (var index = _indexedCount; index < symbols.Count; index++) + { + var symbol = symbols[index]; + if (symbol.Kind is not ("class" or "struct" or "enum")) + continue; + + _parents[new RecordPrimaryComponentParentKey( + symbol.FileId, + symbol.Kind, + symbol.Name, + symbol.StartLine)] = symbol; + } + + _indexedCount = symbols.Count; + _lastIndexedSymbol = symbols.Count > 0 ? symbols[^1] : null; + } + } + + private readonly record struct StrippedRecordComponentText( + string Text, + int ConsumedNewlines); +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.cs new file mode 100644 index 000000000..638605f0b --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.RecordComponents.cs @@ -0,0 +1,1634 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private static void CollectRecordPrimaryComponentSymbols( + long fileId, + string lang, + string[] lines, + int declarationLineIndex, + int declarationStartColumn, + string kind, + string recordName, + ref List? pendingRecordPrimaryComponents, + ref RecordPrimaryComponentParentIndex? parentIndex, + List symbols) + { + if (lang == "kotlin") + { + if (kind is not "class" and not "enum") + return; + } + else if (kind is not "class" and not "struct") + { + return; + } + + if (!TryGetRecordPrimaryComponents( + lang, + lines, + declarationLineIndex, + declarationStartColumn, + kind, + recordName, + out var components, + out var declarationEndLine)) + return; + + var parentKey = new RecordPrimaryComponentParentKey( + fileId, + kind, + recordName, + declarationLineIndex + 1); + var parentSymbol = (parentIndex ??= new RecordPrimaryComponentParentIndex()) + .FindLast(symbols, parentKey); + if (parentSymbol != null) + parentSymbol.EndLine = Math.Max(parentSymbol.EndLine, declarationEndLine); + + if (components.Count > 0) + { + (pendingRecordPrimaryComponents ??= []).Add(new PendingRecordPrimaryComponents( + fileId, + kind, + recordName, + declarationLineIndex + 1, + components)); + } + } + + private static void MaterializeRecordPrimaryComponentSymbols( + List symbols, + List? pendingRecordPrimaryComponents) + { + if (pendingRecordPrimaryComponents is not { Count: > 0 }) + return; + + var parents = new Dictionary(); + var propertyBuckets = new Dictionary>(); + foreach (var pending in pendingRecordPrimaryComponents) + { + parents.TryAdd( + new RecordPrimaryComponentParentKey( + pending.FileId, + pending.Kind, + pending.RecordName, + pending.RecordStartLine), + null); + propertyBuckets.TryAdd( + new RecordPrimaryComponentPropertyKey( + pending.FileId, + pending.Kind, + pending.RecordName), + []); + } + + // Build both final-state indexes after AssignContainers. Forward overwrite preserves + // the previous reverse-search "last parent wins" rule, and property buckets retain + // source-list order for overlapping/nested parent ranges. + // AssignContainers 後の最終状態から両indexを構築する。forward上書きで従来の + // 「最後のparent優先」を保ち、property bucketは重複range向けにlist順を保つ。 + foreach (var symbol in symbols) + { + var parentKey = new RecordPrimaryComponentParentKey( + symbol.FileId, + symbol.Kind, + symbol.Name, + symbol.StartLine); + if (parents.ContainsKey(parentKey)) + parents[parentKey] = symbol; + + if (symbol.Kind == "property" + && symbol.ContainerKind != null + && symbol.ContainerName != null + && propertyBuckets.TryGetValue( + new RecordPrimaryComponentPropertyKey( + symbol.FileId, + symbol.ContainerKind, + symbol.ContainerName), + out var propertyBucket)) + { + propertyBucket.Add(symbol); + } + } + + foreach (var pending in pendingRecordPrimaryComponents) + { + var parentKey = new RecordPrimaryComponentParentKey( + pending.FileId, + pending.Kind, + pending.RecordName, + pending.RecordStartLine); + var parentSymbol = parents[parentKey]; + if (parentSymbol == null) + continue; + + var propertyKey = new RecordPrimaryComponentPropertyKey( + pending.FileId, + pending.Kind, + pending.RecordName); + var propertyBucket = propertyBuckets[propertyKey]; + var existingComponentNames = BuildExistingRecordPrimaryComponentNameSet(propertyBucket, parentSymbol); + + foreach (var component in pending.Components) + { + if (!existingComponentNames.Add(component.Name)) + continue; + + var componentSymbol = new SymbolRecord + { + FileId = pending.FileId, + Kind = "property", + Name = component.Name, + Line = component.Line, + StartLine = component.Line, + EndLine = component.Line, + Signature = component.Signature, + ContainerKind = pending.Kind, + ContainerName = pending.RecordName, + Visibility = component.Visibility ?? "public", + ReturnType = component.Type, + }; + symbols.Add(componentSymbol); + propertyBucket.Add(componentSymbol); + } + } + } + + private static HashSet BuildExistingRecordPrimaryComponentNameSet( + IReadOnlyList propertyCandidates, + SymbolRecord parentSymbol) + { + var existingComponentNames = new HashSet(StringComparer.Ordinal); + foreach (var symbol in propertyCandidates) + { + if (symbol.StartLine >= parentSymbol.StartLine + && symbol.EndLine <= parentSymbol.EndLine) + { + existingComponentNames.Add(symbol.Name); + } + } + + return existingComponentNames; + } + + private static bool TryGetRecordPrimaryComponents( + string lang, + string[] lines, + int declarationLineIndex, + int declarationStartColumn, + string kind, + string recordName, + out List components, + out int declarationEndLine) + { + components = null!; + declarationEndLine = declarationLineIndex + 1; + + if (lang is not "csharp" and not "java" and not "kotlin") + return false; + + var declaration = lang == "kotlin" + ? CollectKotlinPrimaryConstructorDeclarationText(lines, declarationLineIndex, declarationStartColumn) + : CollectRecordDeclarationText(lines, declarationLineIndex, declarationStartColumn); + if (string.IsNullOrWhiteSpace(declaration)) + return false; + + var recordRegex = GetCurrentDeclarationRecordRegex(lang, kind, recordName); + var javaLeadingAnnotationOffset = 0; + var recordMatch = lang is "java" or "kotlin" + ? (TryMatchJavaDeclarationSegment(recordRegex, declaration, lang == "kotlin", out var javaRecordMatch, out javaLeadingAnnotationOffset) + ? javaRecordMatch + : recordRegex.Match(declaration)) + : recordRegex.Match(declaration); + if (!recordMatch.Success) + return false; + + var parameterOpenIndex = lang == "csharp" + ? FindCSharpPrimaryConstructorParameterListStart(declaration, recordMatch.Index + recordMatch.Length) + : FindRecordPrimaryComponentListStart( + declaration, + recordMatch.Index + recordMatch.Length + javaLeadingAnnotationOffset); + if (parameterOpenIndex < 0) + return false; + + var parameterCloseIndex = FindMatchingRecordPrimaryComponentListEnd(declaration, parameterOpenIndex); + if (parameterCloseIndex <= parameterOpenIndex) + return false; + var declarationTerminatorIndex = FindRecordDeclarationTerminatorIndex(declaration, parameterCloseIndex + 1); + var declarationLineSpanEnd = declarationTerminatorIndex >= 0 ? declarationTerminatorIndex + 1 : parameterCloseIndex + 1; + declarationEndLine = declarationLineIndex + 1 + declaration[..declarationLineSpanEnd].Count(ch => ch == '\n'); + + var rawParameterList = StripRecordComponentComments(declaration[(parameterOpenIndex + 1)..parameterCloseIndex]); + components = []; + foreach (var rawComponent in SplitTopLevelRecordPrimaryComponents(rawParameterList, declarationLineIndex + 1)) + { + if (TryParseRecordPrimaryComponent(lang, rawComponent, out var component)) + components.Add(component); + } + + return true; + } + + private static string CollectRecordDeclarationText(string[] lines, int declarationLineIndex, int declarationStartColumn) + { + var builder = new StringBuilder(); + var parameterOpenIndex = -1; + var parameterCloseIndex = -1; + for (int i = declarationLineIndex; i < lines.Length; i++) + { + if (builder.Length > 0) + builder.Append('\n'); + + // Content was split on '\n', so CRLF lines carry a trailing '\r'. Strip it before + // appending so intermediate separators stay '\n' and the collected declaration + // text is stable across OS line endings (#405 follow-up to #382). + // content は '\n' で分割しているため、CRLF 行は末尾に '\r' が残る。行間の区切りを + // '\n' に揃え、OS 差分で collected text が変わらないよう '\r' を落として追加する + // (#382 に続く #405 対応)。 + var line = lines[i]; + var lineText = i == declarationLineIndex + ? line[Math.Min(declarationStartColumn, line.Length)..] + : line; + builder.Append(StripTrailingCr(lineText)); + + var declaration = builder.ToString(); + if (parameterOpenIndex < 0) + { + parameterOpenIndex = FindRecordPrimaryComponentListStart(declaration, 0); + if (parameterOpenIndex < 0) + { + if (FindRecordDeclarationTerminatorIndex(declaration, 0) >= 0) + return declaration; + continue; + } + } + + if (parameterCloseIndex < 0) + { + parameterCloseIndex = FindMatchingRecordPrimaryComponentListEnd(declaration, parameterOpenIndex); + if (parameterCloseIndex <= parameterOpenIndex) + continue; + } + + if (FindRecordDeclarationTerminatorIndex(declaration, parameterCloseIndex + 1) >= 0) + return declaration; + } + + return builder.ToString(); + } + + private static string CollectKotlinPrimaryConstructorDeclarationText(string[] lines, int declarationLineIndex, int declarationStartColumn) + { + const int KotlinPrimaryConstructorDeclarationLookaheadLineLimit = 32; + + var builder = new StringBuilder(); + var parameterOpenIndex = -1; + var parameterCloseIndex = -1; + for (int i = declarationLineIndex; i < lines.Length && i < declarationLineIndex + KotlinPrimaryConstructorDeclarationLookaheadLineLimit; i++) + { + if (builder.Length > 0) + builder.Append('\n'); + + // Kotlin class headers may split across a few physical lines. Keep the collected + // declaration stable across line endings while bounding the scan so a class without + // a primary constructor does not cause a whole-file read. + // Kotlin の class ヘッダは数行に分割されうる。改行差を吸収しつつ、primary + // constructor を持たない class でファイル全体を走査しないよう、収集範囲を + // ほどよい行数に制限する。 + var line = lines[i]; + var lineText = i == declarationLineIndex + ? line[Math.Min(declarationStartColumn, line.Length)..] + : line; + builder.Append(StripTrailingCr(lineText)); + + var declaration = builder.ToString(); + if (parameterOpenIndex < 0) + { + parameterOpenIndex = FindRecordPrimaryComponentListStart(declaration, 0); + if (parameterOpenIndex < 0) + { + if (FindRecordDeclarationTerminatorIndex(declaration, 0) >= 0) + return declaration; + + continue; + } + } + + if (parameterCloseIndex < 0) + { + parameterCloseIndex = FindMatchingRecordPrimaryComponentListEnd(declaration, parameterOpenIndex); + if (parameterCloseIndex <= parameterOpenIndex) + continue; + } + + return declaration[..(parameterCloseIndex + 1)]; + } + + return builder.ToString(); + } + + private static Regex GetCurrentDeclarationRecordRegex(string lang, string kind, string recordName) + { + if (lang == "csharp") + { + return kind == "struct" + ? new Regex(@"^\s*(?:(?:public|private|protected\s+internal|private\s+protected|protected|internal)\s+)?(?:(?:static|partial|readonly|file|new|ref|unsafe)\s+)*(?:record\s+)?struct\s+" + Regex.Escape(recordName) + @"\b", RegexOptions.CultureInvariant) + : new Regex(@"^\s*(?:(?:public|private|protected\s+internal|private\s+protected|protected|internal)\s+)?(?:(?:static|partial|abstract|sealed|readonly|file|new|unsafe)\s+)*(?:record(?:\s+class)?\s+|class\s+)" + Regex.Escape(recordName) + @"\b", RegexOptions.CultureInvariant); + } + + if (lang == "kotlin") + { + return kind == "enum" + ? new Regex(@"^\s*(?public|private|protected|internal)?\s*(?:(?:abstract|data|sealed|open|inner|value|annotation|expect|actual)\s+)*enum\s+class\s+" + Regex.Escape(recordName) + @"\b", RegexOptions.CultureInvariant) + : new Regex(@"^\s*(?public|private|protected|internal)?\s*(?:(?:abstract|data|sealed|open|inner|value|annotation|expect|actual)\s+)*(?:class|object)\s+" + Regex.Escape(recordName) + @"\b", RegexOptions.CultureInvariant); + } + + return new Regex(@"^\s*(?:public|private|protected)?\s*(?:(?:static|final|abstract|sealed|non-sealed|strictfp)\s+)*record\s+" + Regex.Escape(recordName) + @"\b", RegexOptions.CultureInvariant); + } + + private static int FindCSharpPrimaryConstructorParameterListStart(string declaration, int startIndex) + { + var index = Math.Max(0, startIndex); + if (!SkipCSharpGenericTypeParameterList(declaration, ref index)) + return -1; + + while (index < declaration.Length && char.IsWhiteSpace(declaration[index])) + index++; + + return index < declaration.Length && declaration[index] == '(' + ? index + : -1; + } + + private static bool SkipCSharpGenericTypeParameterList(string declaration, ref int index) + { + if (index >= declaration.Length || declaration[index] != '<') + return true; + + var depth = 0; + for (; index < declaration.Length; index++) + { + var ch = declaration[index]; + if (ch == '<') + depth++; + else if (ch == '>') + { + depth--; + if (depth == 0) + { + index++; + return true; + } + } + } + + return false; + } + + private static int FindRecordPrimaryComponentListStart(string declaration, int startIndex) + { + var angleDepth = 0; + for (int i = Math.Max(0, startIndex); i < declaration.Length; i++) + { + var ch = declaration[i]; + if (ch == '<') + { + angleDepth++; + continue; + } + + if (ch == '>') + { + if (angleDepth > 0) + angleDepth--; + continue; + } + + if (ch == '(' && angleDepth == 0) + return i; + } + + return -1; + } + + private static int FindMatchingRecordPrimaryComponentListEnd(string declaration, int openIndex) + { + var parenDepth = 0; + var angleDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var inLineComment = false; + var inBlockComment = false; + var inSingleQuote = false; + var inDoubleQuote = false; + var escapeNext = false; + + for (int i = openIndex; i < declaration.Length; i++) + { + var ch = declaration[i]; + var next = i + 1 < declaration.Length ? declaration[i + 1] : '\0'; + + if (inLineComment) + { + if (ch == '\n') + inLineComment = false; + continue; + } + + if (inBlockComment) + { + if (ch == '*' && next == '/') + { + inBlockComment = false; + i++; + } + + continue; + } + + if (escapeNext) + { + escapeNext = false; + continue; + } + + if (inSingleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '\'') + inSingleQuote = false; + continue; + } + + if (inDoubleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '"') + inDoubleQuote = false; + continue; + } + + switch (ch) + { + case '\'': + inSingleQuote = true; + continue; + case '"': + inDoubleQuote = true; + continue; + case '/' when next == '/': + inLineComment = true; + i++; + continue; + case '/' when next == '*': + inBlockComment = true; + i++; + continue; + case '(': + parenDepth++; + continue; + case ')': + parenDepth--; + if (parenDepth == 0) + return i; + continue; + case '<' when LooksLikeRecordGenericAngleStart(declaration, i): + angleDepth++; + continue; + case '>': + if (angleDepth > 0) + angleDepth--; + continue; + case '[': + bracketDepth++; + continue; + case ']': + if (bracketDepth > 0) + bracketDepth--; + continue; + case '{': + braceDepth++; + continue; + case '}': + if (braceDepth > 0) + braceDepth--; + continue; + } + } + + return -1; + } + + private static int FindRecordDeclarationTerminatorIndex(string declaration, int startIndex) + { + var parenDepth = 0; + var angleDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var inLineComment = false; + var inBlockComment = false; + var inSingleQuote = false; + var inDoubleQuote = false; + var escapeNext = false; + + for (int i = Math.Max(0, startIndex); i < declaration.Length; i++) + { + var ch = declaration[i]; + var next = i + 1 < declaration.Length ? declaration[i + 1] : '\0'; + + if (inLineComment) + { + if (ch == '\n') + inLineComment = false; + continue; + } + + if (inBlockComment) + { + if (ch == '*' && next == '/') + { + inBlockComment = false; + i++; + } + + continue; + } + + if (escapeNext) + { + escapeNext = false; + continue; + } + + if (inSingleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '\'') + inSingleQuote = false; + continue; + } + + if (inDoubleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '"') + inDoubleQuote = false; + continue; + } + + switch (ch) + { + case '\'': + inSingleQuote = true; + continue; + case '"': + inDoubleQuote = true; + continue; + case '/' when next == '/': + inLineComment = true; + i++; + continue; + case '/' when next == '*': + inBlockComment = true; + i++; + continue; + case '(': + parenDepth++; + continue; + case ')': + if (parenDepth > 0) + parenDepth--; + continue; + case '<' when LooksLikeRecordGenericAngleStart(declaration, i): + angleDepth++; + continue; + case '>': + if (angleDepth > 0) + angleDepth--; + continue; + case '[': + bracketDepth++; + continue; + case ']': + if (bracketDepth > 0) + bracketDepth--; + continue; + case '{' when parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 && braceDepth == 0: + return i; + case '{': + braceDepth++; + continue; + case '}': + if (braceDepth > 0) + braceDepth--; + continue; + case ';' when parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 && braceDepth == 0: + return i; + } + } + + return -1; + } + + private static IEnumerable SplitTopLevelRecordPrimaryComponents(string parameterList, int firstLineNumber) + { + var builder = new StringBuilder(); + var parenDepth = 0; + var angleDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var inSingleQuote = false; + var inDoubleQuote = false; + var escapeNext = false; + var currentLineNumber = firstLineNumber; + var componentLineNumber = firstLineNumber; + var componentHasToken = false; + + for (int index = 0; index < parameterList.Length; index++) + { + var ch = parameterList[index]; + if (!componentHasToken && !char.IsWhiteSpace(ch)) + { + componentHasToken = true; + componentLineNumber = currentLineNumber; + } + + if (escapeNext) + { + builder.Append(ch); + escapeNext = false; + if (ch == '\n') + currentLineNumber++; + continue; + } + + if (inSingleQuote) + { + builder.Append(ch); + if (ch == '\\') + escapeNext = true; + else if (ch == '\'') + inSingleQuote = false; + else if (ch == '\n') + currentLineNumber++; + continue; + } + + if (inDoubleQuote) + { + builder.Append(ch); + if (ch == '\\') + escapeNext = true; + else if (ch == '"') + inDoubleQuote = false; + else if (ch == '\n') + currentLineNumber++; + continue; + } + + switch (ch) + { + case '\'': + inSingleQuote = true; + builder.Append(ch); + continue; + case '"': + inDoubleQuote = true; + builder.Append(ch); + continue; + case '(': + parenDepth++; + builder.Append(ch); + continue; + case ')': + if (parenDepth > 0) + parenDepth--; + builder.Append(ch); + continue; + case '<' when LooksLikeRecordGenericAngleStart(parameterList, index): + angleDepth++; + builder.Append(ch); + continue; + case '>': + if (angleDepth > 0) + angleDepth--; + builder.Append(ch); + continue; + case '[': + bracketDepth++; + builder.Append(ch); + continue; + case ']': + if (bracketDepth > 0) + bracketDepth--; + builder.Append(ch); + continue; + case '{': + braceDepth++; + builder.Append(ch); + continue; + case '}': + if (braceDepth > 0) + braceDepth--; + builder.Append(ch); + continue; + case ',' when parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 && braceDepth == 0: + var component = builder.ToString().Trim(); + if (component.Length > 0) + yield return new RecordPrimaryComponentSlice(component, componentLineNumber); + builder.Clear(); + componentHasToken = false; + componentLineNumber = currentLineNumber; + continue; + default: + builder.Append(ch); + if (ch == '\n') + currentLineNumber++; + continue; + } + } + + var trailingComponent = builder.ToString().Trim(); + if (trailingComponent.Length > 0) + yield return new RecordPrimaryComponentSlice(trailingComponent, componentLineNumber); + } + + private static string StripRecordComponentComments(string text) + { + var builder = new StringBuilder(text.Length); + var inLineComment = false; + var inBlockComment = false; + var inSingleQuote = false; + var inDoubleQuote = false; + var escapeNext = false; + + for (int i = 0; i < text.Length; i++) + { + var ch = text[i]; + var next = i + 1 < text.Length ? text[i + 1] : '\0'; + + if (inLineComment) + { + if (ch == '\n') + { + inLineComment = false; + builder.Append(ch); + } + + continue; + } + + if (inBlockComment) + { + if (ch == '*' && next == '/') + { + inBlockComment = false; + i++; + builder.Append(' '); + } + else if (ch == '\n') + { + builder.Append(ch); + } + + continue; + } + + if (escapeNext) + { + builder.Append(ch); + escapeNext = false; + continue; + } + + if (inSingleQuote) + { + builder.Append(ch); + if (ch == '\\') + escapeNext = true; + else if (ch == '\'') + inSingleQuote = false; + continue; + } + + if (inDoubleQuote) + { + builder.Append(ch); + if (ch == '\\') + escapeNext = true; + else if (ch == '"') + inDoubleQuote = false; + continue; + } + + if (ch == '\'') + { + inSingleQuote = true; + builder.Append(ch); + continue; + } + + if (ch == '"') + { + inDoubleQuote = true; + builder.Append(ch); + continue; + } + + if (ch == '/' && next == '/') + { + inLineComment = true; + i++; + continue; + } + + if (ch == '/' && next == '*') + { + inBlockComment = true; + i++; + continue; + } + + builder.Append(ch); + } + + return builder.ToString(); + } + + private static bool TryParseRecordPrimaryComponent(string lang, RecordPrimaryComponentSlice rawComponent, out RecordPrimaryComponent component) + { + component = default; + if (string.IsNullOrWhiteSpace(rawComponent.Text)) + return false; + + var normalized = TrimAfterTopLevelEquals(rawComponent.Text).Trim(); + if (normalized.Length == 0) + return false; + + var componentLine = rawComponent.Line; + string? visibility = null; + if (lang == "kotlin") + { + var stripped = StripLeadingJavaRecordComponentAnnotations(normalized, allowKotlinUseSiteTargets: true); + normalized = stripped.Text; + componentLine += stripped.ConsumedNewlines; + + stripped = StripLeadingKotlinConstructorPropertyModifiers(normalized, out visibility); + normalized = stripped.Text; + componentLine += stripped.ConsumedNewlines; + + if (!StartsWithKotlinPropertyKeyword(normalized, out var propertyKeywordLength)) + return false; + + normalized = normalized[propertyKeywordLength..]; + var keywordWhitespaceConsumed = 0; + normalized = TrimLeadingWhitespaceAndCountNewlines(normalized, ref keywordWhitespaceConsumed); + componentLine += keywordWhitespaceConsumed; + + var separatorIndex = FindKotlinPropertyTypeSeparatorIndex(normalized); + if (separatorIndex <= 0) + return false; + + var kotlinComponentName = normalized[..separatorIndex].Trim(); + var kotlinComponentType = normalized[(separatorIndex + 1)..].Trim(); + if (kotlinComponentName.Length == 0 || kotlinComponentType.Length == 0) + return false; + + component = new RecordPrimaryComponent(kotlinComponentName, kotlinComponentType, normalized, componentLine, visibility); + return true; + } + else + { + var stripped = lang == "csharp" + ? StripLeadingCSharpRecordComponentAttributes(normalized) + : StripLeadingJavaRecordComponentAnnotations(normalized, allowKotlinUseSiteTargets: lang == "kotlin"); + normalized = stripped.Text; + componentLine += stripped.ConsumedNewlines; + + stripped = StripLeadingRecordComponentModifiers(lang, normalized); + normalized = stripped.Text; + componentLine += stripped.ConsumedNewlines; + } + if (normalized.Length == 0) + return false; + + var nameMatch = Regex.Match(normalized, @"(?@?[\p{L}_$][\p{L}\p{Nd}_$]*)\s*$", RegexOptions.CultureInvariant); + if (!nameMatch.Success) + return false; + + var componentName = nameMatch.Groups["name"].Value.TrimStart('@'); + var componentType = normalized[..nameMatch.Index].Trim(); + if (componentName.Length == 0 || componentType.Length == 0) + return false; + + component = new RecordPrimaryComponent(componentName, componentType, normalized, componentLine, visibility); + return true; + } + + private static string TrimAfterTopLevelEquals(string text) + { + var parenDepth = 0; + var angleDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var inSingleQuote = false; + var inDoubleQuote = false; + var escapeNext = false; + + for (int i = 0; i < text.Length; i++) + { + var ch = text[i]; + if (escapeNext) + { + escapeNext = false; + continue; + } + + if (inSingleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '\'') + inSingleQuote = false; + continue; + } + + if (inDoubleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '"') + inDoubleQuote = false; + continue; + } + + switch (ch) + { + case '\'': + inSingleQuote = true; + continue; + case '"': + inDoubleQuote = true; + continue; + case '(': + parenDepth++; + continue; + case ')': + if (parenDepth > 0) + parenDepth--; + continue; + case '<' when LooksLikeRecordGenericAngleStart(text, i): + angleDepth++; + continue; + case '>': + if (angleDepth > 0) + angleDepth--; + continue; + case '[': + bracketDepth++; + continue; + case ']': + if (bracketDepth > 0) + bracketDepth--; + continue; + case '{': + braceDepth++; + continue; + case '}': + if (braceDepth > 0) + braceDepth--; + continue; + case '=' when parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 && braceDepth == 0: + return SliceCSharpTrimEnd(text, i); + } + } + + return text; + } + + private static StrippedRecordComponentText StripLeadingCSharpRecordComponentAttributes(string component) + { + var consumedNewlines = 0; + var trimmed = TrimLeadingWhitespaceAndCountNewlines(component, ref consumedNewlines); + while (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + var endIndex = FindMatchingBracket(trimmed, 0, '[', ']'); + if (endIndex < 0) + return new(component.Trim(), 0); + + consumedNewlines += CountNewlines(trimmed.AsSpan(0, endIndex + 1)); + trimmed = TrimLeadingWhitespaceAndCountNewlines(trimmed[(endIndex + 1)..], ref consumedNewlines); + } + + return new(trimmed, consumedNewlines); + } + + private static bool LooksLikeRecordGenericAngleStart(string text, int index) + { + if (index < 0 || index >= text.Length || text[index] != '<') + return false; + + var previousIndex = FindPreviousNonWhitespaceIndex(text, index - 1); + if (previousIndex < 0) + return false; + + var nextIndex = FindNextNonWhitespaceIndex(text, index + 1); + if (nextIndex < 0) + return false; + + var previous = text[previousIndex]; + var next = text[nextIndex]; + if (!IsRecordGenericAnglePredecessor(previous) + || !IsRecordGenericAngleSuccessor(next)) + { + return false; + } + + return TryFindRecordGenericAngleEnd(text, index, out _); + } + + private static bool IsRecordGenericAnglePredecessor(char ch) => + char.IsLetterOrDigit(ch) + || ch is '_' or '$' or '@' or '.' or '>' or ')' or ']' or '?'; + + private static bool IsRecordGenericAngleSuccessor(char ch) => + char.IsLetter(ch) + || ch is '_' or '$' or '@' or '?' or '('; + + private static int FindPreviousNonWhitespaceIndex(string text, int index) + { + for (int i = Math.Min(index, text.Length - 1); i >= 0; i--) + { + if (!char.IsWhiteSpace(text[i])) + return i; + } + + return -1; + } + + private static int FindNextNonWhitespaceIndex(string text, int index) + { + for (int i = Math.Max(0, index); i < text.Length; i++) + { + if (!char.IsWhiteSpace(text[i])) + return i; + } + + return -1; + } + + private static bool TryFindRecordGenericAngleEnd(string text, int openIndex, out int closeIndex) + { + closeIndex = -1; + + var angleDepth = 0; + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var inLineComment = false; + var inBlockComment = false; + var inSingleQuote = false; + var inDoubleQuote = false; + var escapeNext = false; + + for (int i = openIndex; i < text.Length; i++) + { + var ch = text[i]; + var next = i + 1 < text.Length ? text[i + 1] : '\0'; + + if (inLineComment) + { + if (ch == '\n') + inLineComment = false; + continue; + } + + if (inBlockComment) + { + if (ch == '*' && next == '/') + { + inBlockComment = false; + i++; + } + + continue; + } + + if (escapeNext) + { + escapeNext = false; + continue; + } + + if (inSingleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '\'') + inSingleQuote = false; + continue; + } + + if (inDoubleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '"') + inDoubleQuote = false; + continue; + } + + switch (ch) + { + case '\'': + inSingleQuote = true; + continue; + case '"': + inDoubleQuote = true; + continue; + case '/' when next == '/': + inLineComment = true; + i++; + continue; + case '/' when next == '*': + inBlockComment = true; + i++; + continue; + case '(': + parenDepth++; + continue; + case ')': + if (parenDepth > 0) + parenDepth--; + continue; + case '[': + bracketDepth++; + continue; + case ']': + if (bracketDepth > 0) + bracketDepth--; + continue; + case '{': + braceDepth++; + continue; + case '}': + if (braceDepth > 0) + braceDepth--; + continue; + case '<' when i == openIndex || LooksLikeRecordGenericAngleCandidate(text, i): + angleDepth++; + continue; + case '>': + if (angleDepth == 0) + continue; + + angleDepth--; + if (angleDepth == 0) + { + if (!IsRecordGenericAnglePayloadTypeLike(text.AsSpan(openIndex + 1, i - openIndex - 1))) + return false; + + closeIndex = i; + return true; + } + + continue; + } + } + + return false; + } + + private static bool LooksLikeRecordGenericAngleCandidate(string text, int index) + { + if (index < 0 || index >= text.Length || text[index] != '<') + return false; + + var previousIndex = FindPreviousNonWhitespaceIndex(text, index - 1); + if (previousIndex < 0) + return false; + + var nextIndex = FindNextNonWhitespaceIndex(text, index + 1); + if (nextIndex < 0) + return false; + + return IsRecordGenericAnglePredecessor(text[previousIndex]) + && IsRecordGenericAngleSuccessor(text[nextIndex]); + } + + private static bool IsRecordGenericAnglePayloadTypeLike(ReadOnlySpan text) + { + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var inLineComment = false; + var inBlockComment = false; + var inSingleQuote = false; + var inDoubleQuote = false; + var escapeNext = false; + + for (int i = 0; i < text.Length; i++) + { + var ch = text[i]; + var next = i + 1 < text.Length ? text[i + 1] : '\0'; + + if (inLineComment) + { + if (ch == '\n') + inLineComment = false; + continue; + } + + if (inBlockComment) + { + if (ch == '*' && next == '/') + { + inBlockComment = false; + i++; + } + + continue; + } + + if (escapeNext) + { + escapeNext = false; + continue; + } + + if (inSingleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '\'') + inSingleQuote = false; + continue; + } + + if (inDoubleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '"') + inDoubleQuote = false; + continue; + } + + switch (ch) + { + case '\'': + inSingleQuote = true; + continue; + case '"': + inDoubleQuote = true; + continue; + case '/' when next == '/': + inLineComment = true; + i++; + continue; + case '/' when next == '*': + inBlockComment = true; + i++; + continue; + case '(': + parenDepth++; + continue; + case ')': + if (parenDepth > 0) + parenDepth--; + continue; + case '[': + bracketDepth++; + continue; + case ']': + if (bracketDepth > 0) + bracketDepth--; + continue; + case '{': + braceDepth++; + continue; + case '}': + if (braceDepth > 0) + braceDepth--; + continue; + } + + if (parenDepth == 0 + && bracketDepth == 0 + && braceDepth == 0 + && ch is '=' or '+' or '-' or '*' or '/' or '%' or '&' or '|' or '!' or '^' or '~' or ';') + { + return false; + } + } + + return true; + } + + private static StrippedRecordComponentText StripLeadingJavaRecordComponentAnnotations(string component, bool allowKotlinUseSiteTargets) + { + var consumedNewlines = 0; + var trimmed = TrimLeadingWhitespaceAndCountNewlines(component, ref consumedNewlines); + while (trimmed.StartsWith("@", StringComparison.Ordinal)) + { + var index = 1; + while (index < trimmed.Length && (char.IsLetterOrDigit(trimmed[index]) || trimmed[index] is '_' or '$' or '.')) + index++; + + if (allowKotlinUseSiteTargets && index < trimmed.Length && trimmed[index] == ':' && KotlinAnnotationTargets.Contains(trimmed[1..index])) + { + index++; + while (index < trimmed.Length && char.IsWhiteSpace(trimmed[index])) + index++; + while (index < trimmed.Length && (char.IsLetterOrDigit(trimmed[index]) || trimmed[index] is '_' or '$' or '.')) + index++; + } + + if (index < trimmed.Length && trimmed[index] == ':') + { + index++; + while (index < trimmed.Length && char.IsWhiteSpace(trimmed[index])) + index++; + while (index < trimmed.Length && (char.IsLetterOrDigit(trimmed[index]) || trimmed[index] is '_' or '$' or '.')) + index++; + } + + while (index < trimmed.Length && char.IsWhiteSpace(trimmed[index])) + index++; + + if (index < trimmed.Length && trimmed[index] == '(') + { + var endIndex = FindMatchingBracket(trimmed, index, '(', ')'); + if (endIndex < 0) + return new(component.Trim(), 0); + + index = endIndex + 1; + } + + consumedNewlines += CountNewlines(trimmed.AsSpan(0, index)); + trimmed = TrimLeadingWhitespaceAndCountNewlines(trimmed[index..], ref consumedNewlines); + } + + return new(trimmed, consumedNewlines); + } + + private static StrippedRecordComponentText StripLeadingKotlinConstructorPropertyModifiers( + string component, + out string? visibility) + { + visibility = null; + var consumedNewlines = 0; + var trimmed = TrimLeadingWhitespaceAndCountNewlines(component, ref consumedNewlines); + string[] modifiers = ["public", "private", "protected", "internal", "vararg"]; + var removedModifier = true; + while (removedModifier) + { + removedModifier = false; + foreach (var modifier in modifiers) + { + if (trimmed.StartsWith(modifier, StringComparison.Ordinal) + && trimmed.Length > modifier.Length + && char.IsWhiteSpace(trimmed[modifier.Length])) + { + if (modifier is "public" or "private" or "protected" or "internal") + visibility ??= modifier; + + trimmed = TrimLeadingWhitespaceAndCountNewlines(trimmed[(modifier.Length + 1)..], ref consumedNewlines); + removedModifier = true; + break; + } + } + } + + return new(trimmed, consumedNewlines); + } + + private static int FindKotlinPropertyTypeSeparatorIndex(string text) + { + var parenDepth = 0; + var angleDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var inSingleQuote = false; + var inDoubleQuote = false; + var escapeNext = false; + + for (int i = 0; i < text.Length; i++) + { + var ch = text[i]; + if (escapeNext) + { + escapeNext = false; + continue; + } + + if (inSingleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '\'') + inSingleQuote = false; + continue; + } + + if (inDoubleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '"') + inDoubleQuote = false; + continue; + } + + switch (ch) + { + case '\'': + inSingleQuote = true; + continue; + case '"': + inDoubleQuote = true; + continue; + case '(': + parenDepth++; + continue; + case ')': + if (parenDepth > 0) + parenDepth--; + continue; + case '<': + angleDepth++; + continue; + case '>': + if (angleDepth > 0) + angleDepth--; + continue; + case '[': + bracketDepth++; + continue; + case ']': + if (bracketDepth > 0) + bracketDepth--; + continue; + case '{': + braceDepth++; + continue; + case '}': + if (braceDepth > 0) + braceDepth--; + continue; + case ':' when parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 && braceDepth == 0: + return i; + } + } + + return -1; + } + + private static bool StartsWithKotlinPropertyKeyword(string text, out int keywordLength) + { + if (text.StartsWith("val", StringComparison.Ordinal) + && (text.Length == 3 || !IsKotlinPropertyKeywordPart(text[3]))) + { + keywordLength = 3; + return true; + } + + if (text.StartsWith("var", StringComparison.Ordinal) + && (text.Length == 3 || !IsKotlinPropertyKeywordPart(text[3]))) + { + keywordLength = 3; + return true; + } + + keywordLength = 0; + return false; + } + + private static bool IsKotlinPropertyKeywordPart(char ch) => + char.IsLetterOrDigit(ch) || ch == '_'; + + private static StrippedRecordComponentText StripLeadingRecordComponentModifiers(string lang, string component) + { + ReadOnlySpan modifiers = lang == "csharp" + ? ["params", "this", "ref", "out", "in", "scoped", "readonly"] + : ["final"]; + + var consumedNewlines = 0; + var trimmed = TrimLeadingWhitespaceAndCountNewlines(component, ref consumedNewlines); + var removedModifier = true; + while (removedModifier) + { + removedModifier = false; + foreach (var modifier in modifiers) + { + if (trimmed.StartsWith(modifier, StringComparison.Ordinal) + && trimmed.Length > modifier.Length + && char.IsWhiteSpace(trimmed[modifier.Length])) + { + trimmed = TrimLeadingWhitespaceAndCountNewlines(trimmed[(modifier.Length + 1)..], ref consumedNewlines); + removedModifier = true; + break; + } + } + } + + return new(trimmed, consumedNewlines); + } + + private static string TrimLeadingWhitespaceAndCountNewlines(string text, ref int consumedNewlines) + { + var index = 0; + while (index < text.Length && char.IsWhiteSpace(text[index])) + { + if (text[index] == '\n') + consumedNewlines++; + index++; + } + + return text[index..]; + } + + private static int CountNewlines(ReadOnlySpan text) + { + var count = 0; + foreach (var ch in text) + { + if (ch == '\n') + count++; + } + + return count; + } + + private static int FindMatchingBracket(string text, int openIndex, char openBracket, char closeBracket) + { + var depth = 0; + var inSingleQuote = false; + var inDoubleQuote = false; + var escapeNext = false; + + for (int i = openIndex; i < text.Length; i++) + { + var ch = text[i]; + if (escapeNext) + { + escapeNext = false; + continue; + } + + if (inSingleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '\'') + inSingleQuote = false; + continue; + } + + if (inDoubleQuote) + { + if (ch == '\\') + escapeNext = true; + else if (ch == '"') + inDoubleQuote = false; + continue; + } + + if (ch == '\'') + { + inSingleQuote = true; + continue; + } + + if (ch == '"') + { + inDoubleQuote = true; + continue; + } + + if (ch == openBracket) + { + depth++; + continue; + } + + if (ch == closeBracket) + { + depth--; + if (depth == 0) + return i; + } + } + + return -1; + } +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index d9ffacc88..e97102c04 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -643,85 +643,6 @@ private enum CSharpAccessorProbeStatus Rejected } - private readonly record struct RecordPrimaryComponent( - string Name, - string Type, - string Signature, - int Line, - string? Visibility = null); - - private readonly record struct RecordPrimaryComponentSlice( - string Text, - int Line); - - private readonly record struct PendingRecordPrimaryComponents( - long FileId, - string Kind, - string RecordName, - int RecordStartLine, - List Components); - - private readonly record struct RecordPrimaryComponentParentKey( - long FileId, - string Kind, - string Name, - int StartLine); - - private readonly record struct RecordPrimaryComponentPropertyKey( - long FileId, - string ContainerKind, - string ContainerName); - - private sealed class RecordPrimaryComponentParentIndex - { - private readonly Dictionary _parents = []; - private int _indexedCount; - private SymbolRecord? _lastIndexedSymbol; - - public SymbolRecord? FindLast( - List symbols, - RecordPrimaryComponentParentKey key) - { - Synchronize(symbols); - return _parents.TryGetValue(key, out var parent) ? parent : null; - } - - private void Synchronize(List symbols) - { - // AddSymbolRecord can remove declaration-only functions from the list tail. - // Rebuild if that invalidated the indexed boundary; otherwise consume only - // symbols appended since the previous record declaration. - // AddSymbolRecord は末尾の declaration-only function を除くことがあるため、 - // index境界が無効なら再構築し、それ以外は追加分だけを取り込む。 - if (_indexedCount > symbols.Count - || (_indexedCount > 0 - && !ReferenceEquals(symbols[_indexedCount - 1], _lastIndexedSymbol))) - { - _parents.Clear(); - _indexedCount = 0; - } - - for (var index = _indexedCount; index < symbols.Count; index++) - { - var symbol = symbols[index]; - if (symbol.Kind is not ("class" or "struct" or "enum")) - continue; - - _parents[new RecordPrimaryComponentParentKey( - symbol.FileId, - symbol.Kind, - symbol.Name, - symbol.StartLine)] = symbol; - } - - _indexedCount = symbols.Count; - _lastIndexedSymbol = symbols.Count > 0 ? symbols[^1] : null; - } - } - - private readonly record struct StrippedRecordComponentText( - string Text, - int ConsumedNewlines); private readonly record struct JavaScriptClassScanTarget( int StartIndex, @@ -3689,1630 +3610,6 @@ private static bool IsCSharpKeywordAt(string line, int index, string keyword) return next != '_' && !char.IsLetterOrDigit(next); } - private static void CollectRecordPrimaryComponentSymbols( - long fileId, - string lang, - string[] lines, - int declarationLineIndex, - int declarationStartColumn, - string kind, - string recordName, - ref List? pendingRecordPrimaryComponents, - ref RecordPrimaryComponentParentIndex? parentIndex, - List symbols) - { - if (lang == "kotlin") - { - if (kind is not "class" and not "enum") - return; - } - else if (kind is not "class" and not "struct") - { - return; - } - - if (!TryGetRecordPrimaryComponents( - lang, - lines, - declarationLineIndex, - declarationStartColumn, - kind, - recordName, - out var components, - out var declarationEndLine)) - return; - - var parentKey = new RecordPrimaryComponentParentKey( - fileId, - kind, - recordName, - declarationLineIndex + 1); - var parentSymbol = (parentIndex ??= new RecordPrimaryComponentParentIndex()) - .FindLast(symbols, parentKey); - if (parentSymbol != null) - parentSymbol.EndLine = Math.Max(parentSymbol.EndLine, declarationEndLine); - - if (components.Count > 0) - { - (pendingRecordPrimaryComponents ??= []).Add(new PendingRecordPrimaryComponents( - fileId, - kind, - recordName, - declarationLineIndex + 1, - components)); - } - } - - private static void MaterializeRecordPrimaryComponentSymbols( - List symbols, - List? pendingRecordPrimaryComponents) - { - if (pendingRecordPrimaryComponents is not { Count: > 0 }) - return; - - var parents = new Dictionary(); - var propertyBuckets = new Dictionary>(); - foreach (var pending in pendingRecordPrimaryComponents) - { - parents.TryAdd( - new RecordPrimaryComponentParentKey( - pending.FileId, - pending.Kind, - pending.RecordName, - pending.RecordStartLine), - null); - propertyBuckets.TryAdd( - new RecordPrimaryComponentPropertyKey( - pending.FileId, - pending.Kind, - pending.RecordName), - []); - } - - // Build both final-state indexes after AssignContainers. Forward overwrite preserves - // the previous reverse-search "last parent wins" rule, and property buckets retain - // source-list order for overlapping/nested parent ranges. - // AssignContainers 後の最終状態から両indexを構築する。forward上書きで従来の - // 「最後のparent優先」を保ち、property bucketは重複range向けにlist順を保つ。 - foreach (var symbol in symbols) - { - var parentKey = new RecordPrimaryComponentParentKey( - symbol.FileId, - symbol.Kind, - symbol.Name, - symbol.StartLine); - if (parents.ContainsKey(parentKey)) - parents[parentKey] = symbol; - - if (symbol.Kind == "property" - && symbol.ContainerKind != null - && symbol.ContainerName != null - && propertyBuckets.TryGetValue( - new RecordPrimaryComponentPropertyKey( - symbol.FileId, - symbol.ContainerKind, - symbol.ContainerName), - out var propertyBucket)) - { - propertyBucket.Add(symbol); - } - } - - foreach (var pending in pendingRecordPrimaryComponents) - { - var parentKey = new RecordPrimaryComponentParentKey( - pending.FileId, - pending.Kind, - pending.RecordName, - pending.RecordStartLine); - var parentSymbol = parents[parentKey]; - if (parentSymbol == null) - continue; - - var propertyKey = new RecordPrimaryComponentPropertyKey( - pending.FileId, - pending.Kind, - pending.RecordName); - var propertyBucket = propertyBuckets[propertyKey]; - var existingComponentNames = BuildExistingRecordPrimaryComponentNameSet(propertyBucket, parentSymbol); - - foreach (var component in pending.Components) - { - if (!existingComponentNames.Add(component.Name)) - continue; - - var componentSymbol = new SymbolRecord - { - FileId = pending.FileId, - Kind = "property", - Name = component.Name, - Line = component.Line, - StartLine = component.Line, - EndLine = component.Line, - Signature = component.Signature, - ContainerKind = pending.Kind, - ContainerName = pending.RecordName, - Visibility = component.Visibility ?? "public", - ReturnType = component.Type, - }; - symbols.Add(componentSymbol); - propertyBucket.Add(componentSymbol); - } - } - } - - private static HashSet BuildExistingRecordPrimaryComponentNameSet( - IReadOnlyList propertyCandidates, - SymbolRecord parentSymbol) - { - var existingComponentNames = new HashSet(StringComparer.Ordinal); - foreach (var symbol in propertyCandidates) - { - if (symbol.StartLine >= parentSymbol.StartLine - && symbol.EndLine <= parentSymbol.EndLine) - { - existingComponentNames.Add(symbol.Name); - } - } - - return existingComponentNames; - } - - private static bool TryGetRecordPrimaryComponents( - string lang, - string[] lines, - int declarationLineIndex, - int declarationStartColumn, - string kind, - string recordName, - out List components, - out int declarationEndLine) - { - components = null!; - declarationEndLine = declarationLineIndex + 1; - - if (lang is not "csharp" and not "java" and not "kotlin") - return false; - - var declaration = lang == "kotlin" - ? CollectKotlinPrimaryConstructorDeclarationText(lines, declarationLineIndex, declarationStartColumn) - : CollectRecordDeclarationText(lines, declarationLineIndex, declarationStartColumn); - if (string.IsNullOrWhiteSpace(declaration)) - return false; - - var recordRegex = GetCurrentDeclarationRecordRegex(lang, kind, recordName); - var javaLeadingAnnotationOffset = 0; - var recordMatch = lang is "java" or "kotlin" - ? (TryMatchJavaDeclarationSegment(recordRegex, declaration, lang == "kotlin", out var javaRecordMatch, out javaLeadingAnnotationOffset) - ? javaRecordMatch - : recordRegex.Match(declaration)) - : recordRegex.Match(declaration); - if (!recordMatch.Success) - return false; - - var parameterOpenIndex = lang == "csharp" - ? FindCSharpPrimaryConstructorParameterListStart(declaration, recordMatch.Index + recordMatch.Length) - : FindRecordPrimaryComponentListStart( - declaration, - recordMatch.Index + recordMatch.Length + javaLeadingAnnotationOffset); - if (parameterOpenIndex < 0) - return false; - - var parameterCloseIndex = FindMatchingRecordPrimaryComponentListEnd(declaration, parameterOpenIndex); - if (parameterCloseIndex <= parameterOpenIndex) - return false; - var declarationTerminatorIndex = FindRecordDeclarationTerminatorIndex(declaration, parameterCloseIndex + 1); - var declarationLineSpanEnd = declarationTerminatorIndex >= 0 ? declarationTerminatorIndex + 1 : parameterCloseIndex + 1; - declarationEndLine = declarationLineIndex + 1 + declaration[..declarationLineSpanEnd].Count(ch => ch == '\n'); - - var rawParameterList = StripRecordComponentComments(declaration[(parameterOpenIndex + 1)..parameterCloseIndex]); - components = []; - foreach (var rawComponent in SplitTopLevelRecordPrimaryComponents(rawParameterList, declarationLineIndex + 1)) - { - if (TryParseRecordPrimaryComponent(lang, rawComponent, out var component)) - components.Add(component); - } - - return true; - } - - private static string CollectRecordDeclarationText(string[] lines, int declarationLineIndex, int declarationStartColumn) - { - var builder = new System.Text.StringBuilder(); - var parameterOpenIndex = -1; - var parameterCloseIndex = -1; - for (int i = declarationLineIndex; i < lines.Length; i++) - { - if (builder.Length > 0) - builder.Append('\n'); - - // Content was split on '\n', so CRLF lines carry a trailing '\r'. Strip it before - // appending so intermediate separators stay '\n' and the collected declaration - // text is stable across OS line endings (#405 follow-up to #382). - // content は '\n' で分割しているため、CRLF 行は末尾に '\r' が残る。行間の区切りを - // '\n' に揃え、OS 差分で collected text が変わらないよう '\r' を落として追加する - // (#382 に続く #405 対応)。 - var line = lines[i]; - var lineText = i == declarationLineIndex - ? line[Math.Min(declarationStartColumn, line.Length)..] - : line; - builder.Append(StripTrailingCr(lineText)); - - var declaration = builder.ToString(); - if (parameterOpenIndex < 0) - { - parameterOpenIndex = FindRecordPrimaryComponentListStart(declaration, 0); - if (parameterOpenIndex < 0) - { - if (FindRecordDeclarationTerminatorIndex(declaration, 0) >= 0) - return declaration; - continue; - } - } - - if (parameterCloseIndex < 0) - { - parameterCloseIndex = FindMatchingRecordPrimaryComponentListEnd(declaration, parameterOpenIndex); - if (parameterCloseIndex <= parameterOpenIndex) - continue; - } - - if (FindRecordDeclarationTerminatorIndex(declaration, parameterCloseIndex + 1) >= 0) - return declaration; - } - - return builder.ToString(); - } - - private static string CollectKotlinPrimaryConstructorDeclarationText(string[] lines, int declarationLineIndex, int declarationStartColumn) - { - const int KotlinPrimaryConstructorDeclarationLookaheadLineLimit = 32; - - var builder = new System.Text.StringBuilder(); - var parameterOpenIndex = -1; - var parameterCloseIndex = -1; - for (int i = declarationLineIndex; i < lines.Length && i < declarationLineIndex + KotlinPrimaryConstructorDeclarationLookaheadLineLimit; i++) - { - if (builder.Length > 0) - builder.Append('\n'); - - // Kotlin class headers may split across a few physical lines. Keep the collected - // declaration stable across line endings while bounding the scan so a class without - // a primary constructor does not cause a whole-file read. - // Kotlin の class ヘッダは数行に分割されうる。改行差を吸収しつつ、primary - // constructor を持たない class でファイル全体を走査しないよう、収集範囲を - // ほどよい行数に制限する。 - var line = lines[i]; - var lineText = i == declarationLineIndex - ? line[Math.Min(declarationStartColumn, line.Length)..] - : line; - builder.Append(StripTrailingCr(lineText)); - - var declaration = builder.ToString(); - if (parameterOpenIndex < 0) - { - parameterOpenIndex = FindRecordPrimaryComponentListStart(declaration, 0); - if (parameterOpenIndex < 0) - { - if (FindRecordDeclarationTerminatorIndex(declaration, 0) >= 0) - return declaration; - - continue; - } - } - - if (parameterCloseIndex < 0) - { - parameterCloseIndex = FindMatchingRecordPrimaryComponentListEnd(declaration, parameterOpenIndex); - if (parameterCloseIndex <= parameterOpenIndex) - continue; - } - - return declaration[..(parameterCloseIndex + 1)]; - } - - return builder.ToString(); - } - - private static Regex GetCurrentDeclarationRecordRegex(string lang, string kind, string recordName) - { - if (lang == "csharp") - { - return kind == "struct" - ? new Regex(@"^\s*(?:(?:public|private|protected\s+internal|private\s+protected|protected|internal)\s+)?(?:(?:static|partial|readonly|file|new|ref|unsafe)\s+)*(?:record\s+)?struct\s+" + Regex.Escape(recordName) + @"\b", RegexOptions.CultureInvariant) - : new Regex(@"^\s*(?:(?:public|private|protected\s+internal|private\s+protected|protected|internal)\s+)?(?:(?:static|partial|abstract|sealed|readonly|file|new|unsafe)\s+)*(?:record(?:\s+class)?\s+|class\s+)" + Regex.Escape(recordName) + @"\b", RegexOptions.CultureInvariant); - } - - if (lang == "kotlin") - { - return kind == "enum" - ? new Regex(@"^\s*(?public|private|protected|internal)?\s*(?:(?:abstract|data|sealed|open|inner|value|annotation|expect|actual)\s+)*enum\s+class\s+" + Regex.Escape(recordName) + @"\b", RegexOptions.CultureInvariant) - : new Regex(@"^\s*(?public|private|protected|internal)?\s*(?:(?:abstract|data|sealed|open|inner|value|annotation|expect|actual)\s+)*(?:class|object)\s+" + Regex.Escape(recordName) + @"\b", RegexOptions.CultureInvariant); - } - - return new Regex(@"^\s*(?:public|private|protected)?\s*(?:(?:static|final|abstract|sealed|non-sealed|strictfp)\s+)*record\s+" + Regex.Escape(recordName) + @"\b", RegexOptions.CultureInvariant); - } - - private static int FindCSharpPrimaryConstructorParameterListStart(string declaration, int startIndex) - { - var index = Math.Max(0, startIndex); - if (!SkipCSharpGenericTypeParameterList(declaration, ref index)) - return -1; - - while (index < declaration.Length && char.IsWhiteSpace(declaration[index])) - index++; - - return index < declaration.Length && declaration[index] == '(' - ? index - : -1; - } - - private static bool SkipCSharpGenericTypeParameterList(string declaration, ref int index) - { - if (index >= declaration.Length || declaration[index] != '<') - return true; - - var depth = 0; - for (; index < declaration.Length; index++) - { - var ch = declaration[index]; - if (ch == '<') - depth++; - else if (ch == '>') - { - depth--; - if (depth == 0) - { - index++; - return true; - } - } - } - - return false; - } - - private static int FindRecordPrimaryComponentListStart(string declaration, int startIndex) - { - var angleDepth = 0; - for (int i = Math.Max(0, startIndex); i < declaration.Length; i++) - { - var ch = declaration[i]; - if (ch == '<') - { - angleDepth++; - continue; - } - - if (ch == '>') - { - if (angleDepth > 0) - angleDepth--; - continue; - } - - if (ch == '(' && angleDepth == 0) - return i; - } - - return -1; - } - - private static int FindMatchingRecordPrimaryComponentListEnd(string declaration, int openIndex) - { - var parenDepth = 0; - var angleDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - var inLineComment = false; - var inBlockComment = false; - var inSingleQuote = false; - var inDoubleQuote = false; - var escapeNext = false; - - for (int i = openIndex; i < declaration.Length; i++) - { - var ch = declaration[i]; - var next = i + 1 < declaration.Length ? declaration[i + 1] : '\0'; - - if (inLineComment) - { - if (ch == '\n') - inLineComment = false; - continue; - } - - if (inBlockComment) - { - if (ch == '*' && next == '/') - { - inBlockComment = false; - i++; - } - - continue; - } - - if (escapeNext) - { - escapeNext = false; - continue; - } - - if (inSingleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '\'') - inSingleQuote = false; - continue; - } - - if (inDoubleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '"') - inDoubleQuote = false; - continue; - } - - switch (ch) - { - case '\'': - inSingleQuote = true; - continue; - case '"': - inDoubleQuote = true; - continue; - case '/' when next == '/': - inLineComment = true; - i++; - continue; - case '/' when next == '*': - inBlockComment = true; - i++; - continue; - case '(': - parenDepth++; - continue; - case ')': - parenDepth--; - if (parenDepth == 0) - return i; - continue; - case '<' when LooksLikeRecordGenericAngleStart(declaration, i): - angleDepth++; - continue; - case '>': - if (angleDepth > 0) - angleDepth--; - continue; - case '[': - bracketDepth++; - continue; - case ']': - if (bracketDepth > 0) - bracketDepth--; - continue; - case '{': - braceDepth++; - continue; - case '}': - if (braceDepth > 0) - braceDepth--; - continue; - } - } - - return -1; - } - - private static int FindRecordDeclarationTerminatorIndex(string declaration, int startIndex) - { - var parenDepth = 0; - var angleDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - var inLineComment = false; - var inBlockComment = false; - var inSingleQuote = false; - var inDoubleQuote = false; - var escapeNext = false; - - for (int i = Math.Max(0, startIndex); i < declaration.Length; i++) - { - var ch = declaration[i]; - var next = i + 1 < declaration.Length ? declaration[i + 1] : '\0'; - - if (inLineComment) - { - if (ch == '\n') - inLineComment = false; - continue; - } - - if (inBlockComment) - { - if (ch == '*' && next == '/') - { - inBlockComment = false; - i++; - } - - continue; - } - - if (escapeNext) - { - escapeNext = false; - continue; - } - - if (inSingleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '\'') - inSingleQuote = false; - continue; - } - - if (inDoubleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '"') - inDoubleQuote = false; - continue; - } - - switch (ch) - { - case '\'': - inSingleQuote = true; - continue; - case '"': - inDoubleQuote = true; - continue; - case '/' when next == '/': - inLineComment = true; - i++; - continue; - case '/' when next == '*': - inBlockComment = true; - i++; - continue; - case '(': - parenDepth++; - continue; - case ')': - if (parenDepth > 0) - parenDepth--; - continue; - case '<' when LooksLikeRecordGenericAngleStart(declaration, i): - angleDepth++; - continue; - case '>': - if (angleDepth > 0) - angleDepth--; - continue; - case '[': - bracketDepth++; - continue; - case ']': - if (bracketDepth > 0) - bracketDepth--; - continue; - case '{' when parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 && braceDepth == 0: - return i; - case '{': - braceDepth++; - continue; - case '}': - if (braceDepth > 0) - braceDepth--; - continue; - case ';' when parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 && braceDepth == 0: - return i; - } - } - - return -1; - } - - private static IEnumerable SplitTopLevelRecordPrimaryComponents(string parameterList, int firstLineNumber) - { - var builder = new System.Text.StringBuilder(); - var parenDepth = 0; - var angleDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - var inSingleQuote = false; - var inDoubleQuote = false; - var escapeNext = false; - var currentLineNumber = firstLineNumber; - var componentLineNumber = firstLineNumber; - var componentHasToken = false; - - for (int index = 0; index < parameterList.Length; index++) - { - var ch = parameterList[index]; - if (!componentHasToken && !char.IsWhiteSpace(ch)) - { - componentHasToken = true; - componentLineNumber = currentLineNumber; - } - - if (escapeNext) - { - builder.Append(ch); - escapeNext = false; - if (ch == '\n') - currentLineNumber++; - continue; - } - - if (inSingleQuote) - { - builder.Append(ch); - if (ch == '\\') - escapeNext = true; - else if (ch == '\'') - inSingleQuote = false; - else if (ch == '\n') - currentLineNumber++; - continue; - } - - if (inDoubleQuote) - { - builder.Append(ch); - if (ch == '\\') - escapeNext = true; - else if (ch == '"') - inDoubleQuote = false; - else if (ch == '\n') - currentLineNumber++; - continue; - } - - switch (ch) - { - case '\'': - inSingleQuote = true; - builder.Append(ch); - continue; - case '"': - inDoubleQuote = true; - builder.Append(ch); - continue; - case '(': - parenDepth++; - builder.Append(ch); - continue; - case ')': - if (parenDepth > 0) - parenDepth--; - builder.Append(ch); - continue; - case '<' when LooksLikeRecordGenericAngleStart(parameterList, index): - angleDepth++; - builder.Append(ch); - continue; - case '>': - if (angleDepth > 0) - angleDepth--; - builder.Append(ch); - continue; - case '[': - bracketDepth++; - builder.Append(ch); - continue; - case ']': - if (bracketDepth > 0) - bracketDepth--; - builder.Append(ch); - continue; - case '{': - braceDepth++; - builder.Append(ch); - continue; - case '}': - if (braceDepth > 0) - braceDepth--; - builder.Append(ch); - continue; - case ',' when parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 && braceDepth == 0: - var component = builder.ToString().Trim(); - if (component.Length > 0) - yield return new RecordPrimaryComponentSlice(component, componentLineNumber); - builder.Clear(); - componentHasToken = false; - componentLineNumber = currentLineNumber; - continue; - default: - builder.Append(ch); - if (ch == '\n') - currentLineNumber++; - continue; - } - } - - var trailingComponent = builder.ToString().Trim(); - if (trailingComponent.Length > 0) - yield return new RecordPrimaryComponentSlice(trailingComponent, componentLineNumber); - } - - private static string StripRecordComponentComments(string text) - { - var builder = new System.Text.StringBuilder(text.Length); - var inLineComment = false; - var inBlockComment = false; - var inSingleQuote = false; - var inDoubleQuote = false; - var escapeNext = false; - - for (int i = 0; i < text.Length; i++) - { - var ch = text[i]; - var next = i + 1 < text.Length ? text[i + 1] : '\0'; - - if (inLineComment) - { - if (ch == '\n') - { - inLineComment = false; - builder.Append(ch); - } - - continue; - } - - if (inBlockComment) - { - if (ch == '*' && next == '/') - { - inBlockComment = false; - i++; - builder.Append(' '); - } - else if (ch == '\n') - { - builder.Append(ch); - } - - continue; - } - - if (escapeNext) - { - builder.Append(ch); - escapeNext = false; - continue; - } - - if (inSingleQuote) - { - builder.Append(ch); - if (ch == '\\') - escapeNext = true; - else if (ch == '\'') - inSingleQuote = false; - continue; - } - - if (inDoubleQuote) - { - builder.Append(ch); - if (ch == '\\') - escapeNext = true; - else if (ch == '"') - inDoubleQuote = false; - continue; - } - - if (ch == '\'') - { - inSingleQuote = true; - builder.Append(ch); - continue; - } - - if (ch == '"') - { - inDoubleQuote = true; - builder.Append(ch); - continue; - } - - if (ch == '/' && next == '/') - { - inLineComment = true; - i++; - continue; - } - - if (ch == '/' && next == '*') - { - inBlockComment = true; - i++; - continue; - } - - builder.Append(ch); - } - - return builder.ToString(); - } - - private static bool TryParseRecordPrimaryComponent(string lang, RecordPrimaryComponentSlice rawComponent, out RecordPrimaryComponent component) - { - component = default; - if (string.IsNullOrWhiteSpace(rawComponent.Text)) - return false; - - var normalized = TrimAfterTopLevelEquals(rawComponent.Text).Trim(); - if (normalized.Length == 0) - return false; - - var componentLine = rawComponent.Line; - string? visibility = null; - if (lang == "kotlin") - { - var stripped = StripLeadingJavaRecordComponentAnnotations(normalized, allowKotlinUseSiteTargets: true); - normalized = stripped.Text; - componentLine += stripped.ConsumedNewlines; - - stripped = StripLeadingKotlinConstructorPropertyModifiers(normalized, out visibility); - normalized = stripped.Text; - componentLine += stripped.ConsumedNewlines; - - if (!StartsWithKotlinPropertyKeyword(normalized, out var propertyKeywordLength)) - return false; - - normalized = normalized[propertyKeywordLength..]; - var keywordWhitespaceConsumed = 0; - normalized = TrimLeadingWhitespaceAndCountNewlines(normalized, ref keywordWhitespaceConsumed); - componentLine += keywordWhitespaceConsumed; - - var separatorIndex = FindKotlinPropertyTypeSeparatorIndex(normalized); - if (separatorIndex <= 0) - return false; - - var kotlinComponentName = normalized[..separatorIndex].Trim(); - var kotlinComponentType = normalized[(separatorIndex + 1)..].Trim(); - if (kotlinComponentName.Length == 0 || kotlinComponentType.Length == 0) - return false; - - component = new RecordPrimaryComponent(kotlinComponentName, kotlinComponentType, normalized, componentLine, visibility); - return true; - } - else - { - var stripped = lang == "csharp" - ? StripLeadingCSharpRecordComponentAttributes(normalized) - : StripLeadingJavaRecordComponentAnnotations(normalized, allowKotlinUseSiteTargets: lang == "kotlin"); - normalized = stripped.Text; - componentLine += stripped.ConsumedNewlines; - - stripped = StripLeadingRecordComponentModifiers(lang, normalized); - normalized = stripped.Text; - componentLine += stripped.ConsumedNewlines; - } - if (normalized.Length == 0) - return false; - - var nameMatch = Regex.Match(normalized, @"(?@?[\p{L}_$][\p{L}\p{Nd}_$]*)\s*$", RegexOptions.CultureInvariant); - if (!nameMatch.Success) - return false; - - var componentName = nameMatch.Groups["name"].Value.TrimStart('@'); - var componentType = normalized[..nameMatch.Index].Trim(); - if (componentName.Length == 0 || componentType.Length == 0) - return false; - - component = new RecordPrimaryComponent(componentName, componentType, normalized, componentLine, visibility); - return true; - } - - private static string TrimAfterTopLevelEquals(string text) - { - var parenDepth = 0; - var angleDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - var inSingleQuote = false; - var inDoubleQuote = false; - var escapeNext = false; - - for (int i = 0; i < text.Length; i++) - { - var ch = text[i]; - if (escapeNext) - { - escapeNext = false; - continue; - } - - if (inSingleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '\'') - inSingleQuote = false; - continue; - } - - if (inDoubleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '"') - inDoubleQuote = false; - continue; - } - - switch (ch) - { - case '\'': - inSingleQuote = true; - continue; - case '"': - inDoubleQuote = true; - continue; - case '(': - parenDepth++; - continue; - case ')': - if (parenDepth > 0) - parenDepth--; - continue; - case '<' when LooksLikeRecordGenericAngleStart(text, i): - angleDepth++; - continue; - case '>': - if (angleDepth > 0) - angleDepth--; - continue; - case '[': - bracketDepth++; - continue; - case ']': - if (bracketDepth > 0) - bracketDepth--; - continue; - case '{': - braceDepth++; - continue; - case '}': - if (braceDepth > 0) - braceDepth--; - continue; - case '=' when parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 && braceDepth == 0: - return SliceCSharpTrimEnd(text, i); - } - } - - return text; - } - - private static StrippedRecordComponentText StripLeadingCSharpRecordComponentAttributes(string component) - { - var consumedNewlines = 0; - var trimmed = TrimLeadingWhitespaceAndCountNewlines(component, ref consumedNewlines); - while (trimmed.StartsWith("[", StringComparison.Ordinal)) - { - var endIndex = FindMatchingBracket(trimmed, 0, '[', ']'); - if (endIndex < 0) - return new(component.Trim(), 0); - - consumedNewlines += CountNewlines(trimmed.AsSpan(0, endIndex + 1)); - trimmed = TrimLeadingWhitespaceAndCountNewlines(trimmed[(endIndex + 1)..], ref consumedNewlines); - } - - return new(trimmed, consumedNewlines); - } - - private static bool LooksLikeRecordGenericAngleStart(string text, int index) - { - if (index < 0 || index >= text.Length || text[index] != '<') - return false; - - var previousIndex = FindPreviousNonWhitespaceIndex(text, index - 1); - if (previousIndex < 0) - return false; - - var nextIndex = FindNextNonWhitespaceIndex(text, index + 1); - if (nextIndex < 0) - return false; - - var previous = text[previousIndex]; - var next = text[nextIndex]; - if (!IsRecordGenericAnglePredecessor(previous) - || !IsRecordGenericAngleSuccessor(next)) - { - return false; - } - - return TryFindRecordGenericAngleEnd(text, index, out _); - } - - private static bool IsRecordGenericAnglePredecessor(char ch) => - char.IsLetterOrDigit(ch) - || ch is '_' or '$' or '@' or '.' or '>' or ')' or ']' or '?'; - - private static bool IsRecordGenericAngleSuccessor(char ch) => - char.IsLetter(ch) - || ch is '_' or '$' or '@' or '?' or '('; - - private static int FindPreviousNonWhitespaceIndex(string text, int index) - { - for (int i = Math.Min(index, text.Length - 1); i >= 0; i--) - { - if (!char.IsWhiteSpace(text[i])) - return i; - } - - return -1; - } - - private static int FindNextNonWhitespaceIndex(string text, int index) - { - for (int i = Math.Max(0, index); i < text.Length; i++) - { - if (!char.IsWhiteSpace(text[i])) - return i; - } - - return -1; - } - - private static bool TryFindRecordGenericAngleEnd(string text, int openIndex, out int closeIndex) - { - closeIndex = -1; - - var angleDepth = 0; - var parenDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - var inLineComment = false; - var inBlockComment = false; - var inSingleQuote = false; - var inDoubleQuote = false; - var escapeNext = false; - - for (int i = openIndex; i < text.Length; i++) - { - var ch = text[i]; - var next = i + 1 < text.Length ? text[i + 1] : '\0'; - - if (inLineComment) - { - if (ch == '\n') - inLineComment = false; - continue; - } - - if (inBlockComment) - { - if (ch == '*' && next == '/') - { - inBlockComment = false; - i++; - } - - continue; - } - - if (escapeNext) - { - escapeNext = false; - continue; - } - - if (inSingleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '\'') - inSingleQuote = false; - continue; - } - - if (inDoubleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '"') - inDoubleQuote = false; - continue; - } - - switch (ch) - { - case '\'': - inSingleQuote = true; - continue; - case '"': - inDoubleQuote = true; - continue; - case '/' when next == '/': - inLineComment = true; - i++; - continue; - case '/' when next == '*': - inBlockComment = true; - i++; - continue; - case '(': - parenDepth++; - continue; - case ')': - if (parenDepth > 0) - parenDepth--; - continue; - case '[': - bracketDepth++; - continue; - case ']': - if (bracketDepth > 0) - bracketDepth--; - continue; - case '{': - braceDepth++; - continue; - case '}': - if (braceDepth > 0) - braceDepth--; - continue; - case '<' when i == openIndex || LooksLikeRecordGenericAngleCandidate(text, i): - angleDepth++; - continue; - case '>': - if (angleDepth == 0) - continue; - - angleDepth--; - if (angleDepth == 0) - { - if (!IsRecordGenericAnglePayloadTypeLike(text.AsSpan(openIndex + 1, i - openIndex - 1))) - return false; - - closeIndex = i; - return true; - } - - continue; - } - } - - return false; - } - - private static bool LooksLikeRecordGenericAngleCandidate(string text, int index) - { - if (index < 0 || index >= text.Length || text[index] != '<') - return false; - - var previousIndex = FindPreviousNonWhitespaceIndex(text, index - 1); - if (previousIndex < 0) - return false; - - var nextIndex = FindNextNonWhitespaceIndex(text, index + 1); - if (nextIndex < 0) - return false; - - return IsRecordGenericAnglePredecessor(text[previousIndex]) - && IsRecordGenericAngleSuccessor(text[nextIndex]); - } - - private static bool IsRecordGenericAnglePayloadTypeLike(ReadOnlySpan text) - { - var parenDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - var inLineComment = false; - var inBlockComment = false; - var inSingleQuote = false; - var inDoubleQuote = false; - var escapeNext = false; - - for (int i = 0; i < text.Length; i++) - { - var ch = text[i]; - var next = i + 1 < text.Length ? text[i + 1] : '\0'; - - if (inLineComment) - { - if (ch == '\n') - inLineComment = false; - continue; - } - - if (inBlockComment) - { - if (ch == '*' && next == '/') - { - inBlockComment = false; - i++; - } - - continue; - } - - if (escapeNext) - { - escapeNext = false; - continue; - } - - if (inSingleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '\'') - inSingleQuote = false; - continue; - } - - if (inDoubleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '"') - inDoubleQuote = false; - continue; - } - - switch (ch) - { - case '\'': - inSingleQuote = true; - continue; - case '"': - inDoubleQuote = true; - continue; - case '/' when next == '/': - inLineComment = true; - i++; - continue; - case '/' when next == '*': - inBlockComment = true; - i++; - continue; - case '(': - parenDepth++; - continue; - case ')': - if (parenDepth > 0) - parenDepth--; - continue; - case '[': - bracketDepth++; - continue; - case ']': - if (bracketDepth > 0) - bracketDepth--; - continue; - case '{': - braceDepth++; - continue; - case '}': - if (braceDepth > 0) - braceDepth--; - continue; - } - - if (parenDepth == 0 - && bracketDepth == 0 - && braceDepth == 0 - && ch is '=' or '+' or '-' or '*' or '/' or '%' or '&' or '|' or '!' or '^' or '~' or ';') - { - return false; - } - } - - return true; - } - - private static StrippedRecordComponentText StripLeadingJavaRecordComponentAnnotations(string component, bool allowKotlinUseSiteTargets) - { - var consumedNewlines = 0; - var trimmed = TrimLeadingWhitespaceAndCountNewlines(component, ref consumedNewlines); - while (trimmed.StartsWith("@", StringComparison.Ordinal)) - { - var index = 1; - while (index < trimmed.Length && (char.IsLetterOrDigit(trimmed[index]) || trimmed[index] is '_' or '$' or '.')) - index++; - - if (allowKotlinUseSiteTargets && index < trimmed.Length && trimmed[index] == ':' && KotlinAnnotationTargets.Contains(trimmed[1..index])) - { - index++; - while (index < trimmed.Length && char.IsWhiteSpace(trimmed[index])) - index++; - while (index < trimmed.Length && (char.IsLetterOrDigit(trimmed[index]) || trimmed[index] is '_' or '$' or '.')) - index++; - } - - if (index < trimmed.Length && trimmed[index] == ':') - { - index++; - while (index < trimmed.Length && char.IsWhiteSpace(trimmed[index])) - index++; - while (index < trimmed.Length && (char.IsLetterOrDigit(trimmed[index]) || trimmed[index] is '_' or '$' or '.')) - index++; - } - - while (index < trimmed.Length && char.IsWhiteSpace(trimmed[index])) - index++; - - if (index < trimmed.Length && trimmed[index] == '(') - { - var endIndex = FindMatchingBracket(trimmed, index, '(', ')'); - if (endIndex < 0) - return new(component.Trim(), 0); - - index = endIndex + 1; - } - - consumedNewlines += CountNewlines(trimmed.AsSpan(0, index)); - trimmed = TrimLeadingWhitespaceAndCountNewlines(trimmed[index..], ref consumedNewlines); - } - - return new(trimmed, consumedNewlines); - } - - private static StrippedRecordComponentText StripLeadingKotlinConstructorPropertyModifiers( - string component, - out string? visibility) - { - visibility = null; - var consumedNewlines = 0; - var trimmed = TrimLeadingWhitespaceAndCountNewlines(component, ref consumedNewlines); - string[] modifiers = ["public", "private", "protected", "internal", "vararg"]; - var removedModifier = true; - while (removedModifier) - { - removedModifier = false; - foreach (var modifier in modifiers) - { - if (trimmed.StartsWith(modifier, StringComparison.Ordinal) - && trimmed.Length > modifier.Length - && char.IsWhiteSpace(trimmed[modifier.Length])) - { - if (modifier is "public" or "private" or "protected" or "internal") - visibility ??= modifier; - - trimmed = TrimLeadingWhitespaceAndCountNewlines(trimmed[(modifier.Length + 1)..], ref consumedNewlines); - removedModifier = true; - break; - } - } - } - - return new(trimmed, consumedNewlines); - } - - private static int FindKotlinPropertyTypeSeparatorIndex(string text) - { - var parenDepth = 0; - var angleDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - var inSingleQuote = false; - var inDoubleQuote = false; - var escapeNext = false; - - for (int i = 0; i < text.Length; i++) - { - var ch = text[i]; - if (escapeNext) - { - escapeNext = false; - continue; - } - - if (inSingleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '\'') - inSingleQuote = false; - continue; - } - - if (inDoubleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '"') - inDoubleQuote = false; - continue; - } - - switch (ch) - { - case '\'': - inSingleQuote = true; - continue; - case '"': - inDoubleQuote = true; - continue; - case '(': - parenDepth++; - continue; - case ')': - if (parenDepth > 0) - parenDepth--; - continue; - case '<': - angleDepth++; - continue; - case '>': - if (angleDepth > 0) - angleDepth--; - continue; - case '[': - bracketDepth++; - continue; - case ']': - if (bracketDepth > 0) - bracketDepth--; - continue; - case '{': - braceDepth++; - continue; - case '}': - if (braceDepth > 0) - braceDepth--; - continue; - case ':' when parenDepth == 0 && angleDepth == 0 && bracketDepth == 0 && braceDepth == 0: - return i; - } - } - - return -1; - } - - private static bool StartsWithKotlinPropertyKeyword(string text, out int keywordLength) - { - if (text.StartsWith("val", StringComparison.Ordinal) - && (text.Length == 3 || !IsKotlinPropertyKeywordPart(text[3]))) - { - keywordLength = 3; - return true; - } - - if (text.StartsWith("var", StringComparison.Ordinal) - && (text.Length == 3 || !IsKotlinPropertyKeywordPart(text[3]))) - { - keywordLength = 3; - return true; - } - - keywordLength = 0; - return false; - } - - private static bool IsKotlinPropertyKeywordPart(char ch) => - char.IsLetterOrDigit(ch) || ch == '_'; - - private static StrippedRecordComponentText StripLeadingRecordComponentModifiers(string lang, string component) - { - ReadOnlySpan modifiers = lang == "csharp" - ? ["params", "this", "ref", "out", "in", "scoped", "readonly"] - : ["final"]; - - var consumedNewlines = 0; - var trimmed = TrimLeadingWhitespaceAndCountNewlines(component, ref consumedNewlines); - var removedModifier = true; - while (removedModifier) - { - removedModifier = false; - foreach (var modifier in modifiers) - { - if (trimmed.StartsWith(modifier, StringComparison.Ordinal) - && trimmed.Length > modifier.Length - && char.IsWhiteSpace(trimmed[modifier.Length])) - { - trimmed = TrimLeadingWhitespaceAndCountNewlines(trimmed[(modifier.Length + 1)..], ref consumedNewlines); - removedModifier = true; - break; - } - } - } - - return new(trimmed, consumedNewlines); - } - - private static string TrimLeadingWhitespaceAndCountNewlines(string text, ref int consumedNewlines) - { - var index = 0; - while (index < text.Length && char.IsWhiteSpace(text[index])) - { - if (text[index] == '\n') - consumedNewlines++; - index++; - } - - return text[index..]; - } - - private static int CountNewlines(ReadOnlySpan text) - { - var count = 0; - foreach (var ch in text) - { - if (ch == '\n') - count++; - } - - return count; - } - - private static int FindMatchingBracket(string text, int openIndex, char openBracket, char closeBracket) - { - var depth = 0; - var inSingleQuote = false; - var inDoubleQuote = false; - var escapeNext = false; - - for (int i = openIndex; i < text.Length; i++) - { - var ch = text[i]; - if (escapeNext) - { - escapeNext = false; - continue; - } - - if (inSingleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '\'') - inSingleQuote = false; - continue; - } - - if (inDoubleQuote) - { - if (ch == '\\') - escapeNext = true; - else if (ch == '"') - inDoubleQuote = false; - continue; - } - - if (ch == '\'') - { - inSingleQuote = true; - continue; - } - - if (ch == '"') - { - inDoubleQuote = true; - continue; - } - - if (ch == openBracket) - { - depth++; - continue; - } - - if (ch == closeBracket) - { - depth--; - if (depth == 0) - return i; - } - } - - return -1; - } private readonly record struct DeclaredContainerIdentity(long FileId, string Kind, string Name); From 6ccd088335c5aacdfe08f256090c1375c043e570 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:50:42 +0900 Subject: [PATCH 016/101] Isolate symbol container assignment --- .../SymbolExtractor.ContainerAssignment.cs | 666 ++++++++++++++++++ .../Indexer/Symbols/SymbolExtractor.cs | 658 ----------------- 2 files changed, 666 insertions(+), 658 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.ContainerAssignment.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ContainerAssignment.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ContainerAssignment.cs new file mode 100644 index 000000000..84cce4fb6 --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ContainerAssignment.cs @@ -0,0 +1,666 @@ +using System.Text; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private readonly record struct DeclaredContainerIdentity(long FileId, string Kind, string Name); + + private static void PopulateDeclaredContainerQualifiedNames(List symbols) + { + var requestedContainers = new HashSet(); + foreach (var symbol in symbols) + { + if (symbol.ContainerKind != null && symbol.ContainerName != null) + requestedContainers.Add(new DeclaredContainerIdentity(symbol.FileId, symbol.ContainerKind, symbol.ContainerName)); + } + + if (requestedContainers.Count == 0) + return; + + var declaredContainers = new Dictionary>(requestedContainers.Count); + foreach (var candidate in symbols) + { + var identity = new DeclaredContainerIdentity(candidate.FileId, candidate.Kind, candidate.Name); + if (!requestedContainers.Contains(identity)) + continue; + + if (!declaredContainers.TryGetValue(identity, out var candidates)) + { + candidates = []; + declaredContainers.Add(identity, candidates); + } + candidates.Add(candidate); + } + + foreach (var symbol in symbols) + { + if (symbol.ContainerKind == null || symbol.ContainerName == null) + continue; + + var identity = new DeclaredContainerIdentity(symbol.FileId, symbol.ContainerKind, symbol.ContainerName); + if (!declaredContainers.TryGetValue(identity, out var candidates)) + continue; + + var container = FindDeclaredContainerSymbol(candidates, symbol); + if (container == null) + continue; + + symbol.ContainerQualifiedName = container.ContainerQualifiedName != null + ? $"{container.ContainerQualifiedName}.{container.Name}" + : container.Name; + } + } + + private static SymbolRecord? FindDeclaredContainerSymbol(IReadOnlyList candidates, SymbolRecord symbol) + { + SymbolRecord? best = null; + foreach (var candidate in candidates) + { + if (candidate.StartLine > symbol.StartLine + || candidate.EndLine < symbol.EndLine) + { + continue; + } + + if (best == null + || candidate.StartLine > best.StartLine + || (candidate.StartLine == best.StartLine && candidate.EndLine < best.EndLine)) + { + best = candidate; + } + } + + return best; + } + + private static void AssignContainers( + List symbols, + string[]? rawLines = null, + Func? getCSharpLineStartStates = null) + { + if (symbols.Count == 0) + return; + + if (symbols.Count == 1) + { + AssignTopLevelFamilyKey(symbols[0]); + return; + } + + if (!ContainsContainerCandidates(symbols)) + { + foreach (var symbol in symbols) + AssignTopLevelFamilyKey(symbol); + return; + } + + var ordered = BuildContainerAssignmentOrder(symbols); + + var stack = new Stack(); + foreach (var orderedSymbol in ordered) + { + var symbol = orderedSymbol.Symbol; + while (stack.Count > 0 && !IsFileScopedNamespace(stack.Peek()) && symbol.StartLine > stack.Peek().EndLine) + stack.Pop(); + + var containerPath = GetEffectiveContainerPath(stack, symbol, rawLines, getCSharpLineStartStates); + + if (containerPath.Count > 0) + { + var effectiveContainer = containerPath[^1]; + if (symbol.ContainerKind != null && symbol.ContainerName != null) + { + var explicitContainerIndex = -1; + for (var i = containerPath.Count - 1; i >= 0; i--) + { + var container = containerPath[i]; + if (container.Kind == symbol.ContainerKind + && container.Name == symbol.ContainerName) + { + explicitContainerIndex = i; + break; + } + } + + var shouldPromoteToMoreSpecificContainer = + symbol.ContainerKind == "enum" + && explicitContainerIndex >= 0 + && explicitContainerIndex < containerPath.Count - 1 + && effectiveContainer.Kind == "function" + && effectiveContainer.ContainerKind == "enum"; + + if (shouldPromoteToMoreSpecificContainer) + { + effectiveContainer = containerPath[^1]; + symbol.ContainerKind = effectiveContainer.Kind; + symbol.ContainerName = effectiveContainer.Name; + symbol.ContainerQualifiedName = BuildQualifiedContainerName(containerPath, containerPath.Count - 1); + } + else + { + var explicitContainerAlreadyPresent = explicitContainerIndex == containerPath.Count - 1; + var parentQualifiedName = BuildQualifiedContainerName(containerPath); + symbol.ContainerQualifiedName ??= explicitContainerAlreadyPresent + ? parentQualifiedName + : string.IsNullOrWhiteSpace(parentQualifiedName) + ? symbol.ContainerName + : $"{parentQualifiedName}.{symbol.ContainerName}"; + } + } + else + { + symbol.ContainerKind ??= effectiveContainer.Kind; + symbol.ContainerName ??= effectiveContainer.Name; + var qualifiedContainerName = BuildQualifiedContainerName(containerPath); + symbol.ContainerQualifiedName = qualifiedContainerName; + symbol.FamilyKey = BuildInheritedFamilyKey(effectiveContainer, qualifiedContainerName); + } + } + + symbol.FamilyKey ??= BuildSelfFamilyKey(symbol, containerPath); + + if (CanContainSymbols(symbol)) + stack.Push(symbol); + } + } + + private static void AssignTopLevelFamilyKey(SymbolRecord symbol) + => symbol.FamilyKey ??= BuildSelfFamilyKey(symbol, Array.Empty()); + + private static bool ContainsContainerCandidates(IReadOnlyList symbols) + { + foreach (var symbol in symbols) + { + if (CanContainSymbols(symbol)) + return true; + } + + return false; + } + + private readonly record struct ContainerAssignmentSortEntry(SymbolRecord Symbol, int OriginalIndex); + + private static List BuildContainerAssignmentOrder(IReadOnlyList symbols) + { + if (symbols.Count == 0) + return []; + + if (symbols.Count == 1) + return [new ContainerAssignmentSortEntry(symbols[0], 0)]; + + var ordered = new List(symbols.Count); + for (var i = 0; i < symbols.Count; i++) + ordered.Add(new ContainerAssignmentSortEntry(symbols[i], i)); + + ordered.Sort(CompareContainerAssignmentSortEntries); + return ordered; + } + + private static int CompareContainerAssignmentSortEntries(ContainerAssignmentSortEntry left, ContainerAssignmentSortEntry right) + { + var compare = left.Symbol.StartLine.CompareTo(right.Symbol.StartLine); + if (compare != 0) + return compare; + + var leftStartColumnRank = left.Symbol.StartColumn.HasValue ? 0 : 1; + var rightStartColumnRank = right.Symbol.StartColumn.HasValue ? 0 : 1; + compare = leftStartColumnRank.CompareTo(rightStartColumnRank); + if (compare != 0) + return compare; + + compare = (left.Symbol.StartColumn ?? int.MaxValue).CompareTo(right.Symbol.StartColumn ?? int.MaxValue); + if (compare != 0) + return compare; + + compare = right.Symbol.EndLine.CompareTo(left.Symbol.EndLine); + if (compare != 0) + return compare; + + compare = (right.Symbol.Signature?.Length ?? 0).CompareTo(left.Symbol.Signature?.Length ?? 0); + if (compare != 0) + return compare; + + return left.OriginalIndex.CompareTo(right.OriginalIndex); + } + + private static IReadOnlyList GetEffectiveContainerPath( + Stack containers, + SymbolRecord symbol, + string[]? rawLines = null, + Func? getCSharpLineStartStates = null) + { + if (containers.Count == 0) + return []; + + if (containers.Count == 1) + { + var container = containers.Peek(); + return ContainsSymbol(container, symbol, rawLines, getCSharpLineStartStates) + ? [container] + : []; + } + + var orderedContainers = containers.ToArray(); + var containingContainers = new List(orderedContainers.Length); + for (var i = orderedContainers.Length - 1; i >= 0; i--) + { + var container = orderedContainers[i]; + if (ContainsSymbol(container, symbol, rawLines, getCSharpLineStartStates)) + containingContainers.Add(container); + } + + if (containingContainers.Count == 0) + return []; + + if (symbol.Kind == "enum" && symbol.BodyStartLine == null) + { + var enumIndex = containingContainers.FindLastIndex(container => container.Kind == "enum"); + if (enumIndex >= 0) + containingContainers.RemoveRange(enumIndex + 1, containingContainers.Count - enumIndex - 1); + } + + return containingContainers; + } + + private static string? BuildQualifiedContainerName(IReadOnlyList containers) => + BuildQualifiedContainerName(containers, containers.Count); + + private static string? BuildQualifiedContainerName(IReadOnlyList containers, int count) + { + if (count <= 0) + return null; + + StringBuilder? builder = null; + for (var i = 0; i < count; i++) + { + var name = containers[i].Name; + if (string.IsNullOrWhiteSpace(name)) + continue; + + builder ??= new StringBuilder(name.Length); + if (builder.Length > 0) + builder.Append('.'); + + builder.Append(name); + } + + return builder?.ToString(); + } + + private static string? BuildInheritedFamilyKey(SymbolRecord container, string? qualifiedContainerName) => + SupportsCrossFileFamily(container) + ? qualifiedContainerName + : null; + + private static string? BuildSelfFamilyKey(SymbolRecord symbol, IReadOnlyList containers) + { + if (!SupportsCrossFileFamily(symbol)) + return null; + + var symbolName = symbol.Name; + if (containers.Count == 0) + return symbolName; + + StringBuilder? builder = null; + for (var i = 0; i < containers.Count; i++) + { + var name = containers[i].Name; + if (string.IsNullOrWhiteSpace(name)) + continue; + + builder ??= new StringBuilder(name.Length + symbolName.Length + 1); + if (builder.Length > 0) + builder.Append('.'); + + builder.Append(name); + } + + builder ??= new StringBuilder(symbolName.Length); + if (builder.Length > 0) + builder.Append('.'); + + builder.Append(symbolName); + + return builder?.ToString(); + } + + private static bool SupportsCrossFileFamily(SymbolRecord symbol) => + symbol.Kind is "class" or "interface" or "struct" + && !string.IsNullOrWhiteSpace(symbol.Signature) + && PartialModifierRegex.IsMatch(symbol.Signature); + + private static bool TryGetObjCCategoryDisplayName(string objcDeclaration, string baseName, out string displayName) + { + var match = ObjCCategoryDeclarationRegex.Match(objcDeclaration); + if (!match.Success || !string.Equals(match.Groups["class"].Value, baseName, StringComparison.Ordinal)) + { + displayName = string.Empty; + return false; + } + + var categoryName = match.Groups["category"].ValueSpan.Trim().ToString(); + if (categoryName.Length == 0) + { + displayName = string.Empty; + return false; + } + + displayName = $"{baseName}({categoryName})"; + return true; + } + + private static bool CanContainSymbols(SymbolRecord symbol) + { + if (symbol.Kind == "function" + && symbol.ContainerKind == "enum" + && symbol.BodyStartLine != null + && symbol.BodyEndLine != null) + { + return true; + } + + if (!ContainerKinds.Contains(symbol.Kind)) + return false; + + if (IsFileScopedNamespace(symbol)) + return true; + + return symbol.BodyStartLine != null && symbol.BodyEndLine != null; + } + + private static bool ContainsSymbol( + SymbolRecord container, + SymbolRecord candidate, + string[]? rawLines = null, + Func? getCSharpLineStartStates = null) + { + if (IsFileScopedNamespace(container)) + return candidate.StartLine > container.StartLine; + + if (container.BodyStartLine == null || container.BodyEndLine == null) + return false; + + if (candidate.StartLine == container.StartLine) + { + if (TryContainsCSharpSameLineSymbolByRawLine(container, candidate, rawLines, getCSharpLineStartStates, out var containsSameLineSymbol)) + return containsSameLineSymbol; + + return CanContainSameLineSymbol(container, candidate) + && container.Signature != null + && candidate.Signature != null + && container.Signature.Contains(candidate.Signature, StringComparison.Ordinal); + } + + if (candidate.StartLine >= container.BodyStartLine + && candidate.StartLine <= container.BodyEndLine + && candidate.StartLine > container.StartLine) + { + return true; + } + + return IsInsideCSharpClosingBraceLineContainer(container, candidate, rawLines, getCSharpLineStartStates); + } + + private static bool TryContainsCSharpSameLineSymbolByRawLine( + SymbolRecord container, + SymbolRecord candidate, + string[]? rawLines, + Func? getCSharpLineStartStates, + out bool contains) + { + contains = false; + if (rawLines == null + || container.Signature == null + || candidate.Signature == null + || container.StartLine != candidate.StartLine + || container.StartLine <= 0 + || container.StartLine > rawLines.Length + || !CanContainSameLineSymbol(container, candidate)) + { + return false; + } + + var lineIndex = container.StartLine - 1; + var csharpLineStartStates = getCSharpLineStartStates?.Invoke(); + if (csharpLineStartStates == null || container.StartLine > csharpLineStartStates.Length) + return false; + + var rawLine = rawLines[lineIndex]; + var lineStartState = csharpLineStartStates[lineIndex]; + var containerStartColumn = FindSignatureOccurrenceStartColumn( + rawLine, + container.Signature, + container.SameLineSignatureOccurrenceIndex ?? 0, + lineStartState); + var candidateStartColumn = FindSignatureOccurrenceStartColumn( + rawLine, + candidate.Signature, + candidate.SameLineSignatureOccurrenceIndex ?? 0, + lineStartState); + if (containerStartColumn < 0 || candidateStartColumn < 0) + return false; + + if (container.BodyStartLine == container.StartLine + && container.EndLine == container.StartLine) + { + var closingBraceColumn = FindCSharpSameLineContainerClosingBraceColumn(rawLine, containerStartColumn, lineStartState); + if (closingBraceColumn < 0) + return false; + + contains = candidateStartColumn > containerStartColumn + && candidateStartColumn < closingBraceColumn; + return true; + } + + return false; + } + + // A wrapped C# type can deliberately end its body one line earlier when the closing + // brace line also starts an outer sibling (`} public int Q { get; }`). That keeps the + // later outer sibling out of the inner container, but the last inner member may still + // live earlier on that same closing-brace line (`public int P { get; } } public int Q`). + // Reconstruct the matching closing-brace column on the raw end line and treat only the + // declarations that start before that brace as inner members. Closes #549. + // wrapped な C# type は、閉じ brace 行に outer sibling (`} public int Q { get; }`) + // が続くとき、本体終端を 1 行手前へ倒して後続 sibling を inner container から外す。 + // ただし最後の inner member 自体が同じ閉じ brace 行の前半に載ることがあり + // (`public int P { get; } } public int Q`)、そのままだと inner member まで外へ漏れる。 + // そこで raw end line 上で対応する closing brace 列を再構築し、その brace より前に + // 始まる宣言だけを inner member として扱う。Closes #549. + private static bool IsInsideCSharpClosingBraceLineContainer( + SymbolRecord container, + SymbolRecord candidate, + string[]? rawLines, + Func? getCSharpLineStartStates) + { + if (rawLines == null + || container.BodyStartLine == null + || container.BodyEndLine == null + || container.BodyEndLine.Value >= container.EndLine + || candidate.Signature == null + || candidate.StartLine != container.EndLine + || candidate.StartLine <= container.StartLine) + { + return false; + } + + var lineIndex = container.EndLine - 1; + if (lineIndex < 0 || lineIndex >= rawLines.Length) + return false; + + var closingBraceColumn = FindCSharpClosingBraceColumnOnContainerEndLine(container, rawLines); + if (closingBraceColumn < 0) + return false; + + var candidateColumn = FindSignatureOccurrenceStartColumn( + rawLines[lineIndex], + candidate.Signature, + candidate.SameLineSignatureOccurrenceIndex ?? 0, + getCSharpLineStartStates?.Invoke() is { } csharpLineStartStates + && lineIndex < csharpLineStartStates.Length + ? csharpLineStartStates[lineIndex] + : new CSharpLexState()); + return candidateColumn >= 0 && candidateColumn < closingBraceColumn; + } + + private static int FindCSharpClosingBraceColumnOnContainerEndLine(SymbolRecord container, string[] rawLines) + { + if (container.BodyStartLine == null + || container.EndLine <= 0 + || container.EndLine > rawLines.Length + || container.BodyStartLine.Value <= 0 + || container.BodyStartLine.Value > container.EndLine) + { + return -1; + } + + var lexState = new CSharpLexState(); + var depth = 0; + var endLineIndex = container.EndLine - 1; + for (var lineIndex = container.BodyStartLine.Value - 1; lineIndex < endLineIndex; lineIndex++) + { + var lineResult = LexCSharpLine(rawLines[lineIndex], lexState); + lexState = lineResult.EndState; + + foreach (var ch in lineResult.SanitizedLine) + { + if (ch == '{') + { + depth++; + } + else if (ch == '}') + { + depth--; + } + } + } + + var sanitizedLine = LexCSharpLine(rawLines[endLineIndex], lexState).SanitizedLine; + if (depth <= 0) + return -1; + + for (var i = 0; i < sanitizedLine.Length; i++) + { + var ch = sanitizedLine[i]; + if (ch == '{') + { + depth++; + } + else if (ch == '}') + { + depth--; + if (depth == 0) + return i; + } + } + + return -1; + } + + private static int FindSignatureOccurrenceStartColumn( + string rawLine, + string signature, + int occurrenceIndex, + CSharpLexState lineStartState) + { + if (occurrenceIndex < 0 || string.IsNullOrEmpty(rawLine) || string.IsNullOrEmpty(signature)) + return -1; + + // Same-line C# occurrence tracking must ignore declaration lookalikes inside string + // literals and comments, or the nth "real" declaration is mapped onto an earlier + // quoted/commented copy of the same signature. LexCSharpLine preserves original + // columns while blanking those regions, so the resulting indices still line up with + // the raw line. Closes #558. + // same-line C# の occurrence tracking は、文字列リテラルやコメント中の見かけ上の + // 宣言を数えてはいけない。そうしないと n 個目の「本物の」宣言が、より前にある + // quoted/commented な同一 signature へ誤対応付けされる。LexCSharpLine は元の列を + // 保ったまま当該領域だけ空白化するので、得られる index は raw line と整合したまま使える。 + var searchLine = LexCSharpLine(rawLine, lineStartState).SanitizedLine; + var currentOccurrence = 0; + var searchStart = 0; + while (searchStart < searchLine.Length) + { + var matchIndex = searchLine.IndexOf(signature, searchStart, StringComparison.Ordinal); + if (matchIndex < 0) + return -1; + + if (currentOccurrence == occurrenceIndex) + return matchIndex; + + currentOccurrence++; + searchStart = matchIndex + signature.Length; + } + + return -1; + } + + private static int FindCSharpSameLineContainerClosingBraceColumn( + string rawLine, + int containerStartColumn, + CSharpLexState lineStartState) + { + if (containerStartColumn < 0 || containerStartColumn >= rawLine.Length) + return -1; + + var sanitizedLine = LexCSharpLine(rawLine, lineStartState).SanitizedLine; + var openBraceColumn = sanitizedLine.IndexOf('{', containerStartColumn); + if (openBraceColumn < 0) + return -1; + + var depth = 0; + for (var i = openBraceColumn; i < sanitizedLine.Length; i++) + { + var ch = sanitizedLine[i]; + if (ch == '{') + { + depth++; + } + else if (ch == '}') + { + depth--; + if (depth == 0) + return i; + } + } + + return -1; + } + + private static bool CanContainSameLineSymbol(SymbolRecord container, SymbolRecord candidate) + { + return (container.Kind, candidate.Kind) switch + { + ("function", _) when container.ContainerKind == "enum" && container.BodyStartLine != null && container.BodyEndLine != null => true, + ("enum", "enum") => true, + ("namespace", _) => true, + ("class", _) => true, + ("struct", _) => true, + ("interface", _) => true, + ("protocol", _) => true, + _ => false, + }; + } + + // C# file-scoped namespace: `namespace X;` with no braces. Matches only declarations whose + // signature starts with the `namespace` keyword, so body-less namespace rows from other + // languages (e.g. SQL `CREATE SCHEMA ...;` / `ALTER SCHEMA ...;`) are not treated as + // file-scoped and therefore do not wrap every subsequent top-level symbol as their container. + // C# の file-scoped namespace(`namespace X;` 形)だけを対象とする。`namespace` キーワードで + // 始まるシグネチャに限定することで、SQL の `CREATE SCHEMA ...;` / `ALTER SCHEMA ...;` のような + // 他言語の body 無し namespace 行が file-scoped namespace 扱いになり、以降のトップレベル + // シンボル全てを自分の配下にぶら下げてしまう事故を防ぐ。 + private static bool IsFileScopedNamespace(SymbolRecord symbol) + { + if (symbol.Kind != "namespace") + return false; + if (symbol.BodyStartLine != null || symbol.BodyEndLine != null) + return false; + if (symbol.Signature == null) + return false; + var trimmed = symbol.Signature.AsSpan().TrimStart(); + return trimmed.StartsWith("namespace ", StringComparison.Ordinal) + || trimmed.StartsWith("namespace\t", StringComparison.Ordinal); + } +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index e97102c04..2afdb9a52 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -3611,664 +3611,6 @@ private static bool IsCSharpKeywordAt(string line, int index, string keyword) } - private readonly record struct DeclaredContainerIdentity(long FileId, string Kind, string Name); - - private static void PopulateDeclaredContainerQualifiedNames(List symbols) - { - var requestedContainers = new HashSet(); - foreach (var symbol in symbols) - { - if (symbol.ContainerKind != null && symbol.ContainerName != null) - requestedContainers.Add(new DeclaredContainerIdentity(symbol.FileId, symbol.ContainerKind, symbol.ContainerName)); - } - - if (requestedContainers.Count == 0) - return; - - var declaredContainers = new Dictionary>(requestedContainers.Count); - foreach (var candidate in symbols) - { - var identity = new DeclaredContainerIdentity(candidate.FileId, candidate.Kind, candidate.Name); - if (!requestedContainers.Contains(identity)) - continue; - - if (!declaredContainers.TryGetValue(identity, out var candidates)) - { - candidates = []; - declaredContainers.Add(identity, candidates); - } - candidates.Add(candidate); - } - - foreach (var symbol in symbols) - { - if (symbol.ContainerKind == null || symbol.ContainerName == null) - continue; - - var identity = new DeclaredContainerIdentity(symbol.FileId, symbol.ContainerKind, symbol.ContainerName); - if (!declaredContainers.TryGetValue(identity, out var candidates)) - continue; - - var container = FindDeclaredContainerSymbol(candidates, symbol); - if (container == null) - continue; - - symbol.ContainerQualifiedName = container.ContainerQualifiedName != null - ? $"{container.ContainerQualifiedName}.{container.Name}" - : container.Name; - } - } - - private static SymbolRecord? FindDeclaredContainerSymbol(IReadOnlyList candidates, SymbolRecord symbol) - { - SymbolRecord? best = null; - foreach (var candidate in candidates) - { - if (candidate.StartLine > symbol.StartLine - || candidate.EndLine < symbol.EndLine) - { - continue; - } - - if (best == null - || candidate.StartLine > best.StartLine - || (candidate.StartLine == best.StartLine && candidate.EndLine < best.EndLine)) - { - best = candidate; - } - } - - return best; - } - - private static void AssignContainers( - List symbols, - string[]? rawLines = null, - Func? getCSharpLineStartStates = null) - { - if (symbols.Count == 0) - return; - - if (symbols.Count == 1) - { - AssignTopLevelFamilyKey(symbols[0]); - return; - } - - if (!ContainsContainerCandidates(symbols)) - { - foreach (var symbol in symbols) - AssignTopLevelFamilyKey(symbol); - return; - } - - var ordered = BuildContainerAssignmentOrder(symbols); - - var stack = new Stack(); - foreach (var orderedSymbol in ordered) - { - var symbol = orderedSymbol.Symbol; - while (stack.Count > 0 && !IsFileScopedNamespace(stack.Peek()) && symbol.StartLine > stack.Peek().EndLine) - stack.Pop(); - - var containerPath = GetEffectiveContainerPath(stack, symbol, rawLines, getCSharpLineStartStates); - - if (containerPath.Count > 0) - { - var effectiveContainer = containerPath[^1]; - if (symbol.ContainerKind != null && symbol.ContainerName != null) - { - var explicitContainerIndex = -1; - for (var i = containerPath.Count - 1; i >= 0; i--) - { - var container = containerPath[i]; - if (container.Kind == symbol.ContainerKind - && container.Name == symbol.ContainerName) - { - explicitContainerIndex = i; - break; - } - } - - var shouldPromoteToMoreSpecificContainer = - symbol.ContainerKind == "enum" - && explicitContainerIndex >= 0 - && explicitContainerIndex < containerPath.Count - 1 - && effectiveContainer.Kind == "function" - && effectiveContainer.ContainerKind == "enum"; - - if (shouldPromoteToMoreSpecificContainer) - { - effectiveContainer = containerPath[^1]; - symbol.ContainerKind = effectiveContainer.Kind; - symbol.ContainerName = effectiveContainer.Name; - symbol.ContainerQualifiedName = BuildQualifiedContainerName(containerPath, containerPath.Count - 1); - } - else - { - var explicitContainerAlreadyPresent = explicitContainerIndex == containerPath.Count - 1; - var parentQualifiedName = BuildQualifiedContainerName(containerPath); - symbol.ContainerQualifiedName ??= explicitContainerAlreadyPresent - ? parentQualifiedName - : string.IsNullOrWhiteSpace(parentQualifiedName) - ? symbol.ContainerName - : $"{parentQualifiedName}.{symbol.ContainerName}"; - } - } - else - { - symbol.ContainerKind ??= effectiveContainer.Kind; - symbol.ContainerName ??= effectiveContainer.Name; - var qualifiedContainerName = BuildQualifiedContainerName(containerPath); - symbol.ContainerQualifiedName = qualifiedContainerName; - symbol.FamilyKey = BuildInheritedFamilyKey(effectiveContainer, qualifiedContainerName); - } - } - - symbol.FamilyKey ??= BuildSelfFamilyKey(symbol, containerPath); - - if (CanContainSymbols(symbol)) - stack.Push(symbol); - } - } - - private static void AssignTopLevelFamilyKey(SymbolRecord symbol) - => symbol.FamilyKey ??= BuildSelfFamilyKey(symbol, Array.Empty()); - - private static bool ContainsContainerCandidates(IReadOnlyList symbols) - { - foreach (var symbol in symbols) - { - if (CanContainSymbols(symbol)) - return true; - } - - return false; - } - - private readonly record struct ContainerAssignmentSortEntry(SymbolRecord Symbol, int OriginalIndex); - - private static List BuildContainerAssignmentOrder(IReadOnlyList symbols) - { - if (symbols.Count == 0) - return []; - - if (symbols.Count == 1) - return [new ContainerAssignmentSortEntry(symbols[0], 0)]; - - var ordered = new List(symbols.Count); - for (var i = 0; i < symbols.Count; i++) - ordered.Add(new ContainerAssignmentSortEntry(symbols[i], i)); - - ordered.Sort(CompareContainerAssignmentSortEntries); - return ordered; - } - - private static int CompareContainerAssignmentSortEntries(ContainerAssignmentSortEntry left, ContainerAssignmentSortEntry right) - { - var compare = left.Symbol.StartLine.CompareTo(right.Symbol.StartLine); - if (compare != 0) - return compare; - - var leftStartColumnRank = left.Symbol.StartColumn.HasValue ? 0 : 1; - var rightStartColumnRank = right.Symbol.StartColumn.HasValue ? 0 : 1; - compare = leftStartColumnRank.CompareTo(rightStartColumnRank); - if (compare != 0) - return compare; - - compare = (left.Symbol.StartColumn ?? int.MaxValue).CompareTo(right.Symbol.StartColumn ?? int.MaxValue); - if (compare != 0) - return compare; - - compare = right.Symbol.EndLine.CompareTo(left.Symbol.EndLine); - if (compare != 0) - return compare; - - compare = (right.Symbol.Signature?.Length ?? 0).CompareTo(left.Symbol.Signature?.Length ?? 0); - if (compare != 0) - return compare; - - return left.OriginalIndex.CompareTo(right.OriginalIndex); - } - - private static IReadOnlyList GetEffectiveContainerPath( - Stack containers, - SymbolRecord symbol, - string[]? rawLines = null, - Func? getCSharpLineStartStates = null) - { - if (containers.Count == 0) - return []; - - if (containers.Count == 1) - { - var container = containers.Peek(); - return ContainsSymbol(container, symbol, rawLines, getCSharpLineStartStates) - ? [container] - : []; - } - - var orderedContainers = containers.ToArray(); - var containingContainers = new List(orderedContainers.Length); - for (var i = orderedContainers.Length - 1; i >= 0; i--) - { - var container = orderedContainers[i]; - if (ContainsSymbol(container, symbol, rawLines, getCSharpLineStartStates)) - containingContainers.Add(container); - } - - if (containingContainers.Count == 0) - return []; - - if (symbol.Kind == "enum" && symbol.BodyStartLine == null) - { - var enumIndex = containingContainers.FindLastIndex(container => container.Kind == "enum"); - if (enumIndex >= 0) - containingContainers.RemoveRange(enumIndex + 1, containingContainers.Count - enumIndex - 1); - } - - return containingContainers; - } - - private static string? BuildQualifiedContainerName(IReadOnlyList containers) => - BuildQualifiedContainerName(containers, containers.Count); - - private static string? BuildQualifiedContainerName(IReadOnlyList containers, int count) - { - if (count <= 0) - return null; - - StringBuilder? builder = null; - for (var i = 0; i < count; i++) - { - var name = containers[i].Name; - if (string.IsNullOrWhiteSpace(name)) - continue; - - builder ??= new StringBuilder(name.Length); - if (builder.Length > 0) - builder.Append('.'); - - builder.Append(name); - } - - return builder?.ToString(); - } - - private static string? BuildInheritedFamilyKey(SymbolRecord container, string? qualifiedContainerName) => - SupportsCrossFileFamily(container) - ? qualifiedContainerName - : null; - - private static string? BuildSelfFamilyKey(SymbolRecord symbol, IReadOnlyList containers) - { - if (!SupportsCrossFileFamily(symbol)) - return null; - - var symbolName = symbol.Name; - if (containers.Count == 0) - return symbolName; - - StringBuilder? builder = null; - for (var i = 0; i < containers.Count; i++) - { - var name = containers[i].Name; - if (string.IsNullOrWhiteSpace(name)) - continue; - - builder ??= new StringBuilder(name.Length + symbolName.Length + 1); - if (builder.Length > 0) - builder.Append('.'); - - builder.Append(name); - } - - builder ??= new StringBuilder(symbolName.Length); - if (builder.Length > 0) - builder.Append('.'); - - builder.Append(symbolName); - - return builder?.ToString(); - } - - private static bool SupportsCrossFileFamily(SymbolRecord symbol) => - symbol.Kind is "class" or "interface" or "struct" - && !string.IsNullOrWhiteSpace(symbol.Signature) - && PartialModifierRegex.IsMatch(symbol.Signature); - - private static bool TryGetObjCCategoryDisplayName(string objcDeclaration, string baseName, out string displayName) - { - var match = ObjCCategoryDeclarationRegex.Match(objcDeclaration); - if (!match.Success || !string.Equals(match.Groups["class"].Value, baseName, StringComparison.Ordinal)) - { - displayName = string.Empty; - return false; - } - - var categoryName = match.Groups["category"].ValueSpan.Trim().ToString(); - if (categoryName.Length == 0) - { - displayName = string.Empty; - return false; - } - - displayName = $"{baseName}({categoryName})"; - return true; - } - - private static bool CanContainSymbols(SymbolRecord symbol) - { - if (symbol.Kind == "function" - && symbol.ContainerKind == "enum" - && symbol.BodyStartLine != null - && symbol.BodyEndLine != null) - { - return true; - } - - if (!ContainerKinds.Contains(symbol.Kind)) - return false; - - if (IsFileScopedNamespace(symbol)) - return true; - - return symbol.BodyStartLine != null && symbol.BodyEndLine != null; - } - - private static bool ContainsSymbol( - SymbolRecord container, - SymbolRecord candidate, - string[]? rawLines = null, - Func? getCSharpLineStartStates = null) - { - if (IsFileScopedNamespace(container)) - return candidate.StartLine > container.StartLine; - - if (container.BodyStartLine == null || container.BodyEndLine == null) - return false; - - if (candidate.StartLine == container.StartLine) - { - if (TryContainsCSharpSameLineSymbolByRawLine(container, candidate, rawLines, getCSharpLineStartStates, out var containsSameLineSymbol)) - return containsSameLineSymbol; - - return CanContainSameLineSymbol(container, candidate) - && container.Signature != null - && candidate.Signature != null - && container.Signature.Contains(candidate.Signature, StringComparison.Ordinal); - } - - if (candidate.StartLine >= container.BodyStartLine - && candidate.StartLine <= container.BodyEndLine - && candidate.StartLine > container.StartLine) - { - return true; - } - - return IsInsideCSharpClosingBraceLineContainer(container, candidate, rawLines, getCSharpLineStartStates); - } - - private static bool TryContainsCSharpSameLineSymbolByRawLine( - SymbolRecord container, - SymbolRecord candidate, - string[]? rawLines, - Func? getCSharpLineStartStates, - out bool contains) - { - contains = false; - if (rawLines == null - || container.Signature == null - || candidate.Signature == null - || container.StartLine != candidate.StartLine - || container.StartLine <= 0 - || container.StartLine > rawLines.Length - || !CanContainSameLineSymbol(container, candidate)) - { - return false; - } - - var lineIndex = container.StartLine - 1; - var csharpLineStartStates = getCSharpLineStartStates?.Invoke(); - if (csharpLineStartStates == null || container.StartLine > csharpLineStartStates.Length) - return false; - - var rawLine = rawLines[lineIndex]; - var lineStartState = csharpLineStartStates[lineIndex]; - var containerStartColumn = FindSignatureOccurrenceStartColumn( - rawLine, - container.Signature, - container.SameLineSignatureOccurrenceIndex ?? 0, - lineStartState); - var candidateStartColumn = FindSignatureOccurrenceStartColumn( - rawLine, - candidate.Signature, - candidate.SameLineSignatureOccurrenceIndex ?? 0, - lineStartState); - if (containerStartColumn < 0 || candidateStartColumn < 0) - return false; - - if (container.BodyStartLine == container.StartLine - && container.EndLine == container.StartLine) - { - var closingBraceColumn = FindCSharpSameLineContainerClosingBraceColumn(rawLine, containerStartColumn, lineStartState); - if (closingBraceColumn < 0) - return false; - - contains = candidateStartColumn > containerStartColumn - && candidateStartColumn < closingBraceColumn; - return true; - } - - return false; - } - - // A wrapped C# type can deliberately end its body one line earlier when the closing - // brace line also starts an outer sibling (`} public int Q { get; }`). That keeps the - // later outer sibling out of the inner container, but the last inner member may still - // live earlier on that same closing-brace line (`public int P { get; } } public int Q`). - // Reconstruct the matching closing-brace column on the raw end line and treat only the - // declarations that start before that brace as inner members. Closes #549. - // wrapped な C# type は、閉じ brace 行に outer sibling (`} public int Q { get; }`) - // が続くとき、本体終端を 1 行手前へ倒して後続 sibling を inner container から外す。 - // ただし最後の inner member 自体が同じ閉じ brace 行の前半に載ることがあり - // (`public int P { get; } } public int Q`)、そのままだと inner member まで外へ漏れる。 - // そこで raw end line 上で対応する closing brace 列を再構築し、その brace より前に - // 始まる宣言だけを inner member として扱う。Closes #549. - private static bool IsInsideCSharpClosingBraceLineContainer( - SymbolRecord container, - SymbolRecord candidate, - string[]? rawLines, - Func? getCSharpLineStartStates) - { - if (rawLines == null - || container.BodyStartLine == null - || container.BodyEndLine == null - || container.BodyEndLine.Value >= container.EndLine - || candidate.Signature == null - || candidate.StartLine != container.EndLine - || candidate.StartLine <= container.StartLine) - { - return false; - } - - var lineIndex = container.EndLine - 1; - if (lineIndex < 0 || lineIndex >= rawLines.Length) - return false; - - var closingBraceColumn = FindCSharpClosingBraceColumnOnContainerEndLine(container, rawLines); - if (closingBraceColumn < 0) - return false; - - var candidateColumn = FindSignatureOccurrenceStartColumn( - rawLines[lineIndex], - candidate.Signature, - candidate.SameLineSignatureOccurrenceIndex ?? 0, - getCSharpLineStartStates?.Invoke() is { } csharpLineStartStates - && lineIndex < csharpLineStartStates.Length - ? csharpLineStartStates[lineIndex] - : new CSharpLexState()); - return candidateColumn >= 0 && candidateColumn < closingBraceColumn; - } - - private static int FindCSharpClosingBraceColumnOnContainerEndLine(SymbolRecord container, string[] rawLines) - { - if (container.BodyStartLine == null - || container.EndLine <= 0 - || container.EndLine > rawLines.Length - || container.BodyStartLine.Value <= 0 - || container.BodyStartLine.Value > container.EndLine) - { - return -1; - } - - var lexState = new CSharpLexState(); - var depth = 0; - var endLineIndex = container.EndLine - 1; - for (var lineIndex = container.BodyStartLine.Value - 1; lineIndex < endLineIndex; lineIndex++) - { - var lineResult = LexCSharpLine(rawLines[lineIndex], lexState); - lexState = lineResult.EndState; - - foreach (var ch in lineResult.SanitizedLine) - { - if (ch == '{') - { - depth++; - } - else if (ch == '}') - { - depth--; - } - } - } - - var sanitizedLine = LexCSharpLine(rawLines[endLineIndex], lexState).SanitizedLine; - if (depth <= 0) - return -1; - - for (var i = 0; i < sanitizedLine.Length; i++) - { - var ch = sanitizedLine[i]; - if (ch == '{') - { - depth++; - } - else if (ch == '}') - { - depth--; - if (depth == 0) - return i; - } - } - - return -1; - } - - private static int FindSignatureOccurrenceStartColumn( - string rawLine, - string signature, - int occurrenceIndex, - CSharpLexState lineStartState) - { - if (occurrenceIndex < 0 || string.IsNullOrEmpty(rawLine) || string.IsNullOrEmpty(signature)) - return -1; - - // Same-line C# occurrence tracking must ignore declaration lookalikes inside string - // literals and comments, or the nth "real" declaration is mapped onto an earlier - // quoted/commented copy of the same signature. LexCSharpLine preserves original - // columns while blanking those regions, so the resulting indices still line up with - // the raw line. Closes #558. - // same-line C# の occurrence tracking は、文字列リテラルやコメント中の見かけ上の - // 宣言を数えてはいけない。そうしないと n 個目の「本物の」宣言が、より前にある - // quoted/commented な同一 signature へ誤対応付けされる。LexCSharpLine は元の列を - // 保ったまま当該領域だけ空白化するので、得られる index は raw line と整合したまま使える。 - var searchLine = LexCSharpLine(rawLine, lineStartState).SanitizedLine; - var currentOccurrence = 0; - var searchStart = 0; - while (searchStart < searchLine.Length) - { - var matchIndex = searchLine.IndexOf(signature, searchStart, StringComparison.Ordinal); - if (matchIndex < 0) - return -1; - - if (currentOccurrence == occurrenceIndex) - return matchIndex; - - currentOccurrence++; - searchStart = matchIndex + signature.Length; - } - - return -1; - } - - private static int FindCSharpSameLineContainerClosingBraceColumn( - string rawLine, - int containerStartColumn, - CSharpLexState lineStartState) - { - if (containerStartColumn < 0 || containerStartColumn >= rawLine.Length) - return -1; - - var sanitizedLine = LexCSharpLine(rawLine, lineStartState).SanitizedLine; - var openBraceColumn = sanitizedLine.IndexOf('{', containerStartColumn); - if (openBraceColumn < 0) - return -1; - - var depth = 0; - for (var i = openBraceColumn; i < sanitizedLine.Length; i++) - { - var ch = sanitizedLine[i]; - if (ch == '{') - { - depth++; - } - else if (ch == '}') - { - depth--; - if (depth == 0) - return i; - } - } - - return -1; - } - - private static bool CanContainSameLineSymbol(SymbolRecord container, SymbolRecord candidate) - { - return (container.Kind, candidate.Kind) switch - { - ("function", _) when container.ContainerKind == "enum" && container.BodyStartLine != null && container.BodyEndLine != null => true, - ("enum", "enum") => true, - ("namespace", _) => true, - ("class", _) => true, - ("struct", _) => true, - ("interface", _) => true, - ("protocol", _) => true, - _ => false, - }; - } - - // C# file-scoped namespace: `namespace X;` with no braces. Matches only declarations whose - // signature starts with the `namespace` keyword, so body-less namespace rows from other - // languages (e.g. SQL `CREATE SCHEMA ...;` / `ALTER SCHEMA ...;`) are not treated as - // file-scoped and therefore do not wrap every subsequent top-level symbol as their container. - // C# の file-scoped namespace(`namespace X;` 形)だけを対象とする。`namespace` キーワードで - // 始まるシグネチャに限定することで、SQL の `CREATE SCHEMA ...;` / `ALTER SCHEMA ...;` のような - // 他言語の body 無し namespace 行が file-scoped namespace 扱いになり、以降のトップレベル - // シンボル全てを自分の配下にぶら下げてしまう事故を防ぐ。 - private static bool IsFileScopedNamespace(SymbolRecord symbol) - { - if (symbol.Kind != "namespace") - return false; - if (symbol.BodyStartLine != null || symbol.BodyEndLine != null) - return false; - if (symbol.Signature == null) - return false; - var trimmed = symbol.Signature.AsSpan().TrimStart(); - return trimmed.StartsWith("namespace ", StringComparison.Ordinal) - || trimmed.StartsWith("namespace\t", StringComparison.Ordinal); - } private static int CountIndent(string line) { From e1f3a4c52b67894286003811d37b1f1708ee4f0e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:52:48 +0900 Subject: [PATCH 017/101] Separate symbol normalization and Rust supplements --- .../Symbols/SymbolExtractor.Normalization.cs | 391 ++++++++++++++++++ .../Indexer/Symbols/SymbolExtractor.cs | 381 ----------------- 2 files changed, 391 insertions(+), 381 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.Normalization.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Normalization.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Normalization.cs new file mode 100644 index 000000000..9158e7d4a --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Normalization.cs @@ -0,0 +1,391 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private static int CountIndent(string line) + { + int indent = 0; + foreach (var c in line) + { + if (c == ' ') + indent++; + else if (c == '\t') + indent += 4; + else + break; + } + + return indent; + } + + private static bool StartsWithKeyword(string line, int startIndex, string keyword) + { + if (startIndex < 0 || startIndex + keyword.Length > line.Length) + return false; + + if (string.CompareOrdinal(line, startIndex, keyword, 0, keyword.Length) != 0) + return false; + + var nextIndex = startIndex + keyword.Length; + return nextIndex >= line.Length || char.IsWhiteSpace(line[nextIndex]); + } + + private static string? TryGetGroup(Match match, string? groupName) + { + if (groupName == null || !match.Groups[groupName].Success) + return null; + + return NormalizeMetadata(match.Groups[groupName].Value); + } + + private static string? NormalizeMetadata(string? value) + { + if (value is null) + return null; + + var trimmed = value.AsSpan().Trim(); + if (trimmed.IsEmpty) + return null; + + return trimmed.Length == value.Length ? value : trimmed.ToString(); + } + + private static string NormalizeExtractedSymbolName(string? lang, string name, Match match, string matchLine) + { + return lang switch + { + "csharp" => CSharpSymbolNameNormalizer.Normalize(name, match, matchLine), + "cobol" => CobolSymbolNameNormalizer.Normalize(name), + "fsharp" => FSharpSymbolNameNormalizer.Normalize(name), + "java" => JavaSymbolNameNormalizer.Normalize(name), + "kotlin" => KotlinSymbolNameNormalizer.Normalize(name, matchLine), + "ruby" => NormalizeRubySymbolName(name, matchLine), + "rust" => RustSymbolNameNormalizer.Normalize(name), + "smalltalk" => NormalizeSmalltalkSelectorName(name), + "swift" => SwiftSymbolNameNormalizer.Normalize(name), + "vb" => NormalizeVisualBasicSymbolName(name), + "sql" => SqlSymbolNameNormalizer.Normalize(name), + _ => name, + }; + } + + private static string NormalizeVisualBasicSymbolName(string name) + { + var trimmed = name.Trim(); + if (TryValidateVisualBasicIdentifierSegments(trimmed, out var hasEscapedSegment)) + return hasEscapedSegment + ? StripVisualBasicIdentifierEscapes(trimmed) + : trimmed; + + return trimmed; + } + + private static bool TryValidateVisualBasicIdentifierSegments(string name, out bool hasEscapedSegment) + { + hasEscapedSegment = false; + var segmentStart = 0; + for (var index = 0; index <= name.Length; index++) + { + if (index < name.Length && name[index] != '.') + continue; + + var segment = name.AsSpan(segmentStart, index - segmentStart); + if (!IsVisualBasicIdentifierSegment(segment)) + return false; + + hasEscapedSegment |= IsVisualBasicEscapedIdentifier(segment); + segmentStart = index + 1; + } + + return true; + } + + private static bool IsVisualBasicIdentifierSegment(ReadOnlySpan segment) + { + if (segment.Length == 0) + return false; + if (IsVisualBasicEscapedIdentifier(segment)) + return true; + + foreach (var ch in segment) + { + if (ch != '_' && !char.IsLetterOrDigit(ch)) + return false; + } + + return true; + } + + private static bool IsCppTemplateSpecializationSymbol( + string kind, + string name, + string signature, + IReadOnlyList lines, + int lineIndex) + { + if (kind is not ("class" or "struct" or "union" or "function")) + return false; + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature)) + return false; + if (!signature.Contains(name + "<", StringComparison.Ordinal)) + return false; + + var trimmedSignature = signature.AsSpan().TrimStart(); + if (trimmedSignature.StartsWith("template", StringComparison.Ordinal) + || trimmedSignature.StartsWith("export template", StringComparison.Ordinal)) + { + return true; + } + + for (var previousLineIndex = lineIndex - 1; previousLineIndex >= 0; previousLineIndex--) + { + var previous = lines[previousLineIndex].AsSpan().Trim(); + if (previous.IsEmpty) + continue; + return previous.StartsWith("template", StringComparison.Ordinal) + || previous.StartsWith("export template", StringComparison.Ordinal); + } + + return false; + } + + private static readonly Regex RustAssociatedTypeDefaultRegex = new( + @"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?type\s+(?(?:r#)?\w+)(?:\s*<[^=;]+>)?(?:\s*:[^=;]+)?\s*=\s*(?[^;]+)\s*;", + RegexOptions.Compiled); + + private static void ExtractRustAssociatedTypeDefaultSymbols(long fileId, string[] lines, string[] structuralLines, List symbols) + { + if (!LinesContain(lines, "type", StringComparison.Ordinal)) + return; + + var traits = BuildRustAssociatedTypeContainerSnapshot(symbols); + if (traits.Count == 0) + return; + + foreach (var trait in traits) + { + if (!TryFindRustBraceBodyBounds(structuralLines, trait.StartLine - 1, out var startLineIndex, out var endLineIndex)) + continue; + + var depth = 1; + for (var lineIndex = startLineIndex + 1; lineIndex < endLineIndex; lineIndex++) + { + if (depth == 1) + { + var match = RustAssociatedTypeDefaultRegex.Match(lines[lineIndex]); + if (match.Success) + { + var nameGroup = match.Groups["name"]; + var name = RustSymbolNameNormalizer.Normalize(nameGroup.Value); + var lineNumber = lineIndex + 1; + symbols.Add(new SymbolRecord + { + FileId = fileId, + Kind = "property", + Name = name, + Line = lineNumber, + StartLine = lineNumber, + StartColumn = nameGroup.Index, + EndLine = lineNumber, + Signature = lines[lineIndex].Trim(), + ContainerKind = trait.Kind, + ContainerName = trait.Name, + ContainerQualifiedName = trait.ContainerQualifiedName, + Visibility = match.Groups["visibility"].Success ? match.Groups["visibility"].Value : null, + ReturnType = match.Groups["returnType"].ValueSpan.Trim().ToString(), + }); + } + } + + depth = Math.Max(1, depth + CountBraceDelta(structuralLines[lineIndex])); + } + } + } + + private static IReadOnlyList BuildRustAssociatedTypeContainerSnapshot(IReadOnlyList symbols) + { + List<(SymbolRecord Symbol, int OriginalIndex)>? candidates = null; + for (var index = 0; index < symbols.Count; index++) + { + var symbol = symbols[index]; + if (symbol.Kind is ("interface" or "protocol") + && symbol.BodyStartLine is > 0 + && symbol.BodyEndLine is > 0) + { + (candidates ??= []).Add((symbol, index)); + } + } + + if (candidates is null) + return Array.Empty(); + + if (candidates.Count == 1) + return [candidates[0].Symbol]; + + candidates.Sort(static (left, right) => + { + var comparison = left.Symbol.StartLine.CompareTo(right.Symbol.StartLine); + return comparison != 0 + ? comparison + : left.OriginalIndex.CompareTo(right.OriginalIndex); + }); + + var snapshot = new SymbolRecord[candidates.Count]; + for (var i = 0; i < candidates.Count; i++) + snapshot[i] = candidates[i].Symbol; + return snapshot; + } + + private static bool TryFindRustBraceBodyBounds(string[] structuralLines, int startLineIndex, out int bodyStartLineIndex, out int bodyEndLineIndex) + { + bodyStartLineIndex = 0; + bodyEndLineIndex = 0; + if (startLineIndex < 0 || startLineIndex >= structuralLines.Length) + return false; + + var depth = 0; + var opened = false; + for (var lineIndex = startLineIndex; lineIndex < structuralLines.Length; lineIndex++) + { + var line = structuralLines[lineIndex]; + if (!opened) + { + var openColumn = line.IndexOf('{'); + if (openColumn < 0) + continue; + + opened = true; + bodyStartLineIndex = lineIndex; + depth = 1 + CountBraceDelta(line[(openColumn + 1)..]); + } + else + { + depth += CountBraceDelta(line); + } + + if (opened && depth == 0) + { + bodyEndLineIndex = lineIndex; + return true; + } + } + + return false; + } + + private static int CountBraceDelta(string line) + { + var delta = 0; + var inDoubleQuote = false; + var escapeNext = false; + for (var index = 0; index < line.Length; index++) + { + if (escapeNext) + { + escapeNext = false; + continue; + } + + if (inDoubleQuote && line[index] == '\\') + { + escapeNext = true; + continue; + } + + if (line[index] == '"') + { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (inDoubleQuote) + continue; + + if (index + 1 < line.Length && line[index] == '/' && line[index + 1] == '/') + break; + + if (line[index] == '{') + delta++; + else if (line[index] == '}') + delta--; + } + + return delta; + } + + private static bool IsVisualBasicEscapedIdentifier(ReadOnlySpan segment) + => segment.Length >= 2 && segment[0] == '[' && segment[^1] == ']'; + + private static string StripVisualBasicIdentifierEscapes(string name) + { + var builder = new StringBuilder(name.Length); + var segmentStart = 0; + for (var index = 0; index <= name.Length; index++) + { + if (index < name.Length && name[index] != '.') + continue; + + if (segmentStart > 0) + builder.Append('.'); + + var segment = name.AsSpan(segmentStart, index - segmentStart); + builder.Append(IsVisualBasicEscapedIdentifier(segment) + ? segment[1..^1] + : segment); + segmentStart = index + 1; + } + + return builder.ToString(); + } + + private static string NormalizeRubySymbolName(string name, string matchLine) + { + if (!matchLine.AsSpan().TrimStart().StartsWith("require", StringComparison.Ordinal)) + return name; + + var trimmed = name.AsSpan().Trim(); + if (trimmed.Length >= 2 + && ((trimmed[0] == '\'' && trimmed[^1] == '\'') + || (trimmed[0] == '"' && trimmed[^1] == '"'))) + { + return trimmed[1..^1].ToString(); + } + + return trimmed.Length == name.Length ? name : trimmed.ToString(); + } + + private static string NormalizeSmalltalkSelectorName(string name) + { + var trimmed = name.Trim(); + if (!trimmed.Contains(':')) + return trimmed; + + var builder = new StringBuilder(trimmed.Length); + var tokenStart = -1; + for (var index = 0; index <= trimmed.Length; index++) + { + var atEnd = index == trimmed.Length; + if (!atEnd && !char.IsWhiteSpace(trimmed[index])) + { + if (tokenStart < 0) + tokenStart = index; + continue; + } + + if (tokenStart < 0) + continue; + + if (trimmed[index - 1] == ':') + builder.Append(trimmed, tokenStart, index - tokenStart); + + tokenStart = -1; + } + + return builder.Length == 0 ? trimmed : builder.ToString(); + } +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 2afdb9a52..7a2e1c810 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -3612,387 +3612,6 @@ private static bool IsCSharpKeywordAt(string line, int index, string keyword) - private static int CountIndent(string line) - { - int indent = 0; - foreach (var c in line) - { - if (c == ' ') - indent++; - else if (c == '\t') - indent += 4; - else - break; - } - - return indent; - } - - private static bool StartsWithKeyword(string line, int startIndex, string keyword) - { - if (startIndex < 0 || startIndex + keyword.Length > line.Length) - return false; - - if (string.CompareOrdinal(line, startIndex, keyword, 0, keyword.Length) != 0) - return false; - - var nextIndex = startIndex + keyword.Length; - return nextIndex >= line.Length || char.IsWhiteSpace(line[nextIndex]); - } - - private static string? TryGetGroup(Match match, string? groupName) - { - if (groupName == null || !match.Groups[groupName].Success) - return null; - - return NormalizeMetadata(match.Groups[groupName].Value); - } - - private static string? NormalizeMetadata(string? value) - { - if (value is null) - return null; - - var trimmed = value.AsSpan().Trim(); - if (trimmed.IsEmpty) - return null; - - return trimmed.Length == value.Length ? value : trimmed.ToString(); - } - - private static string NormalizeExtractedSymbolName(string? lang, string name, Match match, string matchLine) - { - return lang switch - { - "csharp" => CSharpSymbolNameNormalizer.Normalize(name, match, matchLine), - "cobol" => CobolSymbolNameNormalizer.Normalize(name), - "fsharp" => FSharpSymbolNameNormalizer.Normalize(name), - "java" => JavaSymbolNameNormalizer.Normalize(name), - "kotlin" => KotlinSymbolNameNormalizer.Normalize(name, matchLine), - "ruby" => NormalizeRubySymbolName(name, matchLine), - "rust" => RustSymbolNameNormalizer.Normalize(name), - "smalltalk" => NormalizeSmalltalkSelectorName(name), - "swift" => SwiftSymbolNameNormalizer.Normalize(name), - "vb" => NormalizeVisualBasicSymbolName(name), - "sql" => SqlSymbolNameNormalizer.Normalize(name), - _ => name, - }; - } - - private static string NormalizeVisualBasicSymbolName(string name) - { - var trimmed = name.Trim(); - if (TryValidateVisualBasicIdentifierSegments(trimmed, out var hasEscapedSegment)) - return hasEscapedSegment - ? StripVisualBasicIdentifierEscapes(trimmed) - : trimmed; - - return trimmed; - } - - private static bool TryValidateVisualBasicIdentifierSegments(string name, out bool hasEscapedSegment) - { - hasEscapedSegment = false; - var segmentStart = 0; - for (var index = 0; index <= name.Length; index++) - { - if (index < name.Length && name[index] != '.') - continue; - - var segment = name.AsSpan(segmentStart, index - segmentStart); - if (!IsVisualBasicIdentifierSegment(segment)) - return false; - - hasEscapedSegment |= IsVisualBasicEscapedIdentifier(segment); - segmentStart = index + 1; - } - - return true; - } - - private static bool IsVisualBasicIdentifierSegment(ReadOnlySpan segment) - { - if (segment.Length == 0) - return false; - if (IsVisualBasicEscapedIdentifier(segment)) - return true; - - foreach (var ch in segment) - { - if (ch != '_' && !char.IsLetterOrDigit(ch)) - return false; - } - - return true; - } - - private static bool IsCppTemplateSpecializationSymbol( - string kind, - string name, - string signature, - IReadOnlyList lines, - int lineIndex) - { - if (kind is not ("class" or "struct" or "union" or "function")) - return false; - if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature)) - return false; - if (!signature.Contains(name + "<", StringComparison.Ordinal)) - return false; - - var trimmedSignature = signature.AsSpan().TrimStart(); - if (trimmedSignature.StartsWith("template", StringComparison.Ordinal) - || trimmedSignature.StartsWith("export template", StringComparison.Ordinal)) - { - return true; - } - - for (var previousLineIndex = lineIndex - 1; previousLineIndex >= 0; previousLineIndex--) - { - var previous = lines[previousLineIndex].AsSpan().Trim(); - if (previous.IsEmpty) - continue; - return previous.StartsWith("template", StringComparison.Ordinal) - || previous.StartsWith("export template", StringComparison.Ordinal); - } - - return false; - } - - private static readonly Regex RustAssociatedTypeDefaultRegex = new( - @"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?type\s+(?(?:r#)?\w+)(?:\s*<[^=;]+>)?(?:\s*:[^=;]+)?\s*=\s*(?[^;]+)\s*;", - RegexOptions.Compiled); - - private static void ExtractRustAssociatedTypeDefaultSymbols(long fileId, string[] lines, string[] structuralLines, List symbols) - { - if (!LinesContain(lines, "type", StringComparison.Ordinal)) - return; - - var traits = BuildRustAssociatedTypeContainerSnapshot(symbols); - if (traits.Count == 0) - return; - - foreach (var trait in traits) - { - if (!TryFindRustBraceBodyBounds(structuralLines, trait.StartLine - 1, out var startLineIndex, out var endLineIndex)) - continue; - - var depth = 1; - for (var lineIndex = startLineIndex + 1; lineIndex < endLineIndex; lineIndex++) - { - if (depth == 1) - { - var match = RustAssociatedTypeDefaultRegex.Match(lines[lineIndex]); - if (match.Success) - { - var nameGroup = match.Groups["name"]; - var name = RustSymbolNameNormalizer.Normalize(nameGroup.Value); - var lineNumber = lineIndex + 1; - symbols.Add(new SymbolRecord - { - FileId = fileId, - Kind = "property", - Name = name, - Line = lineNumber, - StartLine = lineNumber, - StartColumn = nameGroup.Index, - EndLine = lineNumber, - Signature = lines[lineIndex].Trim(), - ContainerKind = trait.Kind, - ContainerName = trait.Name, - ContainerQualifiedName = trait.ContainerQualifiedName, - Visibility = match.Groups["visibility"].Success ? match.Groups["visibility"].Value : null, - ReturnType = match.Groups["returnType"].ValueSpan.Trim().ToString(), - }); - } - } - - depth = Math.Max(1, depth + CountBraceDelta(structuralLines[lineIndex])); - } - } - } - - private static IReadOnlyList BuildRustAssociatedTypeContainerSnapshot(IReadOnlyList symbols) - { - List<(SymbolRecord Symbol, int OriginalIndex)>? candidates = null; - for (var index = 0; index < symbols.Count; index++) - { - var symbol = symbols[index]; - if (symbol.Kind is ("interface" or "protocol") - && symbol.BodyStartLine is > 0 - && symbol.BodyEndLine is > 0) - { - (candidates ??= []).Add((symbol, index)); - } - } - - if (candidates is null) - return Array.Empty(); - - if (candidates.Count == 1) - return [candidates[0].Symbol]; - - candidates.Sort(static (left, right) => - { - var comparison = left.Symbol.StartLine.CompareTo(right.Symbol.StartLine); - return comparison != 0 - ? comparison - : left.OriginalIndex.CompareTo(right.OriginalIndex); - }); - - var snapshot = new SymbolRecord[candidates.Count]; - for (var i = 0; i < candidates.Count; i++) - snapshot[i] = candidates[i].Symbol; - return snapshot; - } - - private static bool TryFindRustBraceBodyBounds(string[] structuralLines, int startLineIndex, out int bodyStartLineIndex, out int bodyEndLineIndex) - { - bodyStartLineIndex = 0; - bodyEndLineIndex = 0; - if (startLineIndex < 0 || startLineIndex >= structuralLines.Length) - return false; - - var depth = 0; - var opened = false; - for (var lineIndex = startLineIndex; lineIndex < structuralLines.Length; lineIndex++) - { - var line = structuralLines[lineIndex]; - if (!opened) - { - var openColumn = line.IndexOf('{'); - if (openColumn < 0) - continue; - - opened = true; - bodyStartLineIndex = lineIndex; - depth = 1 + CountBraceDelta(line[(openColumn + 1)..]); - } - else - { - depth += CountBraceDelta(line); - } - - if (opened && depth == 0) - { - bodyEndLineIndex = lineIndex; - return true; - } - } - - return false; - } - - private static int CountBraceDelta(string line) - { - var delta = 0; - var inDoubleQuote = false; - var escapeNext = false; - for (var index = 0; index < line.Length; index++) - { - if (escapeNext) - { - escapeNext = false; - continue; - } - - if (inDoubleQuote && line[index] == '\\') - { - escapeNext = true; - continue; - } - - if (line[index] == '"') - { - inDoubleQuote = !inDoubleQuote; - continue; - } - - if (inDoubleQuote) - continue; - - if (index + 1 < line.Length && line[index] == '/' && line[index + 1] == '/') - break; - - if (line[index] == '{') - delta++; - else if (line[index] == '}') - delta--; - } - - return delta; - } - - private static bool IsVisualBasicEscapedIdentifier(ReadOnlySpan segment) - => segment.Length >= 2 && segment[0] == '[' && segment[^1] == ']'; - - private static string StripVisualBasicIdentifierEscapes(string name) - { - var builder = new StringBuilder(name.Length); - var segmentStart = 0; - for (var index = 0; index <= name.Length; index++) - { - if (index < name.Length && name[index] != '.') - continue; - - if (segmentStart > 0) - builder.Append('.'); - - var segment = name.AsSpan(segmentStart, index - segmentStart); - builder.Append(IsVisualBasicEscapedIdentifier(segment) - ? segment[1..^1] - : segment); - segmentStart = index + 1; - } - - return builder.ToString(); - } - - private static string NormalizeRubySymbolName(string name, string matchLine) - { - if (!matchLine.AsSpan().TrimStart().StartsWith("require", StringComparison.Ordinal)) - return name; - - var trimmed = name.AsSpan().Trim(); - if (trimmed.Length >= 2 - && ((trimmed[0] == '\'' && trimmed[^1] == '\'') - || (trimmed[0] == '"' && trimmed[^1] == '"'))) - { - return trimmed[1..^1].ToString(); - } - - return trimmed.Length == name.Length ? name : trimmed.ToString(); - } - - private static string NormalizeSmalltalkSelectorName(string name) - { - var trimmed = name.Trim(); - if (!trimmed.Contains(':')) - return trimmed; - - var builder = new StringBuilder(trimmed.Length); - var tokenStart = -1; - for (var index = 0; index <= trimmed.Length; index++) - { - var atEnd = index == trimmed.Length; - if (!atEnd && !char.IsWhiteSpace(trimmed[index])) - { - if (tokenStart < 0) - tokenStart = index; - continue; - } - - if (tokenStart < 0) - continue; - - if (trimmed[index - 1] == ':') - builder.Append(trimmed, tokenStart, index - tokenStart); - - tokenStart = -1; - } - - return builder.Length == 0 ? trimmed : builder.ToString(); - } private static readonly Regex ComplexityRegex = new( @"\b(?:if|else\s+if|elif|elsif|elseif|case|catch|except|when|while|for|foreach|guard)\b|(?:\?\?|&&|\|\||[?:](?!=))", From 7e6e9a75773d76b47b10bd49845fce379a3313d0 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 12:57:18 +0900 Subject: [PATCH 018/101] Split MCP tool catalog construction --- src/CodeIndex/Mcp/McpToolCatalog.cs | 610 ++++++++++++++++++++++++ src/CodeIndex/Mcp/McpToolDefinitions.cs | 601 +---------------------- 2 files changed, 614 insertions(+), 597 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpToolCatalog.cs diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs new file mode 100644 index 000000000..4586775a5 --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -0,0 +1,610 @@ +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Models; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + private static JsonArray CreateToolCatalog() + { + var tools = new JsonArray + { + CreateToolDefinition( + "search", + "Use this when starting broad code discovery, checking error text, or running named search audit recipes. Prefer it before shell grep; common next step is `excerpt`, `definition`, or `references` on the best hit. Returns snippets plus `result_stable_at`, `next_cursor`, and `next_step_suggestion` or `recovery_hint`. Use `prefix`/trailing `*` to widen token matching, `rawQuery` for FTS5 syntax, `exactSubstring` for case-sensitive identity, and `tokenBoundary` when a code phrase must not match inside longer identifiers. Details and examples: USER_GUIDE.md#search. / 広いコード調査、エラー文言確認、search audit recipe 実行の起点に使う。shell grep より優先し、次は最有力ヒットに `excerpt` / `definition` / `references` を使う。`prefix` / 末尾 `*` / `rawQuery` / `exactSubstring` / `tokenBoundary` の詳細と例は USER_GUIDE.md#search を参照。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Search query text. Append `*` to a token to make that token a prefix phrase (`計算*` matches `計算する`)." }, + ["recipe"] = new JsonObject { ["type"] = "string", ["description"] = "Run a named search audit recipe instead of a single query. Use `listRecipes:true` to discover available recipe names." }, + ["listRecipes"] = new JsonObject { ["type"] = "boolean", ["description"] = "List built-in and configured search audit recipes without running a search.", ["default"] = false }, + ["auditScope"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "source", "all" }, ["description"] = "Recipe runs only: source applies the recipe's production-code default path/exclusion scope; all searches every indexed path unless other filters exclude it." }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated` and `more_available` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language (e.g. csharp, python, javascript)" }, + ["snippetLines"] = new JsonObject { ["type"] = "integer", ["description"] = "Max snippet lines per result (default: 8, max: 20)", ["default"] = 8, ["minimum"] = 1, ["maximum"] = SearchSnippetFormatter.MaxSnippetLines }, + ["snippetFocus"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "quality", "leftmost", "proximity" }, ["description"] = "Snippet anchoring mode matching CLI `--snippet-focus`: quality (default), leftmost, or proximity.", ["default"] = "quality" }, + ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line snippets per line (default: 512; 0 disables clamping). Match lines are clamped around the first match; non-match lines are clamped from the head. Each clamp inserts a `...(+N)...` marker showing how many chars were elided.", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, + ["rawQuery"] = new JsonObject { ["type"] = "boolean", ["description"] = "Use raw FTS5 syntax instead of literal-safe quoting: content:term, NEAR(a b, 5), OR, NOT, parenthesized groups, prefix*, and quoted phrases.", ["default"] = false }, + ["cursor"] = new JsonObject { ["type"] = "string", ["description"] = "Optional pagination cursor returned as `next_cursor` by a previous search response with the same query and filters. Compare `result_stable_at` across pages to detect index drift." }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude glob-style path patterns. `*` and `?` are wildcards."), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, + ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to files modified since this ISO 8601 timestamp" }, + ["noDedup"] = new JsonObject { ["type"] = "boolean", ["description"] = "Disable overlapping-chunk deduplication and return every raw chunk hit; useful for debugging chunk boundaries or measuring raw match density.", ["default"] = false }, + ["exactSubstring"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for search's exact mode: case-sensitive exact substring match (bypasses FTS5).", ["default"] = false }, + ["tokenBoundary"] = new JsonObject { ["type"] = "boolean", ["description"] = "Case-sensitive exact code-phrase match that also requires identifier/token boundaries around the full query, so `new HttpClient` does not match `new HttpClientHandler`.", ["default"] = false }, + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactSubstring`.", ["default"] = false }, + ["prefix"] = new JsonObject { ["type"] = "boolean", ["description"] = "Opt into FTS5 prefix expansion for every token in `query`. Cannot be combined with `exact`/`exactSubstring`/`tokenBoundary`.", ["default"] = false }, + ["requireBefore"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Keep search matches only when this guard query appears within `guardWindow` lines before the primary match. Accepts a string or string array." }, + ["requireAfter"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Keep search matches only when this guard query appears within `guardWindow` lines after the primary match. Accepts a string or string array." }, + ["rejectBefore"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Drop search matches when this guard query appears within `guardWindow` lines before the primary match. Accepts a string or string array." }, + ["rejectAfter"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Drop search matches when this guard query appears within `guardWindow` lines after the primary match. Accepts a string or string array." }, + ["guardWindow"] = new JsonObject { ["type"] = "integer", ["description"] = $"Line window for guard queries (default: {DbReader.DefaultSearchGuardWindow}, max: {DbReader.MaxSearchGuardWindow}).", ["default"] = DbReader.DefaultSearchGuardWindow, ["minimum"] = 0, ["maximum"] = DbReader.MaxSearchGuardWindow }, + ["guardScope"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "window", "same-line" }, ["description"] = "Evaluate guard queries in the line window or only on the same line before/after the primary match.", ["default"] = "window" }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full rows, count-only metadata, or compact file/line rows without snippets.", ["default"] = "full" } + }, + ["anyOf"] = new JsonArray + { + new JsonObject { ["required"] = new JsonArray { "query" } }, + new JsonObject { ["required"] = new JsonArray { "recipe" } }, + new JsonObject { ["required"] = new JsonArray { "listRecipes" } } + } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "definition", + "Use this when you know or suspect a symbol name and need its declaration before editing. Prefer `exactName:true` for identity checks; common next step is `references` or `excerpt`. Resolve symbol definitions with ranges, signatures, and optional body content. Pass `lsp_compatible:true` to add `uri` and LSP `range` fields to each result. `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Examples: `definition {\"query\":\"McpServer\"}`; `definition {\"query\":\"HandleMessage\",\"lang\":\"csharp\",\"includeBody\":true,\"exactName\":true}`. / シンボル名が分かる、または推測できるときに編集前の宣言確認に使う。identity 確認では `exactName:true` を優先し、次は `references` または `excerpt` を使う。定義範囲、シグネチャ、必要に応じて本体内容付きでシンボル定義を解決。例: `definition {\"query\":\"McpServer\"}`; `definition {\"query\":\"HandleMessage\",\"lang\":\"csharp\",\"includeBody\":true,\"exactName\":true}`。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name pattern to resolve" }, + ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind" }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, + ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, + ["includeBody"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include body content when body ranges are available", ["default"] = false }, + ["lsp_compatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Add file:// uri and LSP range fields to each result", ["default"] = false }, + ["lspCompatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Alias for `lsp_compatible` for JSON-style clients.", ["default"] = false }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, + ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to symbols in files modified since this ISO 8601 timestamp" }, + ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact symbol-name equality: NFKC + Unicode CaseFold exact name match instead of substring, so `Run` no longer also returns `RunAsync`.", ["default"] = false }, + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full rows, count-only metadata, or compact file/line rows without excerpts.", ["default"] = "full" } + }, + ["required"] = new JsonArray { "query" } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "references", + "Use this when you need usage sites, examples, tests, metadata references, or type-position references for a symbol. Prefer it after `definition`; common next step is `excerpt` on representative rows or `callers`/`callees` for runtime impact. Search indexed symbol references such as call sites. Non-empty responses include `next_step_suggestion`; empty responses include `recovery_hint`. Pass `lsp_compatible:true` to add `uri` and LSP `range` fields to each result. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, all indexed reference kinds including metadata uses (`attribute` / `annotation`), JavaScript/TypeScript discriminant tags (`type_tag`), C# BCL Regex timeout audit rows (`bcl_regex_without_timeout`), and compile-time type-position references (`type_reference`) stay visible, and identical constructor `call` + `instantiate` rows at one physical site are collapsed. Pass `kind: \"type_tag\"` to enumerate discriminant comparisons such as `shape.type === \"circle\"`. Pass `kind: \"type_reference\"` to enumerate declaration types, generic constraints, `is`/`as`/`instanceof`, and XML-doc `cref` targets. Pass `kind: \"bcl_regex_without_timeout\"` with query `Regex` to audit direct System.Text.RegularExpressions.Regex construction without a timeout argument. Examples: `references {\"query\":\"Run\"}`; `references {\"query\":\"Service\",\"kind\":\"type_reference\",\"lang\":\"csharp\"}`. / シンボルの利用箇所、例、テスト、metadata 参照、型位置参照を調べるときに使う。`definition` の後に優先し、次は代表行の `excerpt` または実行時影響の `callers` / `callees` を使う。`kind: \"type_tag\"` で JavaScript / TypeScript の discriminant 比較を列挙できる。`kind: \"bcl_regex_without_timeout\"` と query `Regex` で timeout 引数なしの直接 `System.Text.RegularExpressions.Regex` 生成を監査できる。例: `references {\"query\":\"Run\"}`; `references {\"query\":\"Service\",\"kind\":\"type_reference\",\"lang\":\"csharp\"}`。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Referenced symbol name pattern to search for" }, + ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by reference kind (call, instantiate, subscribe, friend, attribute, annotation, type_reference, type_tag, bcl_regex_without_timeout)" }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, + ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line context payloads per result (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, + ["lsp_compatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Add file:// uri and LSP range fields to each result", ["default"] = false }, + ["lspCompatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Alias for `lsp_compatible` for JSON-style clients.", ["default"] = false }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, + ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact referenced-symbol equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full rows, count-only metadata, or compact file/line/column rows without context.", ["default"] = "full" } + }, + ["required"] = new JsonArray { "query" } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "callers", + "Use this when you need to know what calls or depends on a callee symbol before changing it. Prefer it after `definition`/`references`; common next step is `excerpt` on high-ranked caller rows. Find caller symbols that reference a callee. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, only executable kinds (`call`, `instantiate`, `subscribe`) are returned; pass `kind: \"friend\"` explicitly for C++ friend access/coupling edges, while metadata uses (`attribute` / `annotation`) and compile-time type-position references (`type_reference`) do not pollute caller edges; identical constructor `call` + `instantiate` rows at one physical site also collapse. Public `reference_kind`, `reference_kinds`, and `reference_kind_counts` use the same canonical vocabulary. Each grouped row additionally exposes `reference_kinds` (sorted distinct kinds behind the row) and `has_mixed_reference_kinds` so callers do not have to trust the single summary label when a container mixes `call` + `subscribe` edges. The existing `reference_kind` scalar is retained for back-compat and carries the canonical summary priority (`instantiate` > `subscribe` > `call`); `rawKinds` preserves raw-kind priority. `callers` / `callees` are not a reliable path to metadata or type-position references — metadata rows are attributed to their enclosing body-range symbol (for a class-level declaration, that is the class itself; for a file-level target such as `[assembly: ...]`, `containerName` is `null` and the row drops from these graph queries entirely), and `type_reference` rows are compile-time type mentions (declaration types, generic constraints, `is`/`as`/`instanceof`, XML-doc `cref`) rather than runtime calls. Use `references` with `kind: \"attribute\"`, `\"annotation\"`, or `\"type_reference\"` instead. Examples: `callers {\"query\":\"HandleRequest\"}`; `callers {\"query\":\"ExecuteAsync\",\"kind\":\"call\",\"rankBy\":\"weighted\",\"lang\":\"csharp\"}`. / callee シンボルの変更前に呼び出し元や依存元を知りたいときに使う。`definition` / `references` の後に優先し、次は上位 caller 行の `excerpt` を使う。指定シンボルを参照している呼び出し元シンボルを探す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。`kind` 未指定時は実行可能な種別 (`call` / `instantiate` / `subscribe`) だけを返す。C++ friend の access/coupling edge は `kind: \"friend\"` を明示する。metadata 使用 (`attribute` / `annotation`) と compile-time な型位置参照 (`type_reference`) が phantom caller edge として混入しないようにする。同じ物理位置にある constructor の `call` + `instantiate` 重複行も集約する。公開 `reference_kind`、`reference_kinds`、`reference_kind_counts` は同じ canonical 語彙を使う。各グループ行には `reference_kinds`(行内の distinct kind をソートした配列)と `has_mixed_reference_kinds` も追加で返すため、container が `call` + `subscribe` を混在させている行で要約 1 ラベルに騙されずに済む。既存のスカラー `reference_kind` は後方互換のため維持され、canonical な優先サマリー種別(`instantiate` > `subscribe` > `call`)を持つ。`rawKinds` 指定時は raw kind の優先順を持つ。metadata 行の container は注釈対象そのものではなく body-range 上の外側シンボル(クラス直下宣言ならクラス、ファイルレベル target なら `null`)になり、`type_reference` は実行時呼び出しではなく宣言型・generic 制約・`is`/`as`/`instanceof`・XML-doc `cref` といった compile-time な型言及なので、`callers` / `callees` は metadata / 型位置参照の列挙に向かない。Metadata / 型位置参照の列挙は `references --kind attribute|annotation|type_reference` / MCP `references` を使う。例: `callers {\"query\":\"HandleRequest\"}`; `callers {\"query\":\"ExecuteAsync\",\"kind\":\"call\",\"rankBy\":\"weighted\",\"lang\":\"csharp\"}`。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Callee symbol name pattern to search for" }, + ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by edge kind. Default results use the canonical call, instantiate, subscribe vocabulary; non-default `friend` remains available explicitly. Metadata and type-only kinds — metadata (attribute, annotation), type-position (type_reference), and JS/TS discriminant narrowing (type_tag) — are rejected here; use `references` with the desired kind instead." }, + ["rawKinds"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preserve raw reference kinds instead of canonical CLI grouping, matching `--raw-kinds`.", ["default"] = false }, + ["rankBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "weighted", "count", "kind" }, ["description"] = "Ranking model: weighted (default; instantiate=3.0, call=1.0, subscribe=0.1), count, or kind.", ["default"] = "weighted" }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, + ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact callee-name equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full rows, count-only metadata, or compact file/line rows without excerpts.", ["default"] = "full" } + }, + ["required"] = new JsonArray { "query" } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "callees", + "Use this when you need to know what a caller/container symbol invokes or depends on. Prefer it after `definition` or `outline`; common next step is `excerpt` on a callee row. Find callees used by a caller/container symbol. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, only executable kinds (`call`, `instantiate`, `subscribe`) are returned; pass `kind: \"friend\"` explicitly for C++ friend access/coupling edges, while metadata uses (`attribute` / `annotation`) and compile-time type-position references (`type_reference`) do not pollute callee edges; identical constructor `call` + `instantiate` rows at one physical site also collapse. Public `reference_kind`, `reference_kinds`, and `reference_kind_counts` use the same canonical vocabulary. Each grouped row additionally exposes `reference_kinds` (sorted distinct kinds behind the row) and `has_mixed_reference_kinds` for symmetry with `callers`, even though rows are already split per kind on this side. The existing `reference_kind` scalar is retained for back-compat and carries the same kind value. `callees` is not a reliable path to metadata or type-position references — the container assigned to an attribute / annotation row is the enclosing body-range symbol, not the annotated declaration, so `callees Method1 --kind attribute` does not return the attributes on `Method1`, and `type_reference` rows are compile-time type mentions (declaration types, generic constraints, `is`/`as`/`instanceof`, XML-doc `cref`) rather than runtime calls. Use `references` with `kind: \"attribute\"`, `\"annotation\"`, or `\"type_reference\"` instead. Examples: `callees {\"query\":\"Run\"}`; `callees {\"query\":\"Program.Main\",\"kind\":\"instantiate\",\"lang\":\"csharp\",\"limit\":10}`. / caller/container シンボルが呼ぶ先や依存先を知りたいときに使う。`definition` または `outline` の後に優先し、次は callee 行の `excerpt` を使う。呼び出し元シンボルが使っている呼び出し先を探す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。`kind` 未指定時は実行可能な種別 (`call` / `instantiate` / `subscribe`) だけを返す。C++ friend の access/coupling edge は `kind: \"friend\"` を明示する。metadata 使用 (`attribute` / `annotation`) と compile-time な型位置参照 (`type_reference`) が phantom callee edge として混入しないようにする。同じ物理位置にある constructor の `call` + `instantiate` 重複行も集約する。公開 `reference_kind`、`reference_kinds`、`reference_kind_counts` は同じ canonical 語彙を使う。各グループ行には `callers` との対称性のため `reference_kinds`(行内の distinct kind をソートした配列)と `has_mixed_reference_kinds` も返る(`callees` 側は元々 kind ごとに行を分けているため通常は単一要素)。既存のスカラー `reference_kind` は後方互換のため維持され、同じ kind 値を持つ。metadata 行の container は注釈対象自身ではなく body-range 上の外側シンボルになるため、`callees` で `Method1 --kind attribute` を引いても `Method1` に付いた属性は返らない。`type_reference` は実行時呼び出しではなく宣言型・generic 制約・`is`/`as`/`instanceof`・XML-doc `cref` といった compile-time な型言及なので、`callees` は metadata / 型位置参照の列挙に向かない。Metadata / 型位置参照の列挙は `references --kind attribute|annotation|type_reference` / MCP `references` を使う。例: `callees {\"query\":\"Run\"}`; `callees {\"query\":\"Program.Main\",\"kind\":\"instantiate\",\"lang\":\"csharp\",\"limit\":10}`。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Caller/container symbol name pattern to search for" }, + ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by edge kind. Default results use the canonical call, instantiate, subscribe vocabulary; non-default graph kinds remain available explicitly. Metadata and type-only kinds — metadata (attribute, annotation), type-position (type_reference), and JS/TS discriminant narrowing (type_tag) — are rejected here; use `references` with the desired kind instead." }, + ["rawKinds"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preserve raw reference kinds instead of canonical CLI grouping, matching `--raw-kinds`.", ["default"] = false }, + ["rankBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "weighted", "count", "kind" }, ["description"] = "Ranking model: weighted (default; instantiate=3.0, call=1.0, subscribe=0.1), count, or kind.", ["default"] = "weighted" }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, + ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact caller/container equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full rows, count-only metadata, or compact file/line rows without excerpts.", ["default"] = "full" } + }, + ["required"] = new JsonArray { "query" } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "symbols", + "Use this when discovering candidate symbols before `definition`, `references`, `callers`, or `callees`. Prefer `exactName:true` when the name must match exactly. Search for code symbols (functions, classes, interfaces, imports) by name pattern. `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Examples: `symbols {\"query\":\"Service\"}`; `symbols {\"query\":\"Run\",\"kind\":\"function\",\"lang\":\"csharp\",\"exactName\":true}`. / `definition` / `references` / `callers` / `callees` の前に候補シンボルを探すときに使う。名前を厳密一致させるなら `exactName:true` を優先する。シンボル(関数、クラス、インターフェース、import)を名前パターンで検索。例: `symbols {\"query\":\"Service\"}`; `symbols {\"query\":\"Run\",\"kind\":\"function\",\"lang\":\"csharp\",\"exactName\":true}`。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name pattern to search for. Treated as a literal substring (no `|`-OR sugar), so operator symbols such as `operator |` remain searchable." }, + ["names"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Optional list of additional symbol name patterns, OR-joined with `query`. Use this to resolve multiple candidate names in one call." }, + ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind (function, class, interface, import, etc.)" }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, + ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, + ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to symbols in files modified since this ISO 8601 timestamp" }, + ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact symbol-name equality instead of substring, so `Run` no longer matches `RunAsync`/`RunImpact`.", ["default"] = false }, + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return count metadata and a top-file histogram without symbol rows.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full symbol rows, count metadata, or compact file/line/kind/name rows.", ["default"] = "full" } + } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "files", + "Use this when you need to locate indexed files by path, language, or recent-change scope before reading content. Prefer `outline` or `excerpt` as the next step after choosing a file. List indexed files, optionally filtered by name pattern and language. / 内容を読む前に path、言語、最近の変更範囲でインデックス済みファイルを探すときに使う。ファイルを選んだ後は `outline` または `excerpt` を優先する。インデックス済みファイルを一覧(名前パターン・言語でフィルタ可能)。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "File path pattern to filter by" }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Additional path filter text. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, + ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to files modified since this ISO 8601 timestamp" }, + ["orderBySize"] = new JsonObject { ["type"] = "boolean", ["description"] = "Sort by indexed byte size descending before path, matching byte-oriented CLI views.", ["default"] = false }, + ["rawBytes"] = new JsonObject { ["type"] = "boolean", ["description"] = "CLI-compatible alias for byte-oriented file listing. MCP returns indexed size metadata, not raw file bytes.", ["default"] = false } + } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "excerpt", + "Use this after `search`, `definition`, `references`, `outline`, or `map` identifies a file and line range. Prefer focused excerpts over whole-file reads; common next step is `outline` for neighboring structure. Reconstruct a file excerpt from indexed chunks for a given line range. Successful responses include `next_step_suggestion`; empty responses include `recovery_hint`. / `search` / `definition` / `references` / `outline` / `map` でファイルと行範囲を絞った後に使う。ファイル全体ではなく必要範囲の抜粋を優先し、次は周辺構造確認の `outline` を使う。指定行範囲について、インデックス済みチャンクからファイル抜粋を再構成。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["path"] = new JsonObject { ["type"] = "string", ["description"] = "Indexed file path" }, + ["startLine"] = new JsonObject { ["type"] = "integer", ["description"] = "Start line (1-based)" }, + ["endLine"] = new JsonObject { ["type"] = "integer", ["description"] = "End line (default: startLine)" }, + ["before"] = new JsonObject { ["type"] = "integer", ["description"] = "Extra context lines before the range (clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, + ["after"] = new JsonObject { ["type"] = "integer", ["description"] = "Extra context lines after the range (clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, + ["focusLine"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional line inside the excerpt to focus when clamping; without focusColumn, the leading window is retained", ["minimum"] = 1 }, + ["focusColumn"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional 1-based column to keep centered when clamping long single-line content; must be within the focused line length", ["minimum"] = 1 }, + ["focusLength"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional focused span width when clamping (default: 1); requires focusColumn", ["default"] = 1, ["minimum"] = 1 }, + ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line excerpt payloads per line (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, + ["maxOutputBytes"] = new JsonObject { ["type"] = "integer", ["description"] = "Cap excerpt content bytes at a line boundary (default: 1048576; maximum: 1048576). Responses set `truncated: true` and `truncation_reason: output_size_cap` when the cap is reached.", ["default"] = MaxLineByteLength, ["minimum"] = 1, ["maximum"] = MaxLineByteLength } + }, + ["required"] = new JsonArray { "path", "startLine" } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "find_in_file", + "Use this when the target file is already known and you need literal or regex navigation inside it. Prefer `excerpt` on returned lines as the next step. Find literal substring matches inside one known indexed file or a small explicit file list, with line numbers and short surrounding context. / 対象ファイルが既に分かっていて、その中を literal または regex で移動したいときに使う。次は返された行の `excerpt` を優先する。既知のインデックス済みファイル1件または少数の明示ファイル群の中で、行番号と短い前後文脈付きの一致を探す。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Literal substring to look for" }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Required file/path scope. Accepts a single string or an array; multiple values are OR'd together." }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max matching occurrences to return (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, + ["before"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines before the match (default: 0, clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, + ["after"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines after the match (default: 0, clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, + ["snippetLines"] = new JsonObject { ["type"] = "integer", ["description"] = "Total snippet lines around each match when before/after are not set (1-20)", ["default"] = 1, ["minimum"] = 1, ["maximum"] = SearchSnippetFormatter.MaxSnippetLines }, + ["focusLine"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional 1-based line that must contain the match", ["minimum"] = 1 }, + ["focusColumn"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional 1-based column that must be inside the match span", ["minimum"] = 1 }, + ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line snippets per line (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Case-sensitive literal substring match. Default is case-insensitive literal substring matching.", ["default"] = false }, + ["regex"] = new JsonObject { ["type"] = "boolean", ["description"] = "Treat query as a .NET regular expression with a 500 ms timeout", ["default"] = false } + }, + ["required"] = new JsonArray { "query", "path" } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "map", + "Use this when orienting in an unfamiliar repo, module, language mix, or hotspot area before searching. Prefer `search`, `symbols`, `outline`, or `excerpt` as the next step after choosing a path. Return a repo-level overview with selectable sections (`tree`, `languages`, `hotspots`, `metrics`) and optional module depth control. / 不慣れなリポジトリ、モジュール、言語構成、hotspot 領域を search 前に把握するときに使う。path を選んだ後は `search` / `symbols` / `outline` / `excerpt` を優先する。セクション選択(`tree`, `languages`, `hotspots`, `metrics`)とモジュール深さ制御に対応したリポジトリ俯瞰情報を返す。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max items per section (default: 10)", ["default"] = QueryCommandRunner.DefaultMapLimit }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude glob-style path patterns. `*` and `?` are wildcards."), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["sections"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "tree", "languages", "hotspots", "metrics" } }, ["description"] = "Only include selected response sections. Omit for the full backward-compatible map." }, + ["depth"] = new JsonObject { ["type"] = "integer", ["description"] = $"Maximum module/tree depth to include; 0 keeps only root-level modules. Requests above {MaxMcpMapDepth} are clamped with an MCP warning.", ["minimum"] = 0, ["maximum"] = MaxMcpMapDepth }, + ["minEntrypointConfidence"] = new JsonObject { ["type"] = "number", ["description"] = "Minimum entrypoint confidence threshold, from 0.0 to 1.0, matching CLI `--min-entrypoint-confidence`.", ["minimum"] = 0, ["maximum"] = 1 } + } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "analyze_symbol", + "Use this when one symbol needs a compact dossier and you would otherwise chain `definition`, `references`, `callers`, and `callees`. Prefer standalone tools when you need deeper pagination; common next step is `excerpt` on the most relevant rows. Bundle definition, nearby symbols, references, callers, callees, file metadata, and graph-support metadata for one symbol query. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Bundled caller/callee rows carry the same `reference_kind` (preferred summary kind, back-compat) plus `reference_kinds` (sorted distinct) and `has_mixed_reference_kinds` fields as the standalone `callers` / `callees` tools, so mixed `call` + `subscribe` containers stay visible in the bundle. Supports `format: count|compact`; CLI `since` filtering is intentionally not exposed because the backing analysis reader does not support it yet. / 1つのシンボルについて compact な dossier が必要で、`definition` / `references` / `callers` / `callees` を連続呼び出ししそうなときに使う。深い pagination が必要なら単独ツールを優先し、次は重要行の `excerpt` を使う。1つのシンボルクエリに対して、定義、近傍シンボル、参照、caller、callee、ファイルメタデータ、グラフ対応メタデータをまとめて返す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。バンドルされた caller / callee 行にも単独の `callers` / `callees` と同じ `reference_kind`(後方互換の優先サマリー種別)、`reference_kinds`(distinct kind の昇順配列)、`has_mixed_reference_kinds` が付くため、`call` + `subscribe` が混在するコンテナも要約 1 ラベルに潰れず見える。`format: count|compact` 対応。CLI の `since` filter は backing analysis reader 未対応のため意図的に未公開。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name to inspect" }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max items per section (default: 10)", ["default"] = QueryCommandRunner.DefaultMapLimit }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["includeBody"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include body content in definitions when available", ["default"] = false }, + ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp bundled reference context lines so single-line files stay bounded (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, + ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact bundle symbol-name equality. Propagates through definitions, references, callers, and callees so `Run` no longer pulls in `RunAsync` / `RunImpact`.", ["default"] = false }, + ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only dossier counts and graph support metadata.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full dossier, count-only metadata, or compact file/line rows.", ["default"] = "full" } + }, + ["required"] = new JsonArray { "query" } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "impact_analysis", + "Use this when planning a symbol change and you need transitive caller impact rather than just direct references. Prefer `definition` first to confirm identity; common next step is `excerpt` on impacted callers or files. Compute the transitive caller chain for a symbol. The symbol-level BFS walks only call-graph kinds (`call`, `instantiate`, `subscribe`) and excludes metadata-only edges (`attribute`, `annotation`, `type_reference`) so metadata cycles do not inflate caller counts. Multiple edge kinds from the same caller to the same target are counted and returned separately, with `reference_kind`, `reference_kinds`, and `reference_kindCounts` on each caller row. When a scoped query resolves to a single class / struct / interface but no symbol-level callers exist, may return heuristic file-level dependency hints instead; those file hints can include metadata edges, so check `impact_mode`, `heuristic`, and `file_impacts`. When `truncated` is true, inspect `truncated_reason` (`user_limit` means raising `limit` returns more; `safety_cap` means the graph is likely pathological and raising `limit` will not help). Pass `withPaths: true` when you need the call chain via specific intermediates — each caller then carries a `paths` array of shortest routes (issue #1536). / シンボル変更を計画していて、直接参照だけでなく推移的 caller 影響が必要なときに使う。identity 確認には先に `definition` を優先し、次は影響 caller/file の `excerpt` を使う。シンボルの推移的呼び出しチェーンを算出。symbol-level BFS は call graph 種別(`call`、`instantiate`、`subscribe`)のみを辿り、metadata-only edge(`attribute`、`annotation`、`type_reference`)を除外するため、metadata cycle で caller 件数が膨らまない。同じ caller から同じ target への複数 edge kind は別々に数えて返し、各 caller 行に `reference_kind`、`reference_kinds`、`reference_kindCounts` が付く。scoped query が単一の class / struct / interface に解決されても symbol-level caller が無い場合は、代わりに heuristic な file-level dependency hint を返すことがある。この file hint は metadata edge を含み得るため、`impact_mode`・`heuristic`・`file_impacts` を確認すること。`truncated` が真のときは `truncated_reason` を見て、`user_limit` なら `limit` を増やせば残りも取得可能、`safety_cap` ならグラフが病的で `limit` を増やしても解消しないことを区別すること。中間シンボル経由の経路が必要な場合は `withPaths: true` を渡すと、各 caller に経路配列 `paths` が付く(issue #1536)。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name to analyze impact for" }, + ["maxHops"] = new JsonObject { ["type"] = "integer", ["description"] = "Max BFS hops, inclusive (default: 5; maxHops: N returns callers at hop 1..N, so a chain A→B→C→D queried against D with maxHops: 2 yields C at hop 1 and B at hop 2; 0 resolves the symbol without traversing callers). Server-side cap: 50; requests above the cap are clamped and a `warnings` entry plus `max_hops_requested` field is added to the response.", ["default"] = 5, ["minimum"] = 0, ["maximum"] = 50 }, + ["maxDepth"] = new JsonObject { ["type"] = "integer", ["description"] = "Deprecated alias for `maxHops`; accepted during the compatibility period and reported in `warnings` when used.", ["minimum"] = 0, ["maximum"] = 50 }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max total callers or heuristic file-level dependency hints to return (default: 50). Check `truncated` when the limit is reached; `truncated_reason` distinguishes `user_limit` (raise `limit` to get more) from `safety_cap` (pathological graph, raising `limit` will not help).", ["default"] = QueryCommandRunner.DefaultImpactLimit }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, + ["withPaths"] = new JsonObject { ["type"] = "boolean", ["description"] = "When true, each caller carries a `paths` array of shortest call chains [resolvedRoot, intermediate..., callerName]; diamond convergence surfaces every shortest route (per-row cap; `pathsTruncated` flag indicates overflow).", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit caller and file-impact row payloads.", ["default"] = false } + }, + ["required"] = new JsonArray { "query" } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "status", + "Get database statistics, readiness state, and optional CLI-style freshness checks. Use `check`, `scopes`, `staleAfterSeconds`, `explain`, `config`, `logPath`, `format`, or `fields` for bounded health-check views. / DB統計、readiness 状態、必要に応じて CLI 風の freshness check を取得。`check` / `scopes` / `staleAfterSeconds` / `explain` / `config` / `logPath` / `format` / `fields` で health-check 用の出力に絞り込める。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["check"] = new JsonObject { ["type"] = "boolean", ["description"] = "Run a workspace freshness check and populate `workspace_check`, `index_matches_workspace`, and `failed_checks`.", ["default"] = false }, + ["scopes"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "workspace", "graph", "issues", "sql", "hotspot", "csharp", "fold", "newer" } } } }, ["description"] = "Readiness scopes to evaluate for `failed_checks`. Omit to evaluate all scopes." }, + ["staleAfterSeconds"] = new JsonObject { ["type"] = "integer", ["description"] = "Effective stale-after threshold, in seconds, echoed with `index_age_seconds` when `check` is true.", ["default"] = 86400, ["minimum"] = 1 }, + ["explain"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "freshness", "readiness", "all" }, ["description"] = "Include a focused `explain` object for freshness/readiness diagnostics. `all` includes both.", ["default"] = "all" }, + ["config"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include effective MCP/CLI status configuration such as DB path, version, log dir, stale threshold, and update-check request state.", ["default"] = false }, + ["logPath"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include the resolved global tool log directory as `log_path`.", ["default"] = false }, + ["updateCheck"] = new JsonObject { ["type"] = "boolean", ["description"] = "Run the same update check as CLI status. Defaults to false because it may perform network I/O.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "compact" }, ["description"] = "Response shape. `compact` returns counts, freshness, readiness, and requested diagnostics without full language/kind tables.", ["default"] = "full" }, + ["fields"] = new JsonObject + { + ["oneOf"] = new JsonArray + { + new JsonObject { ["type"] = "string", ["minLength"] = 1, ["maxLength"] = MaxStatusProjectionFieldCharacters }, + new JsonObject + { + ["type"] = "array", + ["minItems"] = 1, + ["maxItems"] = MaxStatusProjectionFields, + ["items"] = new JsonObject { ["type"] = "string", ["minLength"] = 1, ["maxLength"] = MaxStatusProjectionFieldCharacters } + } + }, + ["description"] = "Return only these exact top-level structured-content fields after applying `format`, plus the standard `api_version`. Accepts one field or an array; nested paths are not supported." + } + } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "outline", + "Use this when a file is known but you need structure before reading content. Prefer it before whole-file reads; common next step is `excerpt` on a specific symbol range. Return the symbol outline of a single indexed file: all functions, classes, imports with line numbers, signatures, and nesting. / ファイルは分かっているが本文を読む前に構造を把握したいときに使う。ファイル全体を読む前に優先し、次は特定シンボル範囲の `excerpt` を使う。1ファイルのシンボルアウトラインを返す: 関数、クラス、importの行番号、シグネチャ、ネスト構造。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["path"] = new JsonObject { ["type"] = "string", ["description"] = "Indexed file path (e.g. src/app.cs)" }, + }, + ["required"] = new JsonArray { "path" } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "deps", + "Show file-level dependency edges, JSON graph payloads, or dependency cycles from the indexed reference graph. / インデックス済み参照グラフからファイル間の依存エッジ、JSON graph ペイロード、依存サイクルを返す。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max edges (default: 50)", ["default"] = QueryCommandRunner.DefaultImpactLimit }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict source files to glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude glob-style path patterns. `*` and `?` are wildcards."), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files", ["default"] = false }, + ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include dependency edges whose source or target file is detected as generated code. Defaults to false, matching other query tools.", ["default"] = false }, + ["reverse"] = new JsonObject { ["type"] = "boolean", ["description"] = "Reverse lookup: show files that depend ON the matched path", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "edgelist", "json-graph" }, ["description"] = "Structured response format. `edgelist` preserves the existing edges array; `json-graph` returns nodes and edges.", ["default"] = "edgelist" }, + ["cycles"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return stable ranked strongly connected components instead of ordinary edge rows. `limit` paginates the completed analysis; inspect `analysis_complete` and continue with opaque `next_cursor` values. / 通常の edge 行ではなく安定順位付きの強連結成分を返す。`limit` は完了した解析結果をページ分割する。`analysis_complete` を確認し、不透明な `next_cursor` で続きを取得する。", ["default"] = false }, + ["graphBudget"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1, ["maximum"] = QueryCommandRunner.MaxDependencyCycleGraphBudget, ["description"] = "Maximum dependency edges analyzed for `cycles`, independent of the display `limit`. / 表示用 `limit` と独立した、`cycles` 解析対象の依存 edge 上限。", ["default"] = QueryCommandRunner.DefaultDependencyCycleGraphBudget }, + ["cursor"] = new JsonObject { ["type"] = "string", ["maxLength"] = 256, ["description"] = "Opaque dependency-cycle `next_cursor`; reuse the same filters and graphBudget. / 同じ filter と graphBudget で再利用する不透明な dependency-cycle `next_cursor`。" } + } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "languages", + "List supported languages with extensions, aliases, capabilities, and unsupported_guidance fallback commands. Use `indexedOnly`, `capability`, `extension`, or `alias` to match CLI language filters and extension lookup. / 対応言語一覧を拡張子・別名・機能・`unsupported_guidance` の代替コマンド付きで返す。`indexedOnly` / `capability` / `extension` / `alias` で CLI の言語フィルタと拡張子 lookup に合わせて絞り込める。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["indexedOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only languages currently present in the index. Requires the configured database.", ["default"] = false }, + ["capability"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "symbols", "graph", "references" } }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "symbols", "graph", "references" } } } }, ["description"] = "Filter by language capability. `graph` and `references` both require call-graph/reference extraction support. Accepts a single value or an array; all requested capabilities must match." }, + ["extension"] = new JsonObject { ["type"] = "string", ["description"] = "Look up languages by file extension. Accepts `cs` or `.cs` style values." }, + ["alias"] = new JsonObject { ["type"] = "string", ["description"] = "Look up languages by canonical language name or CLI language alias, e.g. `cs` for `csharp`." } + } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "validate", + "Report encoding issues found during indexing: U+FFFD replacement chars, BOM markers, null bytes, mixed/CR-only line endings, UTF-16 BOM detection, likely non-UTF8 encodings. replacement_char rows include origin/severity metadata so agents can separate source literals from decoder replacements. / インデックス時に検出したエンコーディング問題を報告。replacement_char 行は source literal と decoder replacement を分ける origin/severity metadata を含む。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by issue kind (replacement_char, bom, null_byte, mixed_line_endings, mixed_line_endings_three_way, cr_only_line_endings, utf16_bom, non_utf8_likely, line_too_long)" }, + ["severity"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "error", "warning", "info" }, ["description"] = "Filter by issue severity." }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max issues to return (default: 20).", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, + ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a top-file histogram; omit issue rows.", ["default"] = false }, + ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full issue rows, count-only metadata, or compact file/line/kind/severity rows.", ["default"] = "full" } + } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "ping", + "Lightweight connection check. Returns server version and timestamp. No database required. / 軽量接続チェック。サーバーバージョンとタイムスタンプを返す。DB不要。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject() + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "batch_query", + "Execute multiple read-only queries in a single call and return all results plus top-level success/failure counts, partial_failure, and failure_scope (none/isolated/cascading). Dramatically reduces round-trips for AI agents. / 複数の読み取り専用クエリを1回の呼び出しで実行し、全結果に加えてトップレベルの成功/失敗件数、partial_failure、failure_scope(none/isolated/cascading)を返す。AIエージェントの往復回数を劇的に削減。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["queries"] = new JsonObject + { + ["type"] = "array", + ["description"] = $"Array of {{tool, arguments}} objects. Only read-only tools are allowed (not index or backfill_fold). Hard cap: {MaxBatchQuerySize} slots.", + ["minItems"] = 1, + ["maxItems"] = MaxBatchQuerySize, + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["id"] = new JsonObject { ["type"] = "string", ["description"] = "Optional client-supplied slot identifier echoed as slot_id." }, + ["slotId"] = new JsonObject { ["type"] = "string", ["description"] = "Optional client-supplied slot identifier echoed as slot_id." }, + ["tool"] = new JsonObject { ["type"] = "string", ["description"] = "Tool name (e.g. search, definition, symbols)" }, + ["arguments"] = new JsonObject { ["type"] = "object", ["description"] = "Tool arguments" } + }, + ["required"] = new JsonArray { "tool" } + } + }, + ["maxResponseBytes"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional per-call response byte budget for this batch_query response. Values above the server cap are clamped and reported in argument_adjustments.", ["minimum"] = 1, ["maximum"] = MaxBatchQueryResponseByteLimit }, + ["estimateOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return budget and slot estimate metadata without executing the slots.", ["default"] = false } + }, + ["required"] = new JsonArray { "queries" } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "index", + "Index or re-index a project directory. Scans source files, extracts symbols, and builds FTS5 search index. On transports that can carry out-of-band server messages (stdio, and HTTP clients connected to `/events`), when the tools/call request includes a bounded scalar/object `_meta.progressToken`, this tool emits `notifications/progress` with that token while scanning, indexing, and finalizing; oversized or unsupported tokens are ignored instead of echoed. / プロジェクトディレクトリをインデックス(再インデックス)。ソースファイルをスキャンし、シンボルを抽出してFTS5検索インデックスを構築。out-of-band のサーバーメッセージを送れる transport(stdio、および `/events` に接続した HTTP クライアント)では、tools/call リクエストに bounded scalar/object の `_meta.progressToken` が含まれる場合、スキャン・インデックス・finalize 中に同じ token の `notifications/progress` を送信し、上限超過または未対応 token は echo せず無視する。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["path"] = new JsonObject { ["type"] = "string", ["description"] = "Project directory path to index" }, + ["rebuild"] = new JsonObject { ["type"] = "boolean", ["description"] = "Delete existing index and rebuild from scratch (default: false)", ["default"] = false }, + ["dryRun"] = new JsonObject { ["type"] = "boolean", ["description"] = "Plan the index run without mutating the database. Reports scan counts, effective options, and unsupported MCP modes.", ["default"] = false }, + ["maxFileBytes"] = new JsonObject { ["type"] = "integer", ["description"] = "Override the per-file indexing size limit for this run. Defaults to CDIDX_MAX_FILE_BYTES or 4MiB.", ["minimum"] = 1, ["maximum"] = int.MaxValue }, + ["maxSymbolsPerFile"] = new JsonObject { ["type"] = "integer", ["description"] = "Skip symbol/reference indexing for files that produce more symbols than this limit, matching CLI --max-symbols-per-file.", ["default"] = IndexCommandRunner.DefaultMaxSymbolsPerFile, ["minimum"] = 1, ["maximum"] = IndexCommandRunner.MaxSymbolsPerFileLimit }, + ["maxReferencesPerFile"] = new JsonObject { ["type"] = "integer", ["description"] = "Skip references for files that produce more references than this limit, matching CLI --max-references-per-file.", ["default"] = IndexCommandRunner.DefaultMaxReferencesPerFile, ["minimum"] = 1, ["maximum"] = IndexCommandRunner.MaxReferencesPerFileLimit }, + ["followSymlinks"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "none", "internal", "all" }, ["description"] = "Directory and file symlink policy matching CLI --follow-symlinks.", ["default"] = "none" }, + ["includeSymbolKind"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Only index symbols with these kinds. Accepts a value, comma-separated string, or array." }, + ["excludeSymbolKind"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Drop symbols with these kinds before indexing. Accepts a value, comma-separated string, or array." }, + ["memoryTrace"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include lightweight MCP memory samples and duration diagnostics in the response.", ["default"] = false }, + ["parallelism"] = new JsonObject { ["type"] = "integer", ["description"] = "CLI compatibility knob. MCP index currently runs serially and reports effective_parallelism=1 instead of silently using this value.", ["minimum"] = 1, ["maximum"] = IndexCommandRunner.MaxIndexParallelism }, + ["commits"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "CLI compatibility scope. Commit-scoped MCP indexing is not supported; non-dry runs reject it explicitly." }, + ["changedBetween"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "CLI compatibility scope. changed-between MCP indexing is not supported; non-dry runs reject it explicitly." }, + ["files"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "CLI compatibility scope. File-scoped MCP indexing is not supported; non-dry runs reject it explicitly." }, + ["watch"] = new JsonObject { ["type"] = "boolean", ["description"] = "CLI compatibility flag. Long-running watch mode is intentionally disabled for MCP; non-dry runs reject it explicitly.", ["default"] = false }, + ["debounce"] = new JsonObject { ["type"] = "integer", ["description"] = "Watch debounce in milliseconds. Reported as unsupported unless watch mode is added to MCP in the future.", ["minimum"] = 0, ["maximum"] = IndexWatchRunner.MaxDebounceMs } + }, + ["required"] = new JsonArray { "path" } + }, + IndexAnnotations()), + CreateToolDefinition( + "backfill_fold", + "Upgrade folded-name keys in an existing CodeIndex DB without reparsing source files. Rejects missing or blank targets instead of creating a fresh DB. Fills missing `name_folded` columns (or rewrites all keys after fold metadata drift such as version/fingerprint mismatch) and stamps FoldReady on success. Use `dry_run:true` to preview affected row counts without writing, or `force:true` to rewrite every folded key even when metadata appears current. On transports that can carry out-of-band server messages (stdio, and HTTP clients connected to `/events`), when the tools/call request includes a bounded scalar/object `_meta.progressToken`, this tool emits `notifications/progress` with that token during backfill and verification; oversized or unsupported tokens are ignored instead of echoed. / ソース再解析なしで既存の CodeIndex DB の folded-name key を更新する。欠落したDBや空のDBを新規作成せず拒否し、欠損 `name_folded` 列を埋めるか、fold metadata の drift(version / fingerprint 不一致など)時は全 key を再生成し、成功時に FoldReady を stamp する。`dry_run:true` で書き込まず対象行数を確認でき、`force:true` で metadata が current に見える場合でも全 folded key を再生成する。out-of-band のサーバーメッセージを送れる transport(stdio、および `/events` に接続した HTTP クライアント)では、tools/call リクエストに bounded scalar/object の `_meta.progressToken` が含まれる場合、backfill と検証中に同じ token の `notifications/progress` を送信し、上限超過または未対応 token は echo せず無視する。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["dry_run"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preview affected folded-key row counts without writing to the database.", ["default"] = false }, + ["force"] = new JsonObject { ["type"] = "boolean", ["description"] = "Rewrite all folded keys even when stored fold metadata matches the current runtime.", ["default"] = false } + } + }, + IndexAnnotations()), + CreateToolDefinition( + "symbol_hotspots", + "Find the most-referenced symbols in the codebase (hotspot analysis). " + + "Returns symbols ordered by reference score, reference count, then deterministic ties by path, line, name, kind, and symbol id. `groupBy` can be `symbol` or `file`; `statement` is accepted only with `lang=sql` to preserve existing SQL behavior. Structured output includes `grouping_unit`, `count_kind`, `limit_applies_to`, `score_fields`, `ranking_fields`, and matching `query_context` fields so callers can tell whether `limit` applies to symbols, files, or SQL statements. Names that are unique within the active language/kind candidate set use codebase-wide totals; duplicate-name families fall back to conservative same-file counts, and same-file duplicate rows may be grouped when the DB cannot disambiguate targets. Cross-file grouping of duplicate families is trusted only on indexes stamped with the current authoritative hotspot-family version. Useful for identifying central, high-impact code. " + + "/ コードベースで最も参照されるシンボルを検索する(ホットスポット分析)。" + + "参照スコア、参照回数の順にシンボルを返し、同点は path、line、name、kind、symbol id で決定的に並べる。`groupBy` は `symbol` / `file` を指定でき、`statement` は既存 SQL 挙動を保つため `lang=sql` の場合のみ受け付ける。structured output には `grouping_unit`、`count_kind`、`limit_applies_to`、`score_fields`、`ranking_fields` と対応する `query_context` fields が含まれ、`limit` が symbols / files / SQL statements のどれに適用されるかを判別できる。active な言語/種別候補集合で一意な名前は codebase 全体の件数を使い、同名ファミリーは保守的な same-file 件数へフォールバックし、DB が対象を曖昧なく結べない同一ファイル重複行は集約される。duplicate family の cross-file 集約は current の authoritative hotspot-family version で stamp された index でのみ信頼する。中心的で影響の大きいコードの特定に有用。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind" }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, + ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, + ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, + ["groupBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("symbol", "file", "statement"), ["description"] = "Grouping unit. Use symbol or file for non-SQL scopes; statement is accepted only when lang is sql." }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict to glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude glob-style path patterns. `*` and `?` are wildcards."), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files (default: false)", ["default"] = false } + } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "unused_symbols", + "Use this when auditing potential dead code before removal. Prefer `references`, `callers`, or `excerpt` to verify surprising hits before editing. Find symbols that are defined but never referenced in the indexed codebase. " + + "Results include confidence buckets so private hits rank ahead of public/exported suspects; the lowest-confidence bucket also covers reflection, serialization contracts, config, metadata, generated surfaces, documentation headings, and test-only hooks. Only meaningful for languages with reference extraction support. " + + "Structured output includes `summary.by_bucket`, `summary.by_confidence`, `summary.by_contract_domain`, `bucket_taxonomy`, and per-symbol `unusedContractDomain`; bucket values are `likely_unused_private`, `maybe_unused_nonpublic`, `public_or_exported_no_refs`, and `reflection_or_config_suspect`. Use `bucket` or `minConfidence` to audit a single bucket or confidence class. " + + "C# nameof/typeof and direct reflection member-name literals such as GetMethod(\"Foo\") are indexed as references; dynamically constructed reflection names can still require manual review. " + + "/ 削除前に dead code 候補を監査するときに使う。意外なヒットは編集前に `references` / `callers` / `excerpt` で確認する。インデックス済みコードベースで定義されているが一度も参照されていないシンボルを検索する。" + + "private 候補を public/exported suspect より前に返し、最低信頼 bucket は reflection、serialization contract、config、metadata、generated surface、documentation heading、test-only hook も扱う。参照抽出対応言語でのみ意味がある。" + + "構造化出力には `summary.by_bucket`、`summary.by_confidence`、`summary.by_contract_domain`、`bucket_taxonomy`、シンボル単位の `unusedContractDomain` が含まれ、bucket 値は `likely_unused_private`、`maybe_unused_nonpublic`、`public_or_exported_no_refs`、`reflection_or_config_suspect`。`bucket` または `minConfidence` で単一 bucket や confidence class を監査できる。" + + "C# の nameof/typeof と GetMethod(\"Foo\") のような直接の reflection member-name literal は参照として index されるが、動的に組み立てた reflection 名は手動確認が必要な場合がある。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind (function, class, property, interface, enum, struct, event, delegate)" }, + ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language (recommended: use a graph-supported language)" }, + ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 50)", ["default"] = QueryCommandRunner.DefaultImpactLimit }, + ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, + ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, + ["byBucket"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include `symbols_by_bucket` grouped by unused-symbol bucket.", ["default"] = false }, + ["bucket"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("likely_unused_private", "maybe_unused_nonpublic", "public_or_exported_no_refs", "reflection_or_config_suspect"), ["description"] = "Return only one unused-symbol bucket." }, + ["minConfidence"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("medium", "low"), ["description"] = "Return symbols at or above this confidence threshold." }, + ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, + ["excludePaths"] = StringOrArraySchema("Exclude paths containing any of these texts"), + ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files (default: false)", ["default"] = false } + } + }, + ReadOnlyAnnotations()), + CreateToolDefinition( + "suggest_improvement", + "Submit a structured improvement suggestion or error report for cdidx. " + + "Call this when you notice a gap (e.g. missing language support, poor ranking) or encounter an unexpected error. " + + "Never include source code — describe the gap in natural language only. " + + "The tool writes to the resolved .cdidx directory, which must be writable; responses include cdidx_dir for diagnostics. " + + "Responses also include github_submission_reason: submitted, token_not_configured, repo_not_configured, network_error, or api_error. " + + "/ cdidxへの構造化された改善提案またはエラー報告を送信する。" + + "ギャップ(言語サポート不足、ランキング不良等)に気づいたとき、または予期せぬエラーに遭遇したときに呼び出す。" + + "ソースコードを含めないこと — 自然言語でのみギャップを記述する。" + + "解決された .cdidx ディレクトリへ書き込むため、そのディレクトリは書き込み可能である必要がある。応答には診断用の cdidx_dir と github_submission_reason が含まれる。", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["category"] = new JsonObject + { + ["type"] = "string", + ["description"] = "Suggestion category: symbol_extraction, reference_extraction, search_ranking, language_support, output_format, crash_report, unexpected_error, security, performance, bug, cleanup, documentation, feature_request, or other", + ["enum"] = new JsonArray(SuggestionRecord.ValidCategories.Select(category => (JsonNode?)category).ToArray()) + }, + ["language"] = new JsonObject { ["type"] = "string", ["description"] = "Programming language this applies to (optional)" }, + ["description"] = new JsonObject { ["type"] = "string", ["description"] = "What gap or improvement you observed, or what error occurred (NOT source code)" }, + ["context"] = new JsonObject { ["type"] = "string", ["description"] = "What you were trying to do when you noticed the gap (NOT source code)" }, + ["toolInvocationContext"] = new JsonObject { ["type"] = "string", ["description"] = "Natural-language context for the current tool invocation or workflow (optional, NOT source code)" }, + ["evidencePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Repository-relative paths that support the suggestion (optional, no source code)" } + }, + ["required"] = new JsonArray { "category", "description" } + }, + SuggestionAnnotations()) + }; + + AddProjectScopeProperties(tools); + AddCommonSchemaConstraints(tools); + return tools; + } +} diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 7c5777d92..0b9fbb342 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -17,605 +17,12 @@ public partial class McpServer /// Return the list of available tools. /// 利用可能なツール一覧を返す。 /// - private JsonNode HandleToolsList(JsonNode? id, JsonNode? listParams) - { - var tools = new JsonArray - { - CreateToolDefinition( - "search", - "Use this when starting broad code discovery, checking error text, or running named search audit recipes. Prefer it before shell grep; common next step is `excerpt`, `definition`, or `references` on the best hit. Returns snippets plus `result_stable_at`, `next_cursor`, and `next_step_suggestion` or `recovery_hint`. Use `prefix`/trailing `*` to widen token matching, `rawQuery` for FTS5 syntax, `exactSubstring` for case-sensitive identity, and `tokenBoundary` when a code phrase must not match inside longer identifiers. Details and examples: USER_GUIDE.md#search. / 広いコード調査、エラー文言確認、search audit recipe 実行の起点に使う。shell grep より優先し、次は最有力ヒットに `excerpt` / `definition` / `references` を使う。`prefix` / 末尾 `*` / `rawQuery` / `exactSubstring` / `tokenBoundary` の詳細と例は USER_GUIDE.md#search を参照。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Search query text. Append `*` to a token to make that token a prefix phrase (`計算*` matches `計算する`)." }, - ["recipe"] = new JsonObject { ["type"] = "string", ["description"] = "Run a named search audit recipe instead of a single query. Use `listRecipes:true` to discover available recipe names." }, - ["listRecipes"] = new JsonObject { ["type"] = "boolean", ["description"] = "List built-in and configured search audit recipes without running a search.", ["default"] = false }, - ["auditScope"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "source", "all" }, ["description"] = "Recipe runs only: source applies the recipe's production-code default path/exclusion scope; all searches every indexed path unless other filters exclude it." }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated` and `more_available` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language (e.g. csharp, python, javascript)" }, - ["snippetLines"] = new JsonObject { ["type"] = "integer", ["description"] = "Max snippet lines per result (default: 8, max: 20)", ["default"] = 8, ["minimum"] = 1, ["maximum"] = SearchSnippetFormatter.MaxSnippetLines }, - ["snippetFocus"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "quality", "leftmost", "proximity" }, ["description"] = "Snippet anchoring mode matching CLI `--snippet-focus`: quality (default), leftmost, or proximity.", ["default"] = "quality" }, - ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line snippets per line (default: 512; 0 disables clamping). Match lines are clamped around the first match; non-match lines are clamped from the head. Each clamp inserts a `...(+N)...` marker showing how many chars were elided.", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, - ["rawQuery"] = new JsonObject { ["type"] = "boolean", ["description"] = "Use raw FTS5 syntax instead of literal-safe quoting: content:term, NEAR(a b, 5), OR, NOT, parenthesized groups, prefix*, and quoted phrases.", ["default"] = false }, - ["cursor"] = new JsonObject { ["type"] = "string", ["description"] = "Optional pagination cursor returned as `next_cursor` by a previous search response with the same query and filters. Compare `result_stable_at` across pages to detect index drift." }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude glob-style path patterns. `*` and `?` are wildcards."), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, - ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to files modified since this ISO 8601 timestamp" }, - ["noDedup"] = new JsonObject { ["type"] = "boolean", ["description"] = "Disable overlapping-chunk deduplication and return every raw chunk hit; useful for debugging chunk boundaries or measuring raw match density.", ["default"] = false }, - ["exactSubstring"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for search's exact mode: case-sensitive exact substring match (bypasses FTS5).", ["default"] = false }, - ["tokenBoundary"] = new JsonObject { ["type"] = "boolean", ["description"] = "Case-sensitive exact code-phrase match that also requires identifier/token boundaries around the full query, so `new HttpClient` does not match `new HttpClientHandler`.", ["default"] = false }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactSubstring`.", ["default"] = false }, - ["prefix"] = new JsonObject { ["type"] = "boolean", ["description"] = "Opt into FTS5 prefix expansion for every token in `query`. Cannot be combined with `exact`/`exactSubstring`/`tokenBoundary`.", ["default"] = false }, - ["requireBefore"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Keep search matches only when this guard query appears within `guardWindow` lines before the primary match. Accepts a string or string array." }, - ["requireAfter"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Keep search matches only when this guard query appears within `guardWindow` lines after the primary match. Accepts a string or string array." }, - ["rejectBefore"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Drop search matches when this guard query appears within `guardWindow` lines before the primary match. Accepts a string or string array." }, - ["rejectAfter"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Drop search matches when this guard query appears within `guardWindow` lines after the primary match. Accepts a string or string array." }, - ["guardWindow"] = new JsonObject { ["type"] = "integer", ["description"] = $"Line window for guard queries (default: {DbReader.DefaultSearchGuardWindow}, max: {DbReader.MaxSearchGuardWindow}).", ["default"] = DbReader.DefaultSearchGuardWindow, ["minimum"] = 0, ["maximum"] = DbReader.MaxSearchGuardWindow }, - ["guardScope"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "window", "same-line" }, ["description"] = "Evaluate guard queries in the line window or only on the same line before/after the primary match.", ["default"] = "window" }, - ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false }, - ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full rows, count-only metadata, or compact file/line rows without snippets.", ["default"] = "full" } - }, - ["anyOf"] = new JsonArray - { - new JsonObject { ["required"] = new JsonArray { "query" } }, - new JsonObject { ["required"] = new JsonArray { "recipe" } }, - new JsonObject { ["required"] = new JsonArray { "listRecipes" } } - } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "definition", - "Use this when you know or suspect a symbol name and need its declaration before editing. Prefer `exactName:true` for identity checks; common next step is `references` or `excerpt`. Resolve symbol definitions with ranges, signatures, and optional body content. Pass `lsp_compatible:true` to add `uri` and LSP `range` fields to each result. `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Examples: `definition {\"query\":\"McpServer\"}`; `definition {\"query\":\"HandleMessage\",\"lang\":\"csharp\",\"includeBody\":true,\"exactName\":true}`. / シンボル名が分かる、または推測できるときに編集前の宣言確認に使う。identity 確認では `exactName:true` を優先し、次は `references` または `excerpt` を使う。定義範囲、シグネチャ、必要に応じて本体内容付きでシンボル定義を解決。例: `definition {\"query\":\"McpServer\"}`; `definition {\"query\":\"HandleMessage\",\"lang\":\"csharp\",\"includeBody\":true,\"exactName\":true}`。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name pattern to resolve" }, - ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind" }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, - ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, - ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, - ["includeBody"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include body content when body ranges are available", ["default"] = false }, - ["lsp_compatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Add file:// uri and LSP range fields to each result", ["default"] = false }, - ["lspCompatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Alias for `lsp_compatible` for JSON-style clients.", ["default"] = false }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, - ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to symbols in files modified since this ISO 8601 timestamp" }, - ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact symbol-name equality: NFKC + Unicode CaseFold exact name match instead of substring, so `Run` no longer also returns `RunAsync`.", ["default"] = false }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, - ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full rows, count-only metadata, or compact file/line rows without excerpts.", ["default"] = "full" } - }, - ["required"] = new JsonArray { "query" } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "references", - "Use this when you need usage sites, examples, tests, metadata references, or type-position references for a symbol. Prefer it after `definition`; common next step is `excerpt` on representative rows or `callers`/`callees` for runtime impact. Search indexed symbol references such as call sites. Non-empty responses include `next_step_suggestion`; empty responses include `recovery_hint`. Pass `lsp_compatible:true` to add `uri` and LSP `range` fields to each result. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, all indexed reference kinds including metadata uses (`attribute` / `annotation`), JavaScript/TypeScript discriminant tags (`type_tag`), C# BCL Regex timeout audit rows (`bcl_regex_without_timeout`), and compile-time type-position references (`type_reference`) stay visible, and identical constructor `call` + `instantiate` rows at one physical site are collapsed. Pass `kind: \"type_tag\"` to enumerate discriminant comparisons such as `shape.type === \"circle\"`. Pass `kind: \"type_reference\"` to enumerate declaration types, generic constraints, `is`/`as`/`instanceof`, and XML-doc `cref` targets. Pass `kind: \"bcl_regex_without_timeout\"` with query `Regex` to audit direct System.Text.RegularExpressions.Regex construction without a timeout argument. Examples: `references {\"query\":\"Run\"}`; `references {\"query\":\"Service\",\"kind\":\"type_reference\",\"lang\":\"csharp\"}`. / シンボルの利用箇所、例、テスト、metadata 参照、型位置参照を調べるときに使う。`definition` の後に優先し、次は代表行の `excerpt` または実行時影響の `callers` / `callees` を使う。`kind: \"type_tag\"` で JavaScript / TypeScript の discriminant 比較を列挙できる。`kind: \"bcl_regex_without_timeout\"` と query `Regex` で timeout 引数なしの直接 `System.Text.RegularExpressions.Regex` 生成を監査できる。例: `references {\"query\":\"Run\"}`; `references {\"query\":\"Service\",\"kind\":\"type_reference\",\"lang\":\"csharp\"}`。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Referenced symbol name pattern to search for" }, - ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by reference kind (call, instantiate, subscribe, friend, attribute, annotation, type_reference, type_tag, bcl_regex_without_timeout)" }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, - ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, - ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line context payloads per result (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, - ["lsp_compatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Add file:// uri and LSP range fields to each result", ["default"] = false }, - ["lspCompatible"] = new JsonObject { ["type"] = "boolean", ["description"] = "Alias for `lsp_compatible` for JSON-style clients.", ["default"] = false }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, - ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact referenced-symbol equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, - ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false }, - ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full rows, count-only metadata, or compact file/line/column rows without context.", ["default"] = "full" } - }, - ["required"] = new JsonArray { "query" } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "callers", - "Use this when you need to know what calls or depends on a callee symbol before changing it. Prefer it after `definition`/`references`; common next step is `excerpt` on high-ranked caller rows. Find caller symbols that reference a callee. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, only executable kinds (`call`, `instantiate`, `subscribe`) are returned; pass `kind: \"friend\"` explicitly for C++ friend access/coupling edges, while metadata uses (`attribute` / `annotation`) and compile-time type-position references (`type_reference`) do not pollute caller edges; identical constructor `call` + `instantiate` rows at one physical site also collapse. Public `reference_kind`, `reference_kinds`, and `reference_kind_counts` use the same canonical vocabulary. Each grouped row additionally exposes `reference_kinds` (sorted distinct kinds behind the row) and `has_mixed_reference_kinds` so callers do not have to trust the single summary label when a container mixes `call` + `subscribe` edges. The existing `reference_kind` scalar is retained for back-compat and carries the canonical summary priority (`instantiate` > `subscribe` > `call`); `rawKinds` preserves raw-kind priority. `callers` / `callees` are not a reliable path to metadata or type-position references — metadata rows are attributed to their enclosing body-range symbol (for a class-level declaration, that is the class itself; for a file-level target such as `[assembly: ...]`, `containerName` is `null` and the row drops from these graph queries entirely), and `type_reference` rows are compile-time type mentions (declaration types, generic constraints, `is`/`as`/`instanceof`, XML-doc `cref`) rather than runtime calls. Use `references` with `kind: \"attribute\"`, `\"annotation\"`, or `\"type_reference\"` instead. Examples: `callers {\"query\":\"HandleRequest\"}`; `callers {\"query\":\"ExecuteAsync\",\"kind\":\"call\",\"rankBy\":\"weighted\",\"lang\":\"csharp\"}`. / callee シンボルの変更前に呼び出し元や依存元を知りたいときに使う。`definition` / `references` の後に優先し、次は上位 caller 行の `excerpt` を使う。指定シンボルを参照している呼び出し元シンボルを探す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。`kind` 未指定時は実行可能な種別 (`call` / `instantiate` / `subscribe`) だけを返す。C++ friend の access/coupling edge は `kind: \"friend\"` を明示する。metadata 使用 (`attribute` / `annotation`) と compile-time な型位置参照 (`type_reference`) が phantom caller edge として混入しないようにする。同じ物理位置にある constructor の `call` + `instantiate` 重複行も集約する。公開 `reference_kind`、`reference_kinds`、`reference_kind_counts` は同じ canonical 語彙を使う。各グループ行には `reference_kinds`(行内の distinct kind をソートした配列)と `has_mixed_reference_kinds` も追加で返すため、container が `call` + `subscribe` を混在させている行で要約 1 ラベルに騙されずに済む。既存のスカラー `reference_kind` は後方互換のため維持され、canonical な優先サマリー種別(`instantiate` > `subscribe` > `call`)を持つ。`rawKinds` 指定時は raw kind の優先順を持つ。metadata 行の container は注釈対象そのものではなく body-range 上の外側シンボル(クラス直下宣言ならクラス、ファイルレベル target なら `null`)になり、`type_reference` は実行時呼び出しではなく宣言型・generic 制約・`is`/`as`/`instanceof`・XML-doc `cref` といった compile-time な型言及なので、`callers` / `callees` は metadata / 型位置参照の列挙に向かない。Metadata / 型位置参照の列挙は `references --kind attribute|annotation|type_reference` / MCP `references` を使う。例: `callers {\"query\":\"HandleRequest\"}`; `callers {\"query\":\"ExecuteAsync\",\"kind\":\"call\",\"rankBy\":\"weighted\",\"lang\":\"csharp\"}`。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Callee symbol name pattern to search for" }, - ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by edge kind. Default results use the canonical call, instantiate, subscribe vocabulary; non-default `friend` remains available explicitly. Metadata and type-only kinds — metadata (attribute, annotation), type-position (type_reference), and JS/TS discriminant narrowing (type_tag) — are rejected here; use `references` with the desired kind instead." }, - ["rawKinds"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preserve raw reference kinds instead of canonical CLI grouping, matching `--raw-kinds`.", ["default"] = false }, - ["rankBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "weighted", "count", "kind" }, ["description"] = "Ranking model: weighted (default; instantiate=3.0, call=1.0, subscribe=0.1), count, or kind.", ["default"] = "weighted" }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, - ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, - ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact callee-name equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, - ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false }, - ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full rows, count-only metadata, or compact file/line rows without excerpts.", ["default"] = "full" } - }, - ["required"] = new JsonArray { "query" } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "callees", - "Use this when you need to know what a caller/container symbol invokes or depends on. Prefer it after `definition` or `outline`; common next step is `excerpt` on a callee row. Find callees used by a caller/container symbol. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, only executable kinds (`call`, `instantiate`, `subscribe`) are returned; pass `kind: \"friend\"` explicitly for C++ friend access/coupling edges, while metadata uses (`attribute` / `annotation`) and compile-time type-position references (`type_reference`) do not pollute callee edges; identical constructor `call` + `instantiate` rows at one physical site also collapse. Public `reference_kind`, `reference_kinds`, and `reference_kind_counts` use the same canonical vocabulary. Each grouped row additionally exposes `reference_kinds` (sorted distinct kinds behind the row) and `has_mixed_reference_kinds` for symmetry with `callers`, even though rows are already split per kind on this side. The existing `reference_kind` scalar is retained for back-compat and carries the same kind value. `callees` is not a reliable path to metadata or type-position references — the container assigned to an attribute / annotation row is the enclosing body-range symbol, not the annotated declaration, so `callees Method1 --kind attribute` does not return the attributes on `Method1`, and `type_reference` rows are compile-time type mentions (declaration types, generic constraints, `is`/`as`/`instanceof`, XML-doc `cref`) rather than runtime calls. Use `references` with `kind: \"attribute\"`, `\"annotation\"`, or `\"type_reference\"` instead. Examples: `callees {\"query\":\"Run\"}`; `callees {\"query\":\"Program.Main\",\"kind\":\"instantiate\",\"lang\":\"csharp\",\"limit\":10}`. / caller/container シンボルが呼ぶ先や依存先を知りたいときに使う。`definition` または `outline` の後に優先し、次は callee 行の `excerpt` を使う。呼び出し元シンボルが使っている呼び出し先を探す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。`kind` 未指定時は実行可能な種別 (`call` / `instantiate` / `subscribe`) だけを返す。C++ friend の access/coupling edge は `kind: \"friend\"` を明示する。metadata 使用 (`attribute` / `annotation`) と compile-time な型位置参照 (`type_reference`) が phantom callee edge として混入しないようにする。同じ物理位置にある constructor の `call` + `instantiate` 重複行も集約する。公開 `reference_kind`、`reference_kinds`、`reference_kind_counts` は同じ canonical 語彙を使う。各グループ行には `callers` との対称性のため `reference_kinds`(行内の distinct kind をソートした配列)と `has_mixed_reference_kinds` も返る(`callees` 側は元々 kind ごとに行を分けているため通常は単一要素)。既存のスカラー `reference_kind` は後方互換のため維持され、同じ kind 値を持つ。metadata 行の container は注釈対象自身ではなく body-range 上の外側シンボルになるため、`callees` で `Method1 --kind attribute` を引いても `Method1` に付いた属性は返らない。`type_reference` は実行時呼び出しではなく宣言型・generic 制約・`is`/`as`/`instanceof`・XML-doc `cref` といった compile-time な型言及なので、`callees` は metadata / 型位置参照の列挙に向かない。Metadata / 型位置参照の列挙は `references --kind attribute|annotation|type_reference` / MCP `references` を使う。例: `callees {\"query\":\"Run\"}`; `callees {\"query\":\"Program.Main\",\"kind\":\"instantiate\",\"lang\":\"csharp\",\"limit\":10}`。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Caller/container symbol name pattern to search for" }, - ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by edge kind. Default results use the canonical call, instantiate, subscribe vocabulary; non-default graph kinds remain available explicitly. Metadata and type-only kinds — metadata (attribute, annotation), type-position (type_reference), and JS/TS discriminant narrowing (type_tag) — are rejected here; use `references` with the desired kind instead." }, - ["rawKinds"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preserve raw reference kinds instead of canonical CLI grouping, matching `--raw-kinds`.", ["default"] = false }, - ["rankBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "weighted", "count", "kind" }, ["description"] = "Ranking model: weighted (default; instantiate=3.0, call=1.0, subscribe=0.1), count, or kind.", ["default"] = "weighted" }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, - ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, - ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact caller/container equality. Uses NFKC + Unicode CaseFold so `Run` no longer matches `RunAsync`.", ["default"] = false }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, - ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false }, - ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full rows, count-only metadata, or compact file/line rows without excerpts.", ["default"] = "full" } - }, - ["required"] = new JsonArray { "query" } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "symbols", - "Use this when discovering candidate symbols before `definition`, `references`, `callers`, or `callees`. Prefer `exactName:true` when the name must match exactly. Search for code symbols (functions, classes, interfaces, imports) by name pattern. `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Examples: `symbols {\"query\":\"Service\"}`; `symbols {\"query\":\"Run\",\"kind\":\"function\",\"lang\":\"csharp\",\"exactName\":true}`. / `definition` / `references` / `callers` / `callees` の前に候補シンボルを探すときに使う。名前を厳密一致させるなら `exactName:true` を優先する。シンボル(関数、クラス、インターフェース、import)を名前パターンで検索。例: `symbols {\"query\":\"Service\"}`; `symbols {\"query\":\"Run\",\"kind\":\"function\",\"lang\":\"csharp\",\"exactName\":true}`。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name pattern to search for. Treated as a literal substring (no `|`-OR sugar), so operator symbols such as `operator |` remain searchable." }, - ["names"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Optional list of additional symbol name patterns, OR-joined with `query`. Use this to resolve multiple candidate names in one call." }, - ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind (function, class, interface, import, etc.)" }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, - ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict matches to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, - ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to symbols in files modified since this ISO 8601 timestamp" }, - ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact symbol-name equality instead of substring, so `Run` no longer matches `RunAsync`/`RunImpact`.", ["default"] = false }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, - ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return count metadata and a top-file histogram without symbol rows.", ["default"] = false }, - ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full symbol rows, count metadata, or compact file/line/kind/name rows.", ["default"] = "full" } - } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "files", - "Use this when you need to locate indexed files by path, language, or recent-change scope before reading content. Prefer `outline` or `excerpt` as the next step after choosing a file. List indexed files, optionally filtered by name pattern and language. / 内容を読む前に path、言語、最近の変更範囲でインデックス済みファイルを探すときに使う。ファイルを選んだ後は `outline` または `excerpt` を優先する。インデックス済みファイルを一覧(名前パターン・言語でフィルタ可能)。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["query"] = new JsonObject { ["type"] = "string", ["description"] = "File path pattern to filter by" }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Additional path filter text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, - ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["since"] = new JsonObject { ["type"] = "string", ["description"] = "Filter to files modified since this ISO 8601 timestamp" }, - ["orderBySize"] = new JsonObject { ["type"] = "boolean", ["description"] = "Sort by indexed byte size descending before path, matching byte-oriented CLI views.", ["default"] = false }, - ["rawBytes"] = new JsonObject { ["type"] = "boolean", ["description"] = "CLI-compatible alias for byte-oriented file listing. MCP returns indexed size metadata, not raw file bytes.", ["default"] = false } - } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "excerpt", - "Use this after `search`, `definition`, `references`, `outline`, or `map` identifies a file and line range. Prefer focused excerpts over whole-file reads; common next step is `outline` for neighboring structure. Reconstruct a file excerpt from indexed chunks for a given line range. Successful responses include `next_step_suggestion`; empty responses include `recovery_hint`. / `search` / `definition` / `references` / `outline` / `map` でファイルと行範囲を絞った後に使う。ファイル全体ではなく必要範囲の抜粋を優先し、次は周辺構造確認の `outline` を使う。指定行範囲について、インデックス済みチャンクからファイル抜粋を再構成。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["path"] = new JsonObject { ["type"] = "string", ["description"] = "Indexed file path" }, - ["startLine"] = new JsonObject { ["type"] = "integer", ["description"] = "Start line (1-based)" }, - ["endLine"] = new JsonObject { ["type"] = "integer", ["description"] = "End line (default: startLine)" }, - ["before"] = new JsonObject { ["type"] = "integer", ["description"] = "Extra context lines before the range (clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, - ["after"] = new JsonObject { ["type"] = "integer", ["description"] = "Extra context lines after the range (clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, - ["focusLine"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional line inside the excerpt to focus when clamping; without focusColumn, the leading window is retained", ["minimum"] = 1 }, - ["focusColumn"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional 1-based column to keep centered when clamping long single-line content; must be within the focused line length", ["minimum"] = 1 }, - ["focusLength"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional focused span width when clamping (default: 1); requires focusColumn", ["default"] = 1, ["minimum"] = 1 }, - ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line excerpt payloads per line (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, - ["maxOutputBytes"] = new JsonObject { ["type"] = "integer", ["description"] = "Cap excerpt content bytes at a line boundary (default: 1048576; maximum: 1048576). Responses set `truncated: true` and `truncation_reason: output_size_cap` when the cap is reached.", ["default"] = MaxLineByteLength, ["minimum"] = 1, ["maximum"] = MaxLineByteLength } - }, - ["required"] = new JsonArray { "path", "startLine" } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "find_in_file", - "Use this when the target file is already known and you need literal or regex navigation inside it. Prefer `excerpt` on returned lines as the next step. Find literal substring matches inside one known indexed file or a small explicit file list, with line numbers and short surrounding context. / 対象ファイルが既に分かっていて、その中を literal または regex で移動したいときに使う。次は返された行の `excerpt` を優先する。既知のインデックス済みファイル1件または少数の明示ファイル群の中で、行番号と短い前後文脈付きの一致を探す。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Literal substring to look for" }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Required file/path scope. Accepts a single string or an array; multiple values are OR'd together." }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max matching occurrences to return (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, - ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["before"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines before the match (default: 0, clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, - ["after"] = new JsonObject { ["type"] = "integer", ["description"] = "Context lines after the match (default: 0, clamped to 1000)", ["default"] = 0, ["minimum"] = 0 }, - ["snippetLines"] = new JsonObject { ["type"] = "integer", ["description"] = "Total snippet lines around each match when before/after are not set (1-20)", ["default"] = 1, ["minimum"] = 1, ["maximum"] = SearchSnippetFormatter.MaxSnippetLines }, - ["focusLine"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional 1-based line that must contain the match", ["minimum"] = 1 }, - ["focusColumn"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional 1-based column that must be inside the match span", ["minimum"] = 1 }, - ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp very long single-line snippets per line (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Case-sensitive literal substring match. Default is case-insensitive literal substring matching.", ["default"] = false }, - ["regex"] = new JsonObject { ["type"] = "boolean", ["description"] = "Treat query as a .NET regular expression with a 500 ms timeout", ["default"] = false } - }, - ["required"] = new JsonArray { "query", "path" } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "map", - "Use this when orienting in an unfamiliar repo, module, language mix, or hotspot area before searching. Prefer `search`, `symbols`, `outline`, or `excerpt` as the next step after choosing a path. Return a repo-level overview with selectable sections (`tree`, `languages`, `hotspots`, `metrics`) and optional module depth control. / 不慣れなリポジトリ、モジュール、言語構成、hotspot 領域を search 前に把握するときに使う。path を選んだ後は `search` / `symbols` / `outline` / `excerpt` を優先する。セクション選択(`tree`, `languages`, `hotspots`, `metrics`)とモジュール深さ制御に対応したリポジトリ俯瞰情報を返す。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max items per section (default: 10)", ["default"] = QueryCommandRunner.DefaultMapLimit }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude glob-style path patterns. `*` and `?` are wildcards."), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, - ["sections"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "tree", "languages", "hotspots", "metrics" } }, ["description"] = "Only include selected response sections. Omit for the full backward-compatible map." }, - ["depth"] = new JsonObject { ["type"] = "integer", ["description"] = $"Maximum module/tree depth to include; 0 keeps only root-level modules. Requests above {MaxMcpMapDepth} are clamped with an MCP warning.", ["minimum"] = 0, ["maximum"] = MaxMcpMapDepth }, - ["minEntrypointConfidence"] = new JsonObject { ["type"] = "number", ["description"] = "Minimum entrypoint confidence threshold, from 0.0 to 1.0, matching CLI `--min-entrypoint-confidence`.", ["minimum"] = 0, ["maximum"] = 1 } - } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "analyze_symbol", - "Use this when one symbol needs a compact dossier and you would otherwise chain `definition`, `references`, `callers`, and `callees`. Prefer standalone tools when you need deeper pagination; common next step is `excerpt` on the most relevant rows. Bundle definition, nearby symbols, references, callers, callees, file metadata, and graph-support metadata for one symbol query. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. Bundled caller/callee rows carry the same `reference_kind` (preferred summary kind, back-compat) plus `reference_kinds` (sorted distinct) and `has_mixed_reference_kinds` fields as the standalone `callers` / `callees` tools, so mixed `call` + `subscribe` containers stay visible in the bundle. Supports `format: count|compact`; CLI `since` filtering is intentionally not exposed because the backing analysis reader does not support it yet. / 1つのシンボルについて compact な dossier が必要で、`definition` / `references` / `callers` / `callees` を連続呼び出ししそうなときに使う。深い pagination が必要なら単独ツールを優先し、次は重要行の `excerpt` を使う。1つのシンボルクエリに対して、定義、近傍シンボル、参照、caller、callee、ファイルメタデータ、グラフ対応メタデータをまとめて返す。完全一致には `exactName` を使う。`exact` は USER_GUIDE.md の flag compatibility table に記載された legacy alias。バンドルされた caller / callee 行にも単独の `callers` / `callees` と同じ `reference_kind`(後方互換の優先サマリー種別)、`reference_kinds`(distinct kind の昇順配列)、`has_mixed_reference_kinds` が付くため、`call` + `subscribe` が混在するコンテナも要約 1 ラベルに潰れず見える。`format: count|compact` 対応。CLI の `since` filter は backing analysis reader 未対応のため意図的に未公開。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name to inspect" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max items per section (default: 10)", ["default"] = QueryCommandRunner.DefaultMapLimit }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["includeBody"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include body content in definitions when available", ["default"] = false }, - ["maxLineWidth"] = new JsonObject { ["type"] = "integer", ["description"] = "Clamp bundled reference context lines so single-line files stay bounded (default: 512; 0 disables clamping)", ["default"] = LineWidthFormatter.DefaultMaxLineWidth, ["minimum"] = 0, ["maximum"] = LineWidthFormatter.MaxAllowedLineWidth }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, - ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["exactName"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for exact bundle symbol-name equality. Propagates through definitions, references, callers, and callees so `Run` no longer pulls in `RunAsync` / `RunImpact`.", ["default"] = false }, - ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactName`.", ["default"] = false }, - ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only dossier counts and graph support metadata.", ["default"] = false }, - ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full dossier, count-only metadata, or compact file/line rows.", ["default"] = "full" } - }, - ["required"] = new JsonArray { "query" } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "impact_analysis", - "Use this when planning a symbol change and you need transitive caller impact rather than just direct references. Prefer `definition` first to confirm identity; common next step is `excerpt` on impacted callers or files. Compute the transitive caller chain for a symbol. The symbol-level BFS walks only call-graph kinds (`call`, `instantiate`, `subscribe`) and excludes metadata-only edges (`attribute`, `annotation`, `type_reference`) so metadata cycles do not inflate caller counts. Multiple edge kinds from the same caller to the same target are counted and returned separately, with `reference_kind`, `reference_kinds`, and `reference_kindCounts` on each caller row. When a scoped query resolves to a single class / struct / interface but no symbol-level callers exist, may return heuristic file-level dependency hints instead; those file hints can include metadata edges, so check `impact_mode`, `heuristic`, and `file_impacts`. When `truncated` is true, inspect `truncated_reason` (`user_limit` means raising `limit` returns more; `safety_cap` means the graph is likely pathological and raising `limit` will not help). Pass `withPaths: true` when you need the call chain via specific intermediates — each caller then carries a `paths` array of shortest routes (issue #1536). / シンボル変更を計画していて、直接参照だけでなく推移的 caller 影響が必要なときに使う。identity 確認には先に `definition` を優先し、次は影響 caller/file の `excerpt` を使う。シンボルの推移的呼び出しチェーンを算出。symbol-level BFS は call graph 種別(`call`、`instantiate`、`subscribe`)のみを辿り、metadata-only edge(`attribute`、`annotation`、`type_reference`)を除外するため、metadata cycle で caller 件数が膨らまない。同じ caller から同じ target への複数 edge kind は別々に数えて返し、各 caller 行に `reference_kind`、`reference_kinds`、`reference_kindCounts` が付く。scoped query が単一の class / struct / interface に解決されても symbol-level caller が無い場合は、代わりに heuristic な file-level dependency hint を返すことがある。この file hint は metadata edge を含み得るため、`impact_mode`・`heuristic`・`file_impacts` を確認すること。`truncated` が真のときは `truncated_reason` を見て、`user_limit` なら `limit` を増やせば残りも取得可能、`safety_cap` ならグラフが病的で `limit` を増やしても解消しないことを区別すること。中間シンボル経由の経路が必要な場合は `withPaths: true` を渡すと、各 caller に経路配列 `paths` が付く(issue #1536)。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Symbol name to analyze impact for" }, - ["maxHops"] = new JsonObject { ["type"] = "integer", ["description"] = "Max BFS hops, inclusive (default: 5; maxHops: N returns callers at hop 1..N, so a chain A→B→C→D queried against D with maxHops: 2 yields C at hop 1 and B at hop 2; 0 resolves the symbol without traversing callers). Server-side cap: 50; requests above the cap are clamped and a `warnings` entry plus `max_hops_requested` field is added to the response.", ["default"] = 5, ["minimum"] = 0, ["maximum"] = 50 }, - ["maxDepth"] = new JsonObject { ["type"] = "integer", ["description"] = "Deprecated alias for `maxHops`; accepted during the compatibility period and reported in `warnings` when used.", ["minimum"] = 0, ["maximum"] = 50 }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max total callers or heuristic file-level dependency hints to return (default: 50). Check `truncated` when the limit is reached; `truncated_reason` distinguishes `user_limit` (raise `limit` to get more) from `safety_cap` (pathological graph, raising `limit` will not help).", ["default"] = QueryCommandRunner.DefaultImpactLimit }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Prefer or restrict paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, - ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include files detected as generated code", ["default"] = false }, - ["withPaths"] = new JsonObject { ["type"] = "boolean", ["description"] = "When true, each caller carries a `paths` array of shortest call chains [resolvedRoot, intermediate..., callerName]; diamond convergence surfaces every shortest route (per-row cap; `pathsTruncated` flag indicates overflow).", ["default"] = false }, - ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit caller and file-impact row payloads.", ["default"] = false } - }, - ["required"] = new JsonArray { "query" } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "status", - "Get database statistics, readiness state, and optional CLI-style freshness checks. Use `check`, `scopes`, `staleAfterSeconds`, `explain`, `config`, `logPath`, `format`, or `fields` for bounded health-check views. / DB統計、readiness 状態、必要に応じて CLI 風の freshness check を取得。`check` / `scopes` / `staleAfterSeconds` / `explain` / `config` / `logPath` / `format` / `fields` で health-check 用の出力に絞り込める。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["check"] = new JsonObject { ["type"] = "boolean", ["description"] = "Run a workspace freshness check and populate `workspace_check`, `index_matches_workspace`, and `failed_checks`.", ["default"] = false }, - ["scopes"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "workspace", "graph", "issues", "sql", "hotspot", "csharp", "fold", "newer" } } } }, ["description"] = "Readiness scopes to evaluate for `failed_checks`. Omit to evaluate all scopes." }, - ["staleAfterSeconds"] = new JsonObject { ["type"] = "integer", ["description"] = "Effective stale-after threshold, in seconds, echoed with `index_age_seconds` when `check` is true.", ["default"] = 86400, ["minimum"] = 1 }, - ["explain"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "freshness", "readiness", "all" }, ["description"] = "Include a focused `explain` object for freshness/readiness diagnostics. `all` includes both.", ["default"] = "all" }, - ["config"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include effective MCP/CLI status configuration such as DB path, version, log dir, stale threshold, and update-check request state.", ["default"] = false }, - ["logPath"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include the resolved global tool log directory as `log_path`.", ["default"] = false }, - ["updateCheck"] = new JsonObject { ["type"] = "boolean", ["description"] = "Run the same update check as CLI status. Defaults to false because it may perform network I/O.", ["default"] = false }, - ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "compact" }, ["description"] = "Response shape. `compact` returns counts, freshness, readiness, and requested diagnostics without full language/kind tables.", ["default"] = "full" }, - ["fields"] = new JsonObject - { - ["oneOf"] = new JsonArray - { - new JsonObject { ["type"] = "string", ["minLength"] = 1, ["maxLength"] = MaxStatusProjectionFieldCharacters }, - new JsonObject - { - ["type"] = "array", - ["minItems"] = 1, - ["maxItems"] = MaxStatusProjectionFields, - ["items"] = new JsonObject { ["type"] = "string", ["minLength"] = 1, ["maxLength"] = MaxStatusProjectionFieldCharacters } - } - }, - ["description"] = "Return only these exact top-level structured-content fields after applying `format`, plus the standard `api_version`. Accepts one field or an array; nested paths are not supported." - } - } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "outline", - "Use this when a file is known but you need structure before reading content. Prefer it before whole-file reads; common next step is `excerpt` on a specific symbol range. Return the symbol outline of a single indexed file: all functions, classes, imports with line numbers, signatures, and nesting. / ファイルは分かっているが本文を読む前に構造を把握したいときに使う。ファイル全体を読む前に優先し、次は特定シンボル範囲の `excerpt` を使う。1ファイルのシンボルアウトラインを返す: 関数、クラス、importの行番号、シグネチャ、ネスト構造。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["path"] = new JsonObject { ["type"] = "string", ["description"] = "Indexed file path (e.g. src/app.cs)" }, - }, - ["required"] = new JsonArray { "path" } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "deps", - "Show file-level dependency edges, JSON graph payloads, or dependency cycles from the indexed reference graph. / インデックス済み参照グラフからファイル間の依存エッジ、JSON graph ペイロード、依存サイクルを返す。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max edges (default: 50)", ["default"] = QueryCommandRunner.DefaultImpactLimit }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict source files to glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude glob-style path patterns. `*` and `?` are wildcards."), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files", ["default"] = false }, - ["includeGenerated"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include dependency edges whose source or target file is detected as generated code. Defaults to false, matching other query tools.", ["default"] = false }, - ["reverse"] = new JsonObject { ["type"] = "boolean", ["description"] = "Reverse lookup: show files that depend ON the matched path", ["default"] = false }, - ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "edgelist", "json-graph" }, ["description"] = "Structured response format. `edgelist` preserves the existing edges array; `json-graph` returns nodes and edges.", ["default"] = "edgelist" }, - ["cycles"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return stable ranked strongly connected components instead of ordinary edge rows. `limit` paginates the completed analysis; inspect `analysis_complete` and continue with opaque `next_cursor` values. / 通常の edge 行ではなく安定順位付きの強連結成分を返す。`limit` は完了した解析結果をページ分割する。`analysis_complete` を確認し、不透明な `next_cursor` で続きを取得する。", ["default"] = false }, - ["graphBudget"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1, ["maximum"] = QueryCommandRunner.MaxDependencyCycleGraphBudget, ["description"] = "Maximum dependency edges analyzed for `cycles`, independent of the display `limit`. / 表示用 `limit` と独立した、`cycles` 解析対象の依存 edge 上限。", ["default"] = QueryCommandRunner.DefaultDependencyCycleGraphBudget }, - ["cursor"] = new JsonObject { ["type"] = "string", ["maxLength"] = 256, ["description"] = "Opaque dependency-cycle `next_cursor`; reuse the same filters and graphBudget. / 同じ filter と graphBudget で再利用する不透明な dependency-cycle `next_cursor`。" } - } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "languages", - "List supported languages with extensions, aliases, capabilities, and unsupported_guidance fallback commands. Use `indexedOnly`, `capability`, `extension`, or `alias` to match CLI language filters and extension lookup. / 対応言語一覧を拡張子・別名・機能・`unsupported_guidance` の代替コマンド付きで返す。`indexedOnly` / `capability` / `extension` / `alias` で CLI の言語フィルタと拡張子 lookup に合わせて絞り込める。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["indexedOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only languages currently present in the index. Requires the configured database.", ["default"] = false }, - ["capability"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "symbols", "graph", "references" } }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "symbols", "graph", "references" } } } }, ["description"] = "Filter by language capability. `graph` and `references` both require call-graph/reference extraction support. Accepts a single value or an array; all requested capabilities must match." }, - ["extension"] = new JsonObject { ["type"] = "string", ["description"] = "Look up languages by file extension. Accepts `cs` or `.cs` style values." }, - ["alias"] = new JsonObject { ["type"] = "string", ["description"] = "Look up languages by canonical language name or CLI language alias, e.g. `cs` for `csharp`." } - } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "validate", - "Report encoding issues found during indexing: U+FFFD replacement chars, BOM markers, null bytes, mixed/CR-only line endings, UTF-16 BOM detection, likely non-UTF8 encodings. replacement_char rows include origin/severity metadata so agents can separate source literals from decoder replacements. / インデックス時に検出したエンコーディング問題を報告。replacement_char 行は source literal と decoder replacement を分ける origin/severity metadata を含む。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by issue kind (replacement_char, bom, null_byte, mixed_line_endings, mixed_line_endings_three_way, cr_only_line_endings, utf16_bom, non_utf8_likely, line_too_long)" }, - ["severity"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "error", "warning", "info" }, ["description"] = "Filter by issue severity." }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max issues to return (default: 20).", ["default"] = QueryCommandRunner.DefaultQueryLimit }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude any paths containing these texts"), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude likely test files", ["default"] = false }, - ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a top-file histogram; omit issue rows.", ["default"] = false }, - ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full issue rows, count-only metadata, or compact file/line/kind/severity rows.", ["default"] = "full" } - } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "ping", - "Lightweight connection check. Returns server version and timestamp. No database required. / 軽量接続チェック。サーバーバージョンとタイムスタンプを返す。DB不要。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject() - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "batch_query", - "Execute multiple read-only queries in a single call and return all results plus top-level success/failure counts, partial_failure, and failure_scope (none/isolated/cascading). Dramatically reduces round-trips for AI agents. / 複数の読み取り専用クエリを1回の呼び出しで実行し、全結果に加えてトップレベルの成功/失敗件数、partial_failure、failure_scope(none/isolated/cascading)を返す。AIエージェントの往復回数を劇的に削減。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["queries"] = new JsonObject - { - ["type"] = "array", - ["description"] = $"Array of {{tool, arguments}} objects. Only read-only tools are allowed (not index or backfill_fold). Hard cap: {MaxBatchQuerySize} slots.", - ["minItems"] = 1, - ["maxItems"] = MaxBatchQuerySize, - ["items"] = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["id"] = new JsonObject { ["type"] = "string", ["description"] = "Optional client-supplied slot identifier echoed as slot_id." }, - ["slotId"] = new JsonObject { ["type"] = "string", ["description"] = "Optional client-supplied slot identifier echoed as slot_id." }, - ["tool"] = new JsonObject { ["type"] = "string", ["description"] = "Tool name (e.g. search, definition, symbols)" }, - ["arguments"] = new JsonObject { ["type"] = "object", ["description"] = "Tool arguments" } - }, - ["required"] = new JsonArray { "tool" } - } - }, - ["maxResponseBytes"] = new JsonObject { ["type"] = "integer", ["description"] = "Optional per-call response byte budget for this batch_query response. Values above the server cap are clamped and reported in argument_adjustments.", ["minimum"] = 1, ["maximum"] = MaxBatchQueryResponseByteLimit }, - ["estimateOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return budget and slot estimate metadata without executing the slots.", ["default"] = false } - }, - ["required"] = new JsonArray { "queries" } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "index", - "Index or re-index a project directory. Scans source files, extracts symbols, and builds FTS5 search index. On transports that can carry out-of-band server messages (stdio, and HTTP clients connected to `/events`), when the tools/call request includes a bounded scalar/object `_meta.progressToken`, this tool emits `notifications/progress` with that token while scanning, indexing, and finalizing; oversized or unsupported tokens are ignored instead of echoed. / プロジェクトディレクトリをインデックス(再インデックス)。ソースファイルをスキャンし、シンボルを抽出してFTS5検索インデックスを構築。out-of-band のサーバーメッセージを送れる transport(stdio、および `/events` に接続した HTTP クライアント)では、tools/call リクエストに bounded scalar/object の `_meta.progressToken` が含まれる場合、スキャン・インデックス・finalize 中に同じ token の `notifications/progress` を送信し、上限超過または未対応 token は echo せず無視する。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["path"] = new JsonObject { ["type"] = "string", ["description"] = "Project directory path to index" }, - ["rebuild"] = new JsonObject { ["type"] = "boolean", ["description"] = "Delete existing index and rebuild from scratch (default: false)", ["default"] = false }, - ["dryRun"] = new JsonObject { ["type"] = "boolean", ["description"] = "Plan the index run without mutating the database. Reports scan counts, effective options, and unsupported MCP modes.", ["default"] = false }, - ["maxFileBytes"] = new JsonObject { ["type"] = "integer", ["description"] = "Override the per-file indexing size limit for this run. Defaults to CDIDX_MAX_FILE_BYTES or 4MiB.", ["minimum"] = 1, ["maximum"] = int.MaxValue }, - ["maxSymbolsPerFile"] = new JsonObject { ["type"] = "integer", ["description"] = "Skip symbol/reference indexing for files that produce more symbols than this limit, matching CLI --max-symbols-per-file.", ["default"] = IndexCommandRunner.DefaultMaxSymbolsPerFile, ["minimum"] = 1, ["maximum"] = IndexCommandRunner.MaxSymbolsPerFileLimit }, - ["maxReferencesPerFile"] = new JsonObject { ["type"] = "integer", ["description"] = "Skip references for files that produce more references than this limit, matching CLI --max-references-per-file.", ["default"] = IndexCommandRunner.DefaultMaxReferencesPerFile, ["minimum"] = 1, ["maximum"] = IndexCommandRunner.MaxReferencesPerFileLimit }, - ["followSymlinks"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "none", "internal", "all" }, ["description"] = "Directory and file symlink policy matching CLI --follow-symlinks.", ["default"] = "none" }, - ["includeSymbolKind"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Only index symbols with these kinds. Accepts a value, comma-separated string, or array." }, - ["excludeSymbolKind"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Drop symbols with these kinds before indexing. Accepts a value, comma-separated string, or array." }, - ["memoryTrace"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include lightweight MCP memory samples and duration diagnostics in the response.", ["default"] = false }, - ["parallelism"] = new JsonObject { ["type"] = "integer", ["description"] = "CLI compatibility knob. MCP index currently runs serially and reports effective_parallelism=1 instead of silently using this value.", ["minimum"] = 1, ["maximum"] = IndexCommandRunner.MaxIndexParallelism }, - ["commits"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "CLI compatibility scope. Commit-scoped MCP indexing is not supported; non-dry runs reject it explicitly." }, - ["changedBetween"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "CLI compatibility scope. changed-between MCP indexing is not supported; non-dry runs reject it explicitly." }, - ["files"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "CLI compatibility scope. File-scoped MCP indexing is not supported; non-dry runs reject it explicitly." }, - ["watch"] = new JsonObject { ["type"] = "boolean", ["description"] = "CLI compatibility flag. Long-running watch mode is intentionally disabled for MCP; non-dry runs reject it explicitly.", ["default"] = false }, - ["debounce"] = new JsonObject { ["type"] = "integer", ["description"] = "Watch debounce in milliseconds. Reported as unsupported unless watch mode is added to MCP in the future.", ["minimum"] = 0, ["maximum"] = IndexWatchRunner.MaxDebounceMs } - }, - ["required"] = new JsonArray { "path" } - }, - IndexAnnotations()), - CreateToolDefinition( - "backfill_fold", - "Upgrade folded-name keys in an existing CodeIndex DB without reparsing source files. Rejects missing or blank targets instead of creating a fresh DB. Fills missing `name_folded` columns (or rewrites all keys after fold metadata drift such as version/fingerprint mismatch) and stamps FoldReady on success. Use `dry_run:true` to preview affected row counts without writing, or `force:true` to rewrite every folded key even when metadata appears current. On transports that can carry out-of-band server messages (stdio, and HTTP clients connected to `/events`), when the tools/call request includes a bounded scalar/object `_meta.progressToken`, this tool emits `notifications/progress` with that token during backfill and verification; oversized or unsupported tokens are ignored instead of echoed. / ソース再解析なしで既存の CodeIndex DB の folded-name key を更新する。欠落したDBや空のDBを新規作成せず拒否し、欠損 `name_folded` 列を埋めるか、fold metadata の drift(version / fingerprint 不一致など)時は全 key を再生成し、成功時に FoldReady を stamp する。`dry_run:true` で書き込まず対象行数を確認でき、`force:true` で metadata が current に見える場合でも全 folded key を再生成する。out-of-band のサーバーメッセージを送れる transport(stdio、および `/events` に接続した HTTP クライアント)では、tools/call リクエストに bounded scalar/object の `_meta.progressToken` が含まれる場合、backfill と検証中に同じ token の `notifications/progress` を送信し、上限超過または未対応 token は echo せず無視する。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["dry_run"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preview affected folded-key row counts without writing to the database.", ["default"] = false }, - ["force"] = new JsonObject { ["type"] = "boolean", ["description"] = "Rewrite all folded keys even when stored fold metadata matches the current runtime.", ["default"] = false } - } - }, - IndexAnnotations()), - CreateToolDefinition( - "symbol_hotspots", - "Find the most-referenced symbols in the codebase (hotspot analysis). " - + "Returns symbols ordered by reference score, reference count, then deterministic ties by path, line, name, kind, and symbol id. `groupBy` can be `symbol` or `file`; `statement` is accepted only with `lang=sql` to preserve existing SQL behavior. Structured output includes `grouping_unit`, `count_kind`, `limit_applies_to`, `score_fields`, `ranking_fields`, and matching `query_context` fields so callers can tell whether `limit` applies to symbols, files, or SQL statements. Names that are unique within the active language/kind candidate set use codebase-wide totals; duplicate-name families fall back to conservative same-file counts, and same-file duplicate rows may be grouped when the DB cannot disambiguate targets. Cross-file grouping of duplicate families is trusted only on indexes stamped with the current authoritative hotspot-family version. Useful for identifying central, high-impact code. " - + "/ コードベースで最も参照されるシンボルを検索する(ホットスポット分析)。" - + "参照スコア、参照回数の順にシンボルを返し、同点は path、line、name、kind、symbol id で決定的に並べる。`groupBy` は `symbol` / `file` を指定でき、`statement` は既存 SQL 挙動を保つため `lang=sql` の場合のみ受け付ける。structured output には `grouping_unit`、`count_kind`、`limit_applies_to`、`score_fields`、`ranking_fields` と対応する `query_context` fields が含まれ、`limit` が symbols / files / SQL statements のどれに適用されるかを判別できる。active な言語/種別候補集合で一意な名前は codebase 全体の件数を使い、同名ファミリーは保守的な same-file 件数へフォールバックし、DB が対象を曖昧なく結べない同一ファイル重複行は集約される。duplicate family の cross-file 集約は current の authoritative hotspot-family version で stamp された index でのみ信頼する。中心的で影響の大きいコードの特定に有用。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind" }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20)", ["default"] = QueryCommandRunner.DefaultQueryLimit }, - ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, - ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, - ["groupBy"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("symbol", "file", "statement"), ["description"] = "Grouping unit. Use symbol or file for non-SQL scopes; statement is accepted only when lang is sql." }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict to glob-style path patterns. `*` and `?` are wildcards. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude glob-style path patterns. `*` and `?` are wildcards."), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files (default: false)", ["default"] = false } - } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "unused_symbols", - "Use this when auditing potential dead code before removal. Prefer `references`, `callers`, or `excerpt` to verify surprising hits before editing. Find symbols that are defined but never referenced in the indexed codebase. " - + "Results include confidence buckets so private hits rank ahead of public/exported suspects; the lowest-confidence bucket also covers reflection, serialization contracts, config, metadata, generated surfaces, documentation headings, and test-only hooks. Only meaningful for languages with reference extraction support. " - + "Structured output includes `summary.by_bucket`, `summary.by_confidence`, `summary.by_contract_domain`, `bucket_taxonomy`, and per-symbol `unusedContractDomain`; bucket values are `likely_unused_private`, `maybe_unused_nonpublic`, `public_or_exported_no_refs`, and `reflection_or_config_suspect`. Use `bucket` or `minConfidence` to audit a single bucket or confidence class. " - + "C# nameof/typeof and direct reflection member-name literals such as GetMethod(\"Foo\") are indexed as references; dynamically constructed reflection names can still require manual review. " - + "/ 削除前に dead code 候補を監査するときに使う。意外なヒットは編集前に `references` / `callers` / `excerpt` で確認する。インデックス済みコードベースで定義されているが一度も参照されていないシンボルを検索する。" - + "private 候補を public/exported suspect より前に返し、最低信頼 bucket は reflection、serialization contract、config、metadata、generated surface、documentation heading、test-only hook も扱う。参照抽出対応言語でのみ意味がある。" - + "構造化出力には `summary.by_bucket`、`summary.by_confidence`、`summary.by_contract_domain`、`bucket_taxonomy`、シンボル単位の `unusedContractDomain` が含まれ、bucket 値は `likely_unused_private`、`maybe_unused_nonpublic`、`public_or_exported_no_refs`、`reflection_or_config_suspect`。`bucket` または `minConfidence` で単一 bucket や confidence class を監査できる。" - + "C# の nameof/typeof と GetMethod(\"Foo\") のような直接の reflection member-name literal は参照として index されるが、動的に組み立てた reflection 名は手動確認が必要な場合がある。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind (function, class, property, interface, enum, struct, event, delegate)" }, - ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language (recommended: use a graph-supported language)" }, - ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 50)", ["default"] = QueryCommandRunner.DefaultImpactLimit }, - ["visibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Filter symbol visibility. Accepts a value, comma-separated string, or array." }, - ["excludeVisibility"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Exclude symbol visibility values. Accepts a value, comma-separated string, or array." }, - ["byBucket"] = new JsonObject { ["type"] = "boolean", ["description"] = "Include `symbols_by_bucket` grouped by unused-symbol bucket.", ["default"] = false }, - ["bucket"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("likely_unused_private", "maybe_unused_nonpublic", "public_or_exported_no_refs", "reflection_or_config_suspect"), ["description"] = "Return only one unused-symbol bucket." }, - ["minConfidence"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("medium", "low"), ["description"] = "Return symbols at or above this confidence threshold." }, - ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, - ["excludePaths"] = StringOrArraySchema("Exclude paths containing any of these texts"), - ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files (default: false)", ["default"] = false } - } - }, - ReadOnlyAnnotations()), - CreateToolDefinition( - "suggest_improvement", - "Submit a structured improvement suggestion or error report for cdidx. " - + "Call this when you notice a gap (e.g. missing language support, poor ranking) or encounter an unexpected error. " - + "Never include source code — describe the gap in natural language only. " - + "The tool writes to the resolved .cdidx directory, which must be writable; responses include cdidx_dir for diagnostics. " - + "Responses also include github_submission_reason: submitted, token_not_configured, repo_not_configured, network_error, or api_error. " - + "/ cdidxへの構造化された改善提案またはエラー報告を送信する。" - + "ギャップ(言語サポート不足、ランキング不良等)に気づいたとき、または予期せぬエラーに遭遇したときに呼び出す。" - + "ソースコードを含めないこと — 自然言語でのみギャップを記述する。" - + "解決された .cdidx ディレクトリへ書き込むため、そのディレクトリは書き込み可能である必要がある。応答には診断用の cdidx_dir と github_submission_reason が含まれる。", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["category"] = new JsonObject - { - ["type"] = "string", - ["description"] = "Suggestion category: symbol_extraction, reference_extraction, search_ranking, language_support, output_format, crash_report, unexpected_error, security, performance, bug, cleanup, documentation, feature_request, or other", - ["enum"] = new JsonArray(SuggestionRecord.ValidCategories.Select(category => (JsonNode?)category).ToArray()) - }, - ["language"] = new JsonObject { ["type"] = "string", ["description"] = "Programming language this applies to (optional)" }, - ["description"] = new JsonObject { ["type"] = "string", ["description"] = "What gap or improvement you observed, or what error occurred (NOT source code)" }, - ["context"] = new JsonObject { ["type"] = "string", ["description"] = "What you were trying to do when you noticed the gap (NOT source code)" }, - ["toolInvocationContext"] = new JsonObject { ["type"] = "string", ["description"] = "Natural-language context for the current tool invocation or workflow (optional, NOT source code)" }, - ["evidencePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Repository-relative paths that support the suggestion (optional, no source code)" } - }, - ["required"] = new JsonArray { "category", "description" } - }, - SuggestionAnnotations()) - }; + private JsonNode HandleToolsList(JsonNode? id, JsonNode? listParams) => + CreateToolsListResponse(id, listParams, CreateToolCatalog()); - AddProjectScopeProperties(tools); - AddCommonSchemaConstraints(tools); + private JsonNode CreateToolsListResponse(JsonNode? id, JsonNode? listParams, JsonArray tools) + { // Per-deployment enablement gate (#1561). Drop any tool the operator disabled via // `CDIDX_MCP_TOOLS_ALLOW` / `CDIDX_MCP_TOOLS_DENY` so AI clients never see destructive // or out-of-scope tools advertised in the first place. From 1545d27cac6f892f4df2f771461dcf98e73bfa52 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 13:02:52 +0900 Subject: [PATCH 019/101] Unify graph command query validation --- src/CodeIndex/Cli/QueryCommandRunner.Graph.cs | 244 ++++++++++-------- 1 file changed, 132 insertions(+), 112 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs index 68948b28f..7a9c215ca 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Graph.cs @@ -7,46 +7,25 @@ public static partial class QueryCommandRunner { public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOptions) { - var previewOptionError = ValidatePreviewOptions("references", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: false); - if (previewOptionError != null) - { - CommandErrorWriter.WriteStderr(previewOptionError); - return CommandExitCodes.UsageError; - } - var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); - if (TryWriteUnsupportedOptionError("references", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("references"), options.Query)) - return CommandExitCodes.UsageError; + if (!TryParseGraphCommandOptions("references", cmdArgs, out var options, out var optionExitCode)) + return optionExitCode; if (TryWriteInvalidKindFilterError(options, "references", AllValidReferenceKinds, AllValidKinds)) return CommandExitCodes.InvalidArgument; if (TryWriteParseError(options, "references")) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "references")) return CommandExitCodes.UsageError; - if (!TryResolveNameExactMode(options, "references", out var exact, out var exactError)) - { - CommandErrorWriter.WriteStderr(exactError); - return CommandExitCodes.UsageError; - } - if (TryWriteBlankQueryError(options, "references")) - return CommandExitCodes.UsageError; - if (string.IsNullOrWhiteSpace(options.Query)) - { - WriteUsageError( + if (!TryValidateGraphSymbolQuery( + "references", + options, "references requires a symbol query argument", - GetUsageLineOrThrow("references"), - "Add the symbol name you want to trace, for example: `cdidx references QueryCommandRunner`."); - return CommandExitCodes.UsageError; - } - if (IsBareVerbatimQueryToken(options.Query)) + "Add the symbol name you want to trace, for example: `cdidx references QueryCommandRunner`.", + out var exact, + out var queryExitCode)) { - WriteUsageError( - "references requires a symbol query argument", - GetUsageLineOrThrow("references"), - "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); - return CommandExitCodes.UsageError; + return queryExitCode; } - if (TryWriteUnexpectedExtraPositionals("references", options)) - return CommandExitCodes.UsageError; + var query = options.Query!; return WithDb(options, jsonOptions, reader => { @@ -54,7 +33,7 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti var baseSqlGraphSignal = reader.GetSqlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); var hdlGraphSignal = reader.GetHdlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); var exactGraphLanguage = exact - ? reader.GetExactGraphSupportedDefinitionLanguage(options.Query, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests) + ? reader.GetExactGraphSupportedDefinitionLanguage(query, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests) : null; if (options.CountOnly) { @@ -162,15 +141,8 @@ public static int RunReferences(string[] cmdArgs, JsonSerializerOptions jsonOpti public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions) { - var previewOptionError = ValidatePreviewOptions("callers", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: false); - if (previewOptionError != null) - { - CommandErrorWriter.WriteStderr(previewOptionError); - return CommandExitCodes.UsageError; - } - var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); - if (TryWriteUnsupportedOptionError("callers", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("callers"), options.Query)) - return CommandExitCodes.UsageError; + if (!TryParseGraphCommandOptions("callers", cmdArgs, out var options, out var optionExitCode)) + return optionExitCode; if (TryWriteParseError(options, "callers")) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "callers")) @@ -179,31 +151,17 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.UsageError; if (TryWriteInvalidKindFilterError(options, "callers", CallGraphOnlyReferenceKinds, AllValidReferenceKinds, AllValidKinds)) return CommandExitCodes.InvalidArgument; - if (!TryResolveNameExactMode(options, "callers", out var exact, out var exactError)) - { - CommandErrorWriter.WriteStderr(exactError); - return CommandExitCodes.UsageError; - } - if (TryWriteBlankQueryError(options, "callers")) - return CommandExitCodes.UsageError; - if (string.IsNullOrWhiteSpace(options.Query)) - { - WriteUsageError( + if (!TryValidateGraphSymbolQuery( + "callers", + options, "callers requires a symbol query argument", - GetUsageLineOrThrow("callers"), - "Add the callee symbol name after the command, for example: `cdidx callers QueryCommandRunner`."); - return CommandExitCodes.UsageError; - } - if (IsBareVerbatimQueryToken(options.Query)) + "Add the callee symbol name after the command, for example: `cdidx callers QueryCommandRunner`.", + out var exact, + out var queryExitCode)) { - WriteUsageError( - "callers requires a symbol query argument", - GetUsageLineOrThrow("callers"), - "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); - return CommandExitCodes.UsageError; + return queryExitCode; } - if (TryWriteUnexpectedExtraPositionals("callers", options)) - return CommandExitCodes.UsageError; + var query = options.Query!; return WithDb(options, jsonOptions, reader => { @@ -212,20 +170,20 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions var baseSqlGraphSignal = reader.GetSqlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); var hdlGraphSignal = reader.GetHdlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); var exactGraphLanguage = exact - ? reader.GetExactGraphSupportedDefinitionLanguage(options.Query, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests) + ? reader.GetExactGraphSupportedDefinitionLanguage(query, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests) : null; if (options.CountOnly) { - var counts = reader.CountCallersTotal(options.Query, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds); + var counts = reader.CountCallersTotal(query, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds); var effectiveSqlGraphSignal = NarrowSqlGraphContractSignal( baseSqlGraphSignal, counts.IncludesSql || DbReader.IsSqlLanguage(options.Lang) || DbReader.IsSqlLanguage(exactGraphLanguage)); var exactSignalForCount = reader.GetCallersExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: effectiveSqlGraphSignal.Relevant); var exactZeroHintForCount = BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountCallers(options.Query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds) > 0, - () => reader.CountCallers(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds), - () => reader.GetCallers(options.Query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode), + () => reader.CountCallers(query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds) > 0, + () => reader.CountCallers(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds), + () => reader.GetCallers(query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode), r => r.CalleeName); WriteExactGraphWarningIfNeeded(exact, options.Json, exactSignalForCount, reader, options); WriteSqlGraphContractWarningIfNeeded(options.Json, effectiveSqlGraphSignal, reader, options); @@ -240,7 +198,7 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.Success; } - var results = reader.GetCallers(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.RankMode, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callers")); + var results = reader.GetCallers(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.RankMode, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callers")); if (options.IncludeBody) AttachBodyExcerpts(reader, results, options.SnippetLines, options.MaxLineWidth); ApplyBodyRecoveryCommands(results, options.DbPath); @@ -248,9 +206,9 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions var exactSignal = reader.GetCallersExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountCallers(options.Query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds) > 0, - () => reader.CountCallers(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds), - () => reader.GetCallers(options.Query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode), + () => reader.CountCallers(query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds) > 0, + () => reader.CountCallers(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds), + () => reader.GetCallers(query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode), r => r.CalleeName); WriteExactGraphWarningIfNeeded(exact, options.Json, exactSignal, reader, options); WriteSqlGraphContractWarningIfNeeded(options.Json, sqlGraphSignal, reader, options); @@ -320,15 +278,8 @@ public static int RunCallers(string[] cmdArgs, JsonSerializerOptions jsonOptions public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions) { - var previewOptionError = ValidatePreviewOptions("callees", cmdArgs, allowMaxLineWidth: true, allowFocusOptions: false); - if (previewOptionError != null) - { - CommandErrorWriter.WriteStderr(previewOptionError); - return CommandExitCodes.UsageError; - } - var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); - if (TryWriteUnsupportedOptionError("callees", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("callees"), options.Query)) - return CommandExitCodes.UsageError; + if (!TryParseGraphCommandOptions("callees", cmdArgs, out var options, out var optionExitCode)) + return optionExitCode; if (TryWriteParseError(options, "callees")) return CommandExitCodes.UsageError; if (TryWriteSnippetLinesZeroUnsupportedError(options, "callees")) @@ -337,31 +288,17 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.UsageError; if (TryWriteInvalidKindFilterError(options, "callees", CallGraphOnlyReferenceKinds, AllValidReferenceKinds, AllValidKinds)) return CommandExitCodes.InvalidArgument; - if (!TryResolveNameExactMode(options, "callees", out var exact, out var exactError)) - { - CommandErrorWriter.WriteStderr(exactError); - return CommandExitCodes.UsageError; - } - if (TryWriteBlankQueryError(options, "callees")) - return CommandExitCodes.UsageError; - if (string.IsNullOrWhiteSpace(options.Query)) - { - WriteUsageError( + if (!TryValidateGraphSymbolQuery( + "callees", + options, "callees requires a caller query argument", - GetUsageLineOrThrow("callees"), - "Add the caller symbol name after the command, for example: `cdidx callees RunIndex`."); - return CommandExitCodes.UsageError; - } - if (IsBareVerbatimQueryToken(options.Query)) + "Add the caller symbol name after the command, for example: `cdidx callees RunIndex`.", + out var exact, + out var queryExitCode)) { - WriteUsageError( - "callees requires a caller query argument", - GetUsageLineOrThrow("callees"), - "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); - return CommandExitCodes.UsageError; + return queryExitCode; } - if (TryWriteUnexpectedExtraPositionals("callees", options)) - return CommandExitCodes.UsageError; + var query = options.Query!; return WithDb(options, jsonOptions, reader => { @@ -370,20 +307,20 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions var baseSqlGraphSignal = reader.GetSqlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); var hdlGraphSignal = reader.GetHdlGraphContractSignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); var exactGraphLanguage = exact - ? reader.GetExactGraphSupportedDefinitionLanguage(options.Query, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests) + ? reader.GetExactGraphSupportedDefinitionLanguage(query, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests) : null; if (options.CountOnly) { - var counts = reader.CountCalleesTotal(options.Query, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds); + var counts = reader.CountCalleesTotal(query, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds); var effectiveSqlGraphSignal = NarrowSqlGraphContractSignal( baseSqlGraphSignal, counts.IncludesSql || DbReader.IsSqlLanguage(options.Lang) || DbReader.IsSqlLanguage(exactGraphLanguage)); var exactSignalForCount = reader.GetCalleesExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: effectiveSqlGraphSignal.Relevant); var exactZeroHintForCount = BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountCallees(options.Query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds) > 0, - () => reader.CountCallees(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds), - () => reader.GetCallees(options.Query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode), + () => reader.CountCallees(query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds) > 0, + () => reader.CountCallees(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds), + () => reader.GetCallees(query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode), r => r.CallerName); WriteExactGraphWarningIfNeeded(exact, options.Json, exactSignalForCount, reader, options); WriteSqlGraphContractWarningIfNeeded(options.Json, effectiveSqlGraphSignal, reader, options); @@ -398,7 +335,7 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.Success; } - var results = reader.GetCallees(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.RankMode, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callees")); + var results = reader.GetCallees(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact, options.RawKinds, options.RankMode, offset: JsonEnvelopeWrapper.GetBoundedResponseOffset("callees")); if (options.IncludeBody) AttachBodyExcerpts(reader, results, options.SnippetLines, options.MaxLineWidth); ApplyBodyRecoveryCommands(results, options.DbPath); @@ -406,9 +343,9 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions var exactSignal = reader.GetCalleesExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); var exactZeroHint = BuildExactZeroHint( exact && reader._hasReferencesTable, - () => reader.CountCallees(options.Query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds) > 0, - () => reader.CountCallees(options.Query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds), - () => reader.GetCallees(options.Query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode), + () => reader.CountCallees(query, ExactZeroHintProbeLimit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds) > 0, + () => reader.CountCallees(query, options.Limit, options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds), + () => reader.GetCallees(query, Math.Min(options.Limit, ExactZeroHintSampleLimit), options.Lang, options.Kind, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, exact: false, rawKinds: options.RawKinds, rankMode: options.RankMode), r => r.CallerName); WriteExactGraphWarningIfNeeded(exact, options.Json, exactSignal, reader, options); WriteSqlGraphContractWarningIfNeeded(options.Json, sqlGraphSignal, reader, options); @@ -474,6 +411,89 @@ public static int RunCallees(string[] cmdArgs, JsonSerializerOptions jsonOptions }); } + private static bool TryParseGraphCommandOptions( + string command, + string[] cmdArgs, + out QueryCommandOptions options, + out int exitCode) + { + var previewOptionError = ValidatePreviewOptions( + command, + cmdArgs, + allowMaxLineWidth: true, + allowFocusOptions: false); + if (previewOptionError != null) + { + CommandErrorWriter.WriteStderr(previewOptionError); + options = null!; + exitCode = CommandExitCodes.UsageError; + return false; + } + + options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); + if (TryWriteUnsupportedOptionError( + command, + cmdArgs, + CliFlagSchema.GetAcceptedFlagNamesForCommand(command), + options.Query)) + { + exitCode = CommandExitCodes.UsageError; + return false; + } + + exitCode = CommandExitCodes.Success; + return true; + } + + private static bool TryValidateGraphSymbolQuery( + string command, + QueryCommandOptions options, + string requiredQueryMessage, + string querySuggestion, + out bool exact, + out int exitCode) + { + exact = false; + if (!TryResolveNameExactMode(options, command, out exact, out var exactError)) + { + CommandErrorWriter.WriteStderr(exactError); + exitCode = CommandExitCodes.UsageError; + return false; + } + + if (TryWriteBlankQueryError(options, command)) + { + exitCode = CommandExitCodes.UsageError; + return false; + } + + if (string.IsNullOrWhiteSpace(options.Query)) + { + WriteUsageError(requiredQueryMessage, GetUsageLineOrThrow(command), querySuggestion); + exitCode = CommandExitCodes.UsageError; + return false; + } + + if (IsBareVerbatimQueryToken(options.Query)) + { + WriteUsageError( + requiredQueryMessage, + GetUsageLineOrThrow(command), + "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); + exitCode = CommandExitCodes.UsageError; + return false; + } + + if (TryWriteUnexpectedExtraPositionals(command, options)) + { + exitCode = CommandExitCodes.UsageError; + return false; + } + + exitCode = CommandExitCodes.Success; + return true; + } + // Human-readable reference_kind label for a grouped caller/callee row. Counts // keep high-volume relationships visible without requiring JSON re-querying. // grouped caller/callee 行の人間向け reference_kind ラベル。count を併記して、 From 1290dade3c533966b8bc6c4432b870c079293d87 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 13:06:34 +0900 Subject: [PATCH 020/101] Extract Python and Java string masking --- .../ReferenceExtractor.PythonStringMasking.cs | 450 +++++++++++++++ ...ReferenceExtractor.StringLiteralMasking.cs | 75 +++ .../ReferenceExtractor.TypeReferences.cs | 515 ------------------ 3 files changed, 525 insertions(+), 515 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.PythonStringMasking.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.StringLiteralMasking.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.PythonStringMasking.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.PythonStringMasking.cs new file mode 100644 index 000000000..499adfbdb --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.PythonStringMasking.cs @@ -0,0 +1,450 @@ +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static string MaskPythonSingleLineFStrings(string line) + { + if (line.IndexOf('f') < 0 && line.IndexOf('F') < 0) + return line; + + char[]? masked = null; + for (var i = 0; i < line.Length; i++) + { + if (!TryOpenPythonSingleLineString(line, i, out var prefixLength, out var quoteChar, out var isRaw, out var isFString)) + continue; + + if (!isFString) + { + i += prefixLength; + continue; + } + + var chars = masked ??= line.ToCharArray(); + var quoteStart = i + prefixLength; + var openingLength = prefixLength + 1; + ReplaceWithSpaces(chars, i, openingLength); + i += openingLength; + + var inExpression = false; + var expressionDepth = 0; + while (i < line.Length) + { + if (!inExpression) + { + if (!isRaw && line[i] == '\\' && i + 1 < line.Length) + { + ReplaceWithSpaces(chars, i, 2); + i += 2; + continue; + } + + if (line[i] == '{' && i + 1 < line.Length && line[i + 1] == '{') + { + ReplaceWithSpaces(chars, i, 2); + i += 2; + continue; + } + + if (line[i] == '}' && i + 1 < line.Length && line[i + 1] == '}') + { + ReplaceWithSpaces(chars, i, 2); + i += 2; + continue; + } + + if (line[i] == '{') + { + chars[i] = ' '; + inExpression = true; + expressionDepth = 1; + i++; + continue; + } + + if (line[i] == quoteChar) + { + chars[i] = ' '; + i++; + break; + } + + chars[i] = ' '; + i++; + continue; + } + + if (line[i] == '{') + { + expressionDepth++; + i++; + continue; + } + + if (line[i] == '}') + { + expressionDepth--; + chars[i] = ' '; + i++; + if (expressionDepth == 0) + inExpression = false; + continue; + } + + if (line[i] == '\'' || line[i] == '"') + { + var nestedQuote = line[i]; + i++; + while (i < line.Length) + { + if (line[i] == '\\' && i + 1 < line.Length) + { + i += 2; + continue; + } + + if (line[i] == nestedQuote) + { + i++; + break; + } + + i++; + } + + // Leave nested string contents in place; the generic string regex + // masks them later while the outer f-string wrapper is already gone. + // ネスト文字列は内容を残す。外側 f-string の殻を先に除去しておき、 + // 内側の generic string regex で後からまとめてマスクする。 + continue; + } + + if (line[i] == '#') + { + chars[i] = ' '; + i++; + continue; + } + + i++; + } + + i = Math.Max(i - 1, quoteStart); + } + + return masked is null ? line : new string(masked); + } + + private static string[] MaskPythonFStrings(IReadOnlyList lines) + { + if (lines is string[] lineArray && !MayContainPythonFString(lines)) + return lineArray; + + var result = new string[lines.Count]; + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + result[lineIndex] = lines[lineIndex]; + + for (var lineIndex = 0; lineIndex < result.Length; lineIndex++) + { + var line = result[lineIndex]; + if (line.IndexOf('f') < 0 && line.IndexOf('F') < 0) + continue; + + for (var column = 0; column < line.Length; column++) + { + if (!TryOpenPythonString(line, column, out var prefixLength, out var quoteChar, out var isRaw, out var isFString, out var isTripleQuoted)) + continue; + + if (!isFString) + { + column += prefixLength; + continue; + } + + if (!isTripleQuoted) + { + result[lineIndex] = MaskPythonSingleLineFStrings(line); + break; + } + + MaskPythonTripleQuotedFString(result, lineIndex, column, prefixLength, quoteChar, isRaw, out var endLineIndex, out var endColumn); + lineIndex = endLineIndex; + line = result[lineIndex]; + column = endColumn; + } + } + + return result; + } + + private static bool MayContainPythonFString(IReadOnlyList lines) + { + foreach (var line in lines) + { + if (line.IndexOf('f') >= 0 || line.IndexOf('F') >= 0) + return true; + } + + return false; + } + + private static void MaskPythonTripleQuotedFString( + string[] lines, + int startLineIndex, + int startColumn, + int prefixLength, + char quoteChar, + bool isRaw, + out int endLineIndex, + out int endColumn) + { + var lineIndex = startLineIndex; + var column = startColumn; + var inExpression = false; + var inExpressionString = false; + var expressionDepth = 0; + var expressionStringQuote = '\0'; + var expressionStringTripleQuoted = false; + + endLineIndex = startLineIndex; + endColumn = startColumn; + + while (lineIndex < lines.Length) + { + var line = lines[lineIndex]; + var chars = line.ToCharArray(); + if (lineIndex == startLineIndex) + { + ReplaceWithSpaces(chars, startColumn, prefixLength + 3); + column = startColumn + prefixLength + 3; + } + else + { + column = 0; + } + + while (column < line.Length) + { + if (!inExpression) + { + if (!isRaw && line[column] == '\\' && column + 1 < line.Length) + { + ReplaceWithSpaces(chars, column, 2); + column += 2; + continue; + } + + if (line[column] == '{' && column + 1 < line.Length && line[column + 1] == '{') + { + ReplaceWithSpaces(chars, column, 2); + column += 2; + continue; + } + + if (line[column] == '}' && column + 1 < line.Length && line[column + 1] == '}') + { + ReplaceWithSpaces(chars, column, 2); + column += 2; + continue; + } + + if (line[column] == '{') + { + chars[column++] = ' '; + inExpression = true; + expressionDepth = 1; + continue; + } + + if (column + 2 < line.Length + && line[column] == quoteChar + && line[column + 1] == quoteChar + && line[column + 2] == quoteChar) + { + ReplaceWithSpaces(chars, column, 3); + lines[lineIndex] = new string(chars); + endLineIndex = lineIndex; + endColumn = column + 2; + return; + } + + chars[column++] = ' '; + continue; + } + + if (inExpressionString) + { + if (line[column] == '\\' && column + 1 < line.Length) + { + column += 2; + continue; + } + + if (expressionStringTripleQuoted) + { + if (column + 2 < line.Length + && line[column] == expressionStringQuote + && line[column + 1] == expressionStringQuote + && line[column + 2] == expressionStringQuote) + { + column += 3; + inExpressionString = false; + continue; + } + + column++; + continue; + } + + if (line[column] == expressionStringQuote) + { + column++; + inExpressionString = false; + continue; + } + + column++; + continue; + } + + if (line[column] == '\'' || line[column] == '"') + { + expressionStringQuote = line[column]; + expressionStringTripleQuoted = column + 2 < line.Length + && line[column + 1] == expressionStringQuote + && line[column + 2] == expressionStringQuote; + column += expressionStringTripleQuoted ? 3 : 1; + inExpressionString = true; + continue; + } + + if (line[column] == '{') + { + expressionDepth++; + column++; + continue; + } + + if (line[column] == '}') + { + expressionDepth--; + chars[column++] = ' '; + if (expressionDepth == 0) + inExpression = false; + continue; + } + + column++; + } + + lines[lineIndex] = new string(chars); + lineIndex++; + } + + endLineIndex = Math.Max(startLineIndex, lines.Length - 1); + endColumn = 0; + } + + private static void ReplaceWithSpaces(char[] buffer, int start, int length) + { + for (var i = start; i < start + length && i < buffer.Length; i++) + buffer[i] = ' '; + } + + private static bool TryOpenPythonSingleLineString( + string line, + int startIndex, + out int prefixLength, + out char quoteChar, + out bool isRaw, + out bool isFString) + { + prefixLength = 0; + quoteChar = '\0'; + isRaw = false; + isFString = false; + + if (startIndex < 0 || startIndex >= line.Length) + return false; + + if (startIndex > 0 && IsIdentifierChar(line[startIndex - 1])) + return false; + + var p = startIndex; + var prefixChars = 0; + while (p < line.Length && prefixChars < 2 && IsPythonStringPrefixChar(line[p])) + { + if (line[p] is 'r' or 'R') + isRaw = true; + if (line[p] is 'f' or 'F') + isFString = true; + p++; + prefixChars++; + } + + if (p >= line.Length || (line[p] != '\'' && line[p] != '"')) + return false; + if (p + 2 < line.Length && line[p] == line[p + 1] && line[p] == line[p + 2]) + return false; + + prefixLength = p - startIndex; + quoteChar = line[p]; + return true; + } + + private static bool TryOpenPythonString( + string line, + int startIndex, + out int prefixLength, + out char quoteChar, + out bool isRaw, + out bool isFString, + out bool isTripleQuoted) + { + isTripleQuoted = false; + if (!TryOpenPythonSingleOrTripleString(line, startIndex, out prefixLength, out quoteChar, out isRaw, out isFString, out isTripleQuoted)) + return false; + return true; + } + + private static bool TryOpenPythonSingleOrTripleString( + string line, + int startIndex, + out int prefixLength, + out char quoteChar, + out bool isRaw, + out bool isFString, + out bool isTripleQuoted) + { + prefixLength = 0; + quoteChar = '\0'; + isRaw = false; + isFString = false; + isTripleQuoted = false; + + if (startIndex < 0 || startIndex >= line.Length) + return false; + + if (startIndex > 0 && IsIdentifierChar(line[startIndex - 1])) + return false; + + var p = startIndex; + var prefixChars = 0; + while (p < line.Length && prefixChars < 2 && IsPythonStringPrefixChar(line[p])) + { + if (line[p] is 'r' or 'R') + isRaw = true; + if (line[p] is 'f' or 'F') + isFString = true; + p++; + prefixChars++; + } + + if (p >= line.Length || (line[p] != '\'' && line[p] != '"')) + return false; + + prefixLength = p - startIndex; + quoteChar = line[p]; + isTripleQuoted = p + 2 < line.Length && line[p + 1] == quoteChar && line[p + 2] == quoteChar; + return true; + } +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.StringLiteralMasking.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.StringLiteralMasking.cs new file mode 100644 index 000000000..78c6ade6f --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.StringLiteralMasking.cs @@ -0,0 +1,75 @@ +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static bool IsPythonStringPrefixChar(char c) => + c is 'r' or 'R' or 'u' or 'U' or 'b' or 'B' or 'f' or 'F'; + + private static string MaskJavaTextBlocks(string content) + { + var firstTextBlockCandidate = content.IndexOf("\"\"\"", StringComparison.Ordinal); + if (firstTextBlockCandidate < 0) + return content; + + var chars = content.ToCharArray(); + + for (var i = firstTextBlockCandidate; i + 2 < chars.Length; i++) + { + if (!IsJavaTextBlockOpening(chars, i)) + continue; + + // Mask the body but keep line breaks so all existing line/column logic stays valid. + i += 3; + while (i < chars.Length) + { + if (i + 2 < chars.Length + && chars[i] == '"' + && chars[i + 1] == '"' + && chars[i + 2] == '"' + && !IsEscapedByBackslashes(chars, i)) + { + i += 2; + break; + } + + if (chars[i] != '\r' && chars[i] != '\n') + chars[i] = ' '; + i++; + } + } + + return new string(chars); + } + + private static bool IsJavaTextBlockOpening(IReadOnlyList chars, int index) + { + if (index + 2 >= chars.Count) + return false; + + if (chars[index] != '"' || chars[index + 1] != '"' || chars[index + 2] != '"') + return false; + + if (IsEscapedByBackslashes(chars, index)) + return false; + + for (var i = index + 3; i < chars.Count; i++) + { + var c = chars[i]; + if (c == '\r' || c == '\n') + return true; + if (!char.IsWhiteSpace(c)) + return false; + } + + return true; + } + + private static bool IsEscapedByBackslashes(IReadOnlyList chars, int index) + { + var backslashCount = 0; + for (var i = index - 1; i >= 0 && chars[i] == '\\'; i--) + backslashCount++; + + return (backslashCount & 1) == 1; + } +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs index 7fd371c12..cde2c7a8d 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs @@ -3383,451 +3383,6 @@ private static int SkipCStyleQuotedLiteral(string line, int start) private static readonly Regex PascalBraceCommentRegex = new(@"\{[^}\r\n]*\}", RegexOptions.Compiled); private static readonly Regex PascalParenStarCommentRegex = new(@"\(\*.*?\*\)", RegexOptions.Compiled); - private static string MaskPythonSingleLineFStrings(string line) - { - if (line.IndexOf('f') < 0 && line.IndexOf('F') < 0) - return line; - - char[]? masked = null; - for (var i = 0; i < line.Length; i++) - { - if (!TryOpenPythonSingleLineString(line, i, out var prefixLength, out var quoteChar, out var isRaw, out var isFString)) - continue; - - if (!isFString) - { - i += prefixLength; - continue; - } - - var chars = masked ??= line.ToCharArray(); - var quoteStart = i + prefixLength; - var openingLength = prefixLength + 1; - ReplaceWithSpaces(chars, i, openingLength); - i += openingLength; - - var inExpression = false; - var expressionDepth = 0; - while (i < line.Length) - { - if (!inExpression) - { - if (!isRaw && line[i] == '\\' && i + 1 < line.Length) - { - ReplaceWithSpaces(chars, i, 2); - i += 2; - continue; - } - - if (line[i] == '{' && i + 1 < line.Length && line[i + 1] == '{') - { - ReplaceWithSpaces(chars, i, 2); - i += 2; - continue; - } - - if (line[i] == '}' && i + 1 < line.Length && line[i + 1] == '}') - { - ReplaceWithSpaces(chars, i, 2); - i += 2; - continue; - } - - if (line[i] == '{') - { - chars[i] = ' '; - inExpression = true; - expressionDepth = 1; - i++; - continue; - } - - if (line[i] == quoteChar) - { - chars[i] = ' '; - i++; - break; - } - - chars[i] = ' '; - i++; - continue; - } - - if (line[i] == '{') - { - expressionDepth++; - i++; - continue; - } - - if (line[i] == '}') - { - expressionDepth--; - chars[i] = ' '; - i++; - if (expressionDepth == 0) - inExpression = false; - continue; - } - - if (line[i] == '\'' || line[i] == '"') - { - var nestedQuote = line[i]; - i++; - while (i < line.Length) - { - if (line[i] == '\\' && i + 1 < line.Length) - { - i += 2; - continue; - } - - if (line[i] == nestedQuote) - { - i++; - break; - } - - i++; - } - - // Leave nested string contents in place; the generic string regex - // masks them later while the outer f-string wrapper is already gone. - // ネスト文字列は内容を残す。外側 f-string の殻を先に除去しておき、 - // 内側の generic string regex で後からまとめてマスクする。 - continue; - } - - if (line[i] == '#') - { - chars[i] = ' '; - i++; - continue; - } - - i++; - } - - i = Math.Max(i - 1, quoteStart); - } - - return masked is null ? line : new string(masked); - } - - private static string[] MaskPythonFStrings(IReadOnlyList lines) - { - if (lines is string[] lineArray && !MayContainPythonFString(lines)) - return lineArray; - - var result = new string[lines.Count]; - for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) - result[lineIndex] = lines[lineIndex]; - - for (var lineIndex = 0; lineIndex < result.Length; lineIndex++) - { - var line = result[lineIndex]; - if (line.IndexOf('f') < 0 && line.IndexOf('F') < 0) - continue; - - for (var column = 0; column < line.Length; column++) - { - if (!TryOpenPythonString(line, column, out var prefixLength, out var quoteChar, out var isRaw, out var isFString, out var isTripleQuoted)) - continue; - - if (!isFString) - { - column += prefixLength; - continue; - } - - if (!isTripleQuoted) - { - result[lineIndex] = MaskPythonSingleLineFStrings(line); - break; - } - - MaskPythonTripleQuotedFString(result, lineIndex, column, prefixLength, quoteChar, isRaw, out var endLineIndex, out var endColumn); - lineIndex = endLineIndex; - line = result[lineIndex]; - column = endColumn; - } - } - - return result; - } - - private static bool MayContainPythonFString(IReadOnlyList lines) - { - foreach (var line in lines) - { - if (line.IndexOf('f') >= 0 || line.IndexOf('F') >= 0) - return true; - } - - return false; - } - - private static void MaskPythonTripleQuotedFString( - string[] lines, - int startLineIndex, - int startColumn, - int prefixLength, - char quoteChar, - bool isRaw, - out int endLineIndex, - out int endColumn) - { - var lineIndex = startLineIndex; - var column = startColumn; - var inExpression = false; - var inExpressionString = false; - var expressionDepth = 0; - var expressionStringQuote = '\0'; - var expressionStringTripleQuoted = false; - - endLineIndex = startLineIndex; - endColumn = startColumn; - - while (lineIndex < lines.Length) - { - var line = lines[lineIndex]; - var chars = line.ToCharArray(); - if (lineIndex == startLineIndex) - { - ReplaceWithSpaces(chars, startColumn, prefixLength + 3); - column = startColumn + prefixLength + 3; - } - else - { - column = 0; - } - - while (column < line.Length) - { - if (!inExpression) - { - if (!isRaw && line[column] == '\\' && column + 1 < line.Length) - { - ReplaceWithSpaces(chars, column, 2); - column += 2; - continue; - } - - if (line[column] == '{' && column + 1 < line.Length && line[column + 1] == '{') - { - ReplaceWithSpaces(chars, column, 2); - column += 2; - continue; - } - - if (line[column] == '}' && column + 1 < line.Length && line[column + 1] == '}') - { - ReplaceWithSpaces(chars, column, 2); - column += 2; - continue; - } - - if (line[column] == '{') - { - chars[column++] = ' '; - inExpression = true; - expressionDepth = 1; - continue; - } - - if (column + 2 < line.Length - && line[column] == quoteChar - && line[column + 1] == quoteChar - && line[column + 2] == quoteChar) - { - ReplaceWithSpaces(chars, column, 3); - lines[lineIndex] = new string(chars); - endLineIndex = lineIndex; - endColumn = column + 2; - return; - } - - chars[column++] = ' '; - continue; - } - - if (inExpressionString) - { - if (line[column] == '\\' && column + 1 < line.Length) - { - column += 2; - continue; - } - - if (expressionStringTripleQuoted) - { - if (column + 2 < line.Length - && line[column] == expressionStringQuote - && line[column + 1] == expressionStringQuote - && line[column + 2] == expressionStringQuote) - { - column += 3; - inExpressionString = false; - continue; - } - - column++; - continue; - } - - if (line[column] == expressionStringQuote) - { - column++; - inExpressionString = false; - continue; - } - - column++; - continue; - } - - if (line[column] == '\'' || line[column] == '"') - { - expressionStringQuote = line[column]; - expressionStringTripleQuoted = column + 2 < line.Length - && line[column + 1] == expressionStringQuote - && line[column + 2] == expressionStringQuote; - column += expressionStringTripleQuoted ? 3 : 1; - inExpressionString = true; - continue; - } - - if (line[column] == '{') - { - expressionDepth++; - column++; - continue; - } - - if (line[column] == '}') - { - expressionDepth--; - chars[column++] = ' '; - if (expressionDepth == 0) - inExpression = false; - continue; - } - - column++; - } - - lines[lineIndex] = new string(chars); - lineIndex++; - } - - endLineIndex = Math.Max(startLineIndex, lines.Length - 1); - endColumn = 0; - } - - private static void ReplaceWithSpaces(char[] buffer, int start, int length) - { - for (var i = start; i < start + length && i < buffer.Length; i++) - buffer[i] = ' '; - } - - private static bool TryOpenPythonSingleLineString( - string line, - int startIndex, - out int prefixLength, - out char quoteChar, - out bool isRaw, - out bool isFString) - { - prefixLength = 0; - quoteChar = '\0'; - isRaw = false; - isFString = false; - - if (startIndex < 0 || startIndex >= line.Length) - return false; - - if (startIndex > 0 && IsIdentifierChar(line[startIndex - 1])) - return false; - - var p = startIndex; - var prefixChars = 0; - while (p < line.Length && prefixChars < 2 && IsPythonStringPrefixChar(line[p])) - { - if (line[p] is 'r' or 'R') - isRaw = true; - if (line[p] is 'f' or 'F') - isFString = true; - p++; - prefixChars++; - } - - if (p >= line.Length || (line[p] != '\'' && line[p] != '"')) - return false; - if (p + 2 < line.Length && line[p] == line[p + 1] && line[p] == line[p + 2]) - return false; - - prefixLength = p - startIndex; - quoteChar = line[p]; - return true; - } - - private static bool TryOpenPythonString( - string line, - int startIndex, - out int prefixLength, - out char quoteChar, - out bool isRaw, - out bool isFString, - out bool isTripleQuoted) - { - isTripleQuoted = false; - if (!TryOpenPythonSingleOrTripleString(line, startIndex, out prefixLength, out quoteChar, out isRaw, out isFString, out isTripleQuoted)) - return false; - return true; - } - - private static bool TryOpenPythonSingleOrTripleString( - string line, - int startIndex, - out int prefixLength, - out char quoteChar, - out bool isRaw, - out bool isFString, - out bool isTripleQuoted) - { - prefixLength = 0; - quoteChar = '\0'; - isRaw = false; - isFString = false; - isTripleQuoted = false; - - if (startIndex < 0 || startIndex >= line.Length) - return false; - - if (startIndex > 0 && IsIdentifierChar(line[startIndex - 1])) - return false; - - var p = startIndex; - var prefixChars = 0; - while (p < line.Length && prefixChars < 2 && IsPythonStringPrefixChar(line[p])) - { - if (line[p] is 'r' or 'R') - isRaw = true; - if (line[p] is 'f' or 'F') - isFString = true; - p++; - prefixChars++; - } - - if (p >= line.Length || (line[p] != '\'' && line[p] != '"')) - return false; - - prefixLength = p - startIndex; - quoteChar = line[p]; - isTripleQuoted = p + 2 < line.Length && line[p + 1] == quoteChar && line[p + 2] == quoteChar; - return true; - } private static bool IsIgnoredCallName(string language, string name) { @@ -4837,75 +4392,5 @@ and not "prolog" and not "ambiguous_pl" and not "nim" and not "matlab" private static bool UsesDashDashComments(string lang) => lang is "lua" or "sql" or "haskell" or "ada"; - private static bool IsPythonStringPrefixChar(char c) => - c is 'r' or 'R' or 'u' or 'U' or 'b' or 'B' or 'f' or 'F'; - - private static string MaskJavaTextBlocks(string content) - { - var firstTextBlockCandidate = content.IndexOf("\"\"\"", StringComparison.Ordinal); - if (firstTextBlockCandidate < 0) - return content; - - var chars = content.ToCharArray(); - - for (var i = firstTextBlockCandidate; i + 2 < chars.Length; i++) - { - if (!IsJavaTextBlockOpening(chars, i)) - continue; - - // Mask the body but keep line breaks so all existing line/column logic stays valid. - i += 3; - while (i < chars.Length) - { - if (i + 2 < chars.Length - && chars[i] == '"' - && chars[i + 1] == '"' - && chars[i + 2] == '"' - && !IsEscapedByBackslashes(chars, i)) - { - i += 2; - break; - } - - if (chars[i] != '\r' && chars[i] != '\n') - chars[i] = ' '; - i++; - } - } - - return new string(chars); - } - - private static bool IsJavaTextBlockOpening(IReadOnlyList chars, int index) - { - if (index + 2 >= chars.Count) - return false; - - if (chars[index] != '"' || chars[index + 1] != '"' || chars[index + 2] != '"') - return false; - - if (IsEscapedByBackslashes(chars, index)) - return false; - - for (var i = index + 3; i < chars.Count; i++) - { - var c = chars[i]; - if (c == '\r' || c == '\n') - return true; - if (!char.IsWhiteSpace(c)) - return false; - } - - return true; - } - - private static bool IsEscapedByBackslashes(IReadOnlyList chars, int index) - { - var backslashCount = 0; - for (var i = index - 1; i >= 0 && chars[i] == '\\'; i--) - backslashCount++; - - return (backslashCount & 1) == 1; - } } From c03931d5460143d2eb7d49515f980512d6980abf Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 13:51:52 +0900 Subject: [PATCH 021/101] Split symbol extraction dispatch and completion phases --- .../Symbols/SymbolExtractor.ExtractCore.cs | 239 +++-------------- .../SymbolExtractor.ExtractionPhases.cs | 250 ++++++++++++++++++ 2 files changed, 283 insertions(+), 206 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs index e25864928..85bbb0af7 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs @@ -40,96 +40,16 @@ private static List ExtractCore( return preparedSymbols!; } - if (lang == "xml") - { - var xmlLines = SplitContentLines(content); - return ExtractXmlSymbols(fileId, content, xmlLines); - } - - if (lang == "json") - { - return ExtractJsonSymbols(fileId, content, SplitContentLines(content)); - } - - if (lang == "jsonl") - { - return ExtractJsonLinesSymbols(fileId, content, SplitContentLines(content)); - } - - if (lang is "toml" or "gitignore" or "gitattributes" or "editorconfig" or "dockerignore" or "config") - { - return ExtractRepositoryMetadataSymbols(fileId, lang, SplitContentLines(content)); - } - - if (lang == "yaml") - { - return ExtractYamlSymbols(fileId, SplitContentLines(content)); - } - - if (lang == "msbuild") - { - return ExtractMsBuildSymbols(fileId, content, SplitContentLines(content)); - } - - if (lang == "solution") - { - return ExtractSolutionSymbols(fileId, SplitContentLines(content)); - } - - if (lang == "app_manifest") - { - return ExtractAppManifestSymbols(fileId, content, SplitContentLines(content)); - } - - if (lang is "dependency_manifest" or "dependency_lock") - { - return DependencyPackageExtractor.ExtractSymbols(fileId, content, SplitContentLines(content), filePath, lang); - } - - if (lang == "ambiguous_m") - { - var matlabContent = AmbiguousMContentMasker.MaskComments( - content, - maskMatlabComments: true, - maskObjectiveCComments: true); - var objectiveCContent = AmbiguousMContentMasker.MaskComments( - content, - maskMatlabComments: true, - maskObjectiveCComments: true, - preserveObjectiveCModuloExpressions: true); - var matlabSymbols = ExtractCore( - fileId, - "matlab", - matlabContent, - contentIsNormalized: true, - hasOversizeLine: false, - conflictMarkerLine: 0, - filePath, - projectRoot, - patternConfigsAlreadyLoaded: true, - cancellationToken); - var objectiveCSymbols = ExtractCore( - fileId, - "objc", - objectiveCContent, - contentIsNormalized: true, - hasOversizeLine: false, - conflictMarkerLine: 0, - filePath, - projectRoot, - patternConfigsAlreadyLoaded: true, - cancellationToken); - matlabSymbols.AddRange(objectiveCSymbols); - return matlabSymbols; - } - - if (lang == "markdown") + if (TryExtractSpecializedSymbols( + fileId, + lang, + content, + filePath, + projectRoot, + cancellationToken, + out var specializedSymbols)) { - var markdownLines = SplitContentLines(content); - var markdownSymbols = ExtractMarkdownSymbols(fileId, markdownLines); - AssignContainers(markdownSymbols, markdownLines, null); - PopulateDeclaredContainerQualifiedNames(markdownSymbols); - return markdownSymbols; + return specializedSymbols; } // Normalize CRLF / CR to LF first so direct callers that bypass FileIndexer @@ -2158,123 +2078,30 @@ bool[] GetCssQualifiedRuleAncestors() => } } - if (lang == "javascript") - ExtractJavaScriptBareMethods(fileId, lines, symbols, getPrivateScopeColumns!, GetJavaScriptTypeScriptSanitizedLines); - else if (lang == "typescript") - ExtractTypeScriptBareMethods(fileId, lines, symbols, getPrivateScopeColumns!, GetJavaScriptTypeScriptSanitizedLines); - else if (lang == "csharp") - ExtractCSharpEnumMembers(fileId, lines, structuralLines, csharpMatchLines!, symbols); - else if (lang == "java") - { - ExtractJavaEnumMembers(fileId, lines, symbols); - ExtractJavaCompactConstructors(fileId, lines, symbols); - ExtractJavaModuleDirectiveSymbols(fileId, lines, structuralLines, symbols, extractionState); - } - else if (lang == "vb") - ExtractVisualBasicEnumMembers(fileId, lines, symbols); - if (lang == "cobol") - ExtractCobolParagraphSymbols(fileId, lines, symbols, extractionState); - - if (string.Equals(originalLang, "svelte", StringComparison.Ordinal)) - ExtractSvelteReactiveSymbols(fileId, lines, symbols); - if (lang == "rust") - ExtractRustUseSymbols(fileId, lines, symbols, extractionState); - if (lang == "rust") - ExtractRustMultilineImplSymbols(fileId, lines, symbols, extractionState); - if (lang == "rust") - ExtractRustAssociatedTypeDefaultSymbols(fileId, lines, structuralLines, symbols); - if (lang == "go") - ExtractGoGroupedDeclarations(fileId, lines, symbols, extractionState); - if (lang == "cpp") - ExtractCppSameLineClassBodyMembers(fileId, lines, symbols); - if (lang == "cpp") - ExtractCppBalancedCallableSymbols(fileId, lines, structuralLines, symbols, extractionState); - if (lang == "cpp") - ExtractCppFriendDeclarationSymbols(fileId, lines, symbols, extractionState); - if (lang is "verilog" or "systemverilog") - ExtractHdlInlineParameterSymbols(fileId, lines, symbols, extractionState); - if (string.Equals(NormalizePluginLanguage(originalLang), "cuda", StringComparison.Ordinal)) - ClassifyCudaFunctionSubKinds(symbols); - if (lang == "python") - ExtractPythonAllExportSymbols(fileId, lines, symbols, pythonModulePrefix); - if (lang == "python") - ExtractPythonClassAttributeSymbols(fileId, lines, symbols); - if (lang == "python") - ExtractPythonWalrusSymbols(fileId, lines, symbols); - if (lang == "perl") - ExtractPerlHashConstantSymbols(fileId, lines, symbols, extractionState); - if (lang == "php") - ExtractPhpAdditionalPropertySymbols(fileId, lines, symbols); - if (lang == "php") - ExtractPhpPromotedConstructorProperties(fileId, lines, symbols); - if (lang == "php") - ExtractPhpDocblockMethodSymbols(fileId, lines, symbols); - if (lang == "php") - ExtractPhpDocblockPropertySymbols(fileId, lines, symbols); - if (lang == "php") - ExtractPhpTraitAliasSymbols(fileId, lines, symbols); - if (lang == "php") - ExtractPhpDocblockTypeAliasSymbols(fileId, lines, symbols); - if (lang == "php") - ExtractPhpDocblockImportTypeSymbols(fileId, lines, symbols); - if (lang == "php") - ExtractPhpPropertyHookSupplementalSymbols(fileId, lines, structuralLines, symbols); - if (lang == "swift") - ExtractSwiftPropertySupplementalSymbols(fileId, lines, structuralLines, symbols); - if (lang == "sql") - { - var sqlSyntheticSymbolLines = MaskSqlSyntheticSymbolLines(lines); - ExtractSqlCteSymbols(fileId, content, lines, symbols, extractionState); - ExtractSqlDefinerSymbols(fileId, lines, sqlSyntheticSymbolLines, symbols, extractionState); - ExtractSqlRoutineResultColumnSymbols(fileId, lines, sqlSyntheticSymbolLines, symbols, extractionState); - ExtractSqlGeneratedColumnSymbols(fileId, lines, sqlSyntheticSymbolLines, symbols, extractionState); - } - if (lang == "graphql") - ExtractGraphQLMemberSymbols(fileId, content, lines, symbols); - if (lang is "csharp" or "python" or "javascript" or "typescript") - ExtractSectionHeadingSymbols(fileId, lang, lines, symbols); - if (IsRazorLanguage(originalLang) || IsRazorFilePath(filePath)) - ExtractRazorDirectiveSymbols(fileId, lines, symbols); - if (prologMultilineHeads is { Count: > 0 }) - { - AddPrologMultilineHeadSymbols( - fileId, - lines, - symbols, - extractionState, - prologMultilineHeads); - } - if (lang == "tcl") - { - DynamicDeclarativeReferenceExtractor.AddTclInlineProcSymbols( - fileId, - lines, - structuralLines, - symbols); - } - AssignContainers(symbols, lines, getCSharpLineStartStates); - if (lang is "shell" or "powershell") - AddScriptScopeSymbol(fileId, lines, symbols); - if (lang == "csharp") - NormalizeCSharpImplicitPartialConstructorReturnTypes(symbols); - if (lang == "go") - AssignGoMethodReceiverContainers(symbols); - if (lang == "go") - ClassifyGoFunctionRoles(symbols, filePath); - MaterializeRecordPrimaryComponentSymbols(symbols, pendingRecordPrimaryComponents); - if (lang is "javascript" or "typescript") - ClassifyJavaScriptTypeScriptReactHooks(symbols); - if (lang == "scala") - ClassifyScalaCompanions(symbols); - KotlinSymbolNameNormalizer.NormalizeSecondaryConstructorNames(symbols); - if (lang == "shell") - ExpandShellAliasSymbols(fileId, lines, symbols, extractionState); - PopulateDeclaredContainerQualifiedNames(symbols); - if (lang == "nim") - { - foreach (var symbol in symbols) - symbol.IdentityNameFolded = NimIdentifierIdentity.Fold(symbol.Name); - } + AddSupplementalSymbols( + fileId, + originalLang, + lang, + content, + filePath, + lines, + structuralLines, + symbols, + extractionState, + getPrivateScopeColumns, + GetJavaScriptTypeScriptSanitizedLines, + csharpMatchLines, + pythonModulePrefix, + prologMultilineHeads); + FinalizePatternSymbols( + fileId, + lang, + filePath, + lines, + symbols, + extractionState, + getCSharpLineStartStates, + pendingRecordPrimaryComponents); return symbols; } diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs new file mode 100644 index 000000000..d32379ec6 --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs @@ -0,0 +1,250 @@ +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private static bool TryExtractSpecializedSymbols( + long fileId, + string? lang, + string content, + string? filePath, + string? projectRoot, + CancellationToken cancellationToken, + out List symbols) + { + switch (lang) + { + case "xml": + { + var lines = SplitContentLines(content); + symbols = ExtractXmlSymbols(fileId, content, lines); + return true; + } + case "json": + symbols = ExtractJsonSymbols(fileId, content, SplitContentLines(content)); + return true; + case "jsonl": + symbols = ExtractJsonLinesSymbols(fileId, content, SplitContentLines(content)); + return true; + case "toml": + case "gitignore": + case "gitattributes": + case "editorconfig": + case "dockerignore": + case "config": + symbols = ExtractRepositoryMetadataSymbols(fileId, lang, SplitContentLines(content)); + return true; + case "yaml": + symbols = ExtractYamlSymbols(fileId, SplitContentLines(content)); + return true; + case "msbuild": + symbols = ExtractMsBuildSymbols(fileId, content, SplitContentLines(content)); + return true; + case "solution": + symbols = ExtractSolutionSymbols(fileId, SplitContentLines(content)); + return true; + case "app_manifest": + symbols = ExtractAppManifestSymbols(fileId, content, SplitContentLines(content)); + return true; + case "dependency_manifest": + case "dependency_lock": + symbols = DependencyPackageExtractor.ExtractSymbols( + fileId, + content, + SplitContentLines(content), + filePath, + lang); + return true; + case "ambiguous_m": + { + var matlabContent = AmbiguousMContentMasker.MaskComments( + content, + maskMatlabComments: true, + maskObjectiveCComments: true); + var objectiveCContent = AmbiguousMContentMasker.MaskComments( + content, + maskMatlabComments: true, + maskObjectiveCComments: true, + preserveObjectiveCModuloExpressions: true); + symbols = ExtractCore( + fileId, + "matlab", + matlabContent, + contentIsNormalized: true, + hasOversizeLine: false, + conflictMarkerLine: 0, + filePath, + projectRoot, + patternConfigsAlreadyLoaded: true, + cancellationToken); + symbols.AddRange(ExtractCore( + fileId, + "objc", + objectiveCContent, + contentIsNormalized: true, + hasOversizeLine: false, + conflictMarkerLine: 0, + filePath, + projectRoot, + patternConfigsAlreadyLoaded: true, + cancellationToken)); + return true; + } + case "markdown": + { + var lines = SplitContentLines(content); + symbols = ExtractMarkdownSymbols(fileId, lines); + AssignContainers(symbols, lines, null); + PopulateDeclaredContainerQualifiedNames(symbols); + return true; + } + default: + symbols = null!; + return false; + } + } + + private static void AddSupplementalSymbols( + long fileId, + string? originalLang, + string lang, + string content, + string? filePath, + string[] lines, + string[] structuralLines, + SymbolExtractionList symbols, + SymbolExtractionState extractionState, + Func? getPrivateScopeColumns, + Func getJavaScriptTypeScriptSanitizedLines, + string[]? csharpMatchLines, + string? pythonModulePrefix, + Dictionary? prologMultilineHeads) + { + if (lang == "javascript") + ExtractJavaScriptBareMethods(fileId, lines, symbols, getPrivateScopeColumns!, getJavaScriptTypeScriptSanitizedLines); + else if (lang == "typescript") + ExtractTypeScriptBareMethods(fileId, lines, symbols, getPrivateScopeColumns!, getJavaScriptTypeScriptSanitizedLines); + else if (lang == "csharp") + ExtractCSharpEnumMembers(fileId, lines, structuralLines, csharpMatchLines!, symbols); + else if (lang == "java") + { + ExtractJavaEnumMembers(fileId, lines, symbols); + ExtractJavaCompactConstructors(fileId, lines, symbols); + ExtractJavaModuleDirectiveSymbols(fileId, lines, structuralLines, symbols, extractionState); + } + else if (lang == "vb") + ExtractVisualBasicEnumMembers(fileId, lines, symbols); + + if (lang == "cobol") + ExtractCobolParagraphSymbols(fileId, lines, symbols, extractionState); + if (string.Equals(originalLang, "svelte", StringComparison.Ordinal)) + ExtractSvelteReactiveSymbols(fileId, lines, symbols); + if (lang == "rust") + { + ExtractRustUseSymbols(fileId, lines, symbols, extractionState); + ExtractRustMultilineImplSymbols(fileId, lines, symbols, extractionState); + ExtractRustAssociatedTypeDefaultSymbols(fileId, lines, structuralLines, symbols); + } + if (lang == "go") + ExtractGoGroupedDeclarations(fileId, lines, symbols, extractionState); + if (lang == "cpp") + { + ExtractCppSameLineClassBodyMembers(fileId, lines, symbols); + ExtractCppBalancedCallableSymbols(fileId, lines, structuralLines, symbols, extractionState); + ExtractCppFriendDeclarationSymbols(fileId, lines, symbols, extractionState); + } + if (lang is "verilog" or "systemverilog") + ExtractHdlInlineParameterSymbols(fileId, lines, symbols, extractionState); + if (string.Equals(NormalizePluginLanguage(originalLang), "cuda", StringComparison.Ordinal)) + ClassifyCudaFunctionSubKinds(symbols); + if (lang == "python") + { + ExtractPythonAllExportSymbols(fileId, lines, symbols, pythonModulePrefix); + ExtractPythonClassAttributeSymbols(fileId, lines, symbols); + ExtractPythonWalrusSymbols(fileId, lines, symbols); + } + if (lang == "perl") + ExtractPerlHashConstantSymbols(fileId, lines, symbols, extractionState); + if (lang == "php") + { + ExtractPhpAdditionalPropertySymbols(fileId, lines, symbols); + ExtractPhpPromotedConstructorProperties(fileId, lines, symbols); + ExtractPhpDocblockMethodSymbols(fileId, lines, symbols); + ExtractPhpDocblockPropertySymbols(fileId, lines, symbols); + ExtractPhpTraitAliasSymbols(fileId, lines, symbols); + ExtractPhpDocblockTypeAliasSymbols(fileId, lines, symbols); + ExtractPhpDocblockImportTypeSymbols(fileId, lines, symbols); + ExtractPhpPropertyHookSupplementalSymbols(fileId, lines, structuralLines, symbols); + } + if (lang == "swift") + ExtractSwiftPropertySupplementalSymbols(fileId, lines, structuralLines, symbols); + if (lang == "sql") + { + var sqlSyntheticSymbolLines = MaskSqlSyntheticSymbolLines(lines); + ExtractSqlCteSymbols(fileId, content, lines, symbols, extractionState); + ExtractSqlDefinerSymbols(fileId, lines, sqlSyntheticSymbolLines, symbols, extractionState); + ExtractSqlRoutineResultColumnSymbols(fileId, lines, sqlSyntheticSymbolLines, symbols, extractionState); + ExtractSqlGeneratedColumnSymbols(fileId, lines, sqlSyntheticSymbolLines, symbols, extractionState); + } + if (lang == "graphql") + ExtractGraphQLMemberSymbols(fileId, content, lines, symbols); + if (lang is "csharp" or "python" or "javascript" or "typescript") + ExtractSectionHeadingSymbols(fileId, lang, lines, symbols); + if (IsRazorLanguage(originalLang) || IsRazorFilePath(filePath)) + ExtractRazorDirectiveSymbols(fileId, lines, symbols); + if (prologMultilineHeads is { Count: > 0 }) + { + AddPrologMultilineHeadSymbols( + fileId, + lines, + symbols, + extractionState, + prologMultilineHeads); + } + if (lang == "tcl") + { + DynamicDeclarativeReferenceExtractor.AddTclInlineProcSymbols( + fileId, + lines, + structuralLines, + symbols); + } + } + + private static void FinalizePatternSymbols( + long fileId, + string lang, + string? filePath, + string[] lines, + SymbolExtractionList symbols, + SymbolExtractionState extractionState, + Func? getCSharpLineStartStates, + List? pendingRecordPrimaryComponents) + { + AssignContainers(symbols, lines, getCSharpLineStartStates); + if (lang is "shell" or "powershell") + AddScriptScopeSymbol(fileId, lines, symbols); + if (lang == "csharp") + NormalizeCSharpImplicitPartialConstructorReturnTypes(symbols); + if (lang == "go") + { + AssignGoMethodReceiverContainers(symbols); + ClassifyGoFunctionRoles(symbols, filePath); + } + MaterializeRecordPrimaryComponentSymbols(symbols, pendingRecordPrimaryComponents); + if (lang is "javascript" or "typescript") + ClassifyJavaScriptTypeScriptReactHooks(symbols); + if (lang == "scala") + ClassifyScalaCompanions(symbols); + KotlinSymbolNameNormalizer.NormalizeSecondaryConstructorNames(symbols); + if (lang == "shell") + ExpandShellAliasSymbols(fileId, lines, symbols, extractionState); + PopulateDeclaredContainerQualifiedNames(symbols); + if (lang == "nim") + { + foreach (var symbol in symbols) + symbol.IdentityNameFolded = NimIdentifierIdentity.Fold(symbol.Name); + } + } +} From f1239dd82efacd3a99f575ab61a4896d9899155b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 13:55:37 +0900 Subject: [PATCH 022/101] Encapsulate symbol pattern scan inputs --- .../Symbols/SymbolExtractor.ExtractCore.cs | 119 +++------------ .../SymbolExtractor.ExtractionPhases.cs | 138 ++++++++++++++++++ 2 files changed, 159 insertions(+), 98 deletions(-) diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs index 85bbb0af7..6a19194c1 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs @@ -101,104 +101,27 @@ private static List ExtractCore( if (patterns == null || lang == null) return []; - var pythonModulePrefix = lang == "python" - ? GetPythonModulePrefix(filePath) - : null; - - var structuralMaskLanguage = lang == "cython" ? "python" : lang; - var structuralLines = StructuralLineMasker.MaskLines(structuralMaskLanguage, lines); - if (lang is "d" or "julia" or "matlab" or "nim") - structuralLines = ScientificNativeCommentMasker.MaskBlockComments(lang, structuralLines); - structuralLines = DynamicDeclarativeReferenceExtractor.MaskNonCodeLines( - lang, - structuralLines); - if (lang == "tcl") - { - structuralLines = DynamicDeclarativeReferenceExtractor.MaskTclContinuedCommentLines( - lines, - structuralLines); - structuralLines = DynamicDeclarativeReferenceExtractor.MaskTclNonScriptLines( - structuralLines); - } - var scientificBodyScannerLines = lang is "julia" or "matlab" - ? PrepareScientificBodyScannerLines(structuralLines, lang) - : null; - var matlabExplicitOuterClosureByLine = lang == "matlab" && scientificBodyScannerLines != null - ? BuildMatlabExplicitOuterClosureMap(scientificBodyScannerLines) - : null; - string[]? javaScriptTypeScriptSanitizedLines = null; - string[] GetJavaScriptTypeScriptSanitizedLines() => - javaScriptTypeScriptSanitizedLines ??= BuildJavaScriptTypeScriptSanitizedLines(lines); - var cssScannerLines = lang == "css" - ? MaskCssScannerLines(lines) - : null; - var sassStylusScannerLines = lang is "sass" or "stylus" - ? MaskSassStylusBlockCommentLines(lang, lines) - : null; - var shellScannerLines = lang == "shell" - ? MaskShellHeredocLines(lines) - : null; - bool[]? prologClauseContinuationLines = null; - Dictionary? prologMultilineHeads = null; - if (lang is "prolog" or "ambiguous_pl") - { - prologMultilineHeads = []; - prologClauseContinuationLines = BuildPrologClauseContinuationLines( - structuralLines, - prologMultilineHeads); - } - var powershellEnumBodyLines = lang == "powershell" - ? FindPowerShellEnumBodyLines(structuralLines) - : null; - int[]?[] csharpMatchColumnToRaw = null!; - var csharpMatchLines = lang == "csharp" - ? BuildCSharpMatchLines(lines, out csharpMatchColumnToRaw) - : null; - CSharpLexState[]? csharpLineStartStates = null; - CSharpLexState[] GetCSharpLineStartStates() => - csharpLineStartStates ??= BuildCSharpLineStartStates(lines); - Func? getCSharpLineStartStates = lang == "csharp" - ? GetCSharpLineStartStates - : null; - DartClassBodyScope? dartInsideClassBody = null; - DartClassBodyScope GetDartInsideClassBody() => - dartInsideClassBody ??= BuildDartClassBodyScope(structuralLines); - JavaScriptScopePrivacyFlags[][]? privateScopeColumns = null; - JavaScriptScopePrivacyFlags[][] GetPrivateScopeColumns() => - privateScopeColumns ??= BuildJavaScriptTypeScriptPrivateScopeColumns(lines, lang!); - Func? getPrivateScopeColumns = lang is "javascript" or "typescript" - ? GetPrivateScopeColumns - : null; - CSharpTypeBodyScope? csharpInsideTypeBody = null; - CSharpTypeBodyScope GetCSharpInsideTypeBody() => - csharpInsideTypeBody ??= BuildCSharpTypeBodyScope(structuralLines); - CSharpCallableParameterScope? csharpCallableParameterScope = null; - CSharpCallableParameterScope GetCSharpCallableParameterScope() => - csharpCallableParameterScope ??= BuildCSharpCallableParameterScope(structuralLines, GetCSharpInsideTypeBody()); - bool[]? csharpSwitchExpressionLines = null; - var csharpSwitchExpressionLinesInitialized = false; - bool[]? GetCSharpSwitchExpressionLines() - { - if (!csharpSwitchExpressionLinesInitialized) - { - csharpSwitchExpressionLinesInitialized = true; - csharpSwitchExpressionLines = LinesContain(structuralLines, "switch", StringComparison.Ordinal) - ? FindCSharpSwitchExpressionLines(structuralLines) - : null; - } - - return csharpSwitchExpressionLines; - } - - Func? getCSharpSwitchExpressionLines = lang == "csharp" - ? GetCSharpSwitchExpressionLines - : null; - bool[]? cssQualifiedRuleAncestors = null; - bool[] GetCssQualifiedRuleAncestors() => - cssQualifiedRuleAncestors ??= FindCssQualifiedRuleAncestors(cssScannerLines!); - Func? getCssQualifiedRuleAncestors = lang == "css" - ? GetCssQualifiedRuleAncestors - : null; + var scanInputs = new PatternScanInputs(lang, filePath, lines); + var pythonModulePrefix = scanInputs.PythonModulePrefix; + var structuralLines = scanInputs.StructuralLines; + var scientificBodyScannerLines = scanInputs.ScientificBodyScannerLines; + var matlabExplicitOuterClosureByLine = scanInputs.MatlabExplicitOuterClosureByLine; + Func GetJavaScriptTypeScriptSanitizedLines = scanInputs.GetJavaScriptTypeScriptSanitizedLines; + var cssScannerLines = scanInputs.CssScannerLines; + var sassStylusScannerLines = scanInputs.SassStylusScannerLines; + var shellScannerLines = scanInputs.ShellScannerLines; + var prologClauseContinuationLines = scanInputs.PrologClauseContinuationLines; + var prologMultilineHeads = scanInputs.PrologMultilineHeads; + var powershellEnumBodyLines = scanInputs.PowershellEnumBodyLines; + var csharpMatchColumnToRaw = scanInputs.CSharpMatchColumnToRaw; + var csharpMatchLines = scanInputs.CSharpMatchLines; + var getCSharpLineStartStates = scanInputs.GetCSharpLineStartStates; + Func GetDartInsideClassBody = scanInputs.GetDartInsideClassBody; + var getPrivateScopeColumns = scanInputs.GetPrivateScopeColumns; + Func GetCSharpInsideTypeBody = scanInputs.GetCSharpInsideTypeBody; + Func GetCSharpCallableParameterScope = scanInputs.GetCSharpCallableParameterScope; + var getCSharpSwitchExpressionLines = scanInputs.GetCSharpSwitchExpressionLines; + var getCssQualifiedRuleAncestors = scanInputs.GetCssQualifiedRuleAncestors; var fsharpTypeBodyState = FSharpTypeBodyState.None; var initialSymbolCapacity = EstimateSymbolListInitialCapacity(lines.Length); var symbols = new SymbolExtractionList(initialSymbolCapacity); diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs index d32379ec6..b7b8504b1 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs @@ -4,6 +4,144 @@ namespace CodeIndex.Indexer; public static partial class SymbolExtractor { + private sealed class PatternScanInputs + { + private readonly string _lang; + private readonly string[] _lines; + private CSharpLexState[]? _csharpLineStartStates; + private DartClassBodyScope? _dartInsideClassBody; + private JavaScriptScopePrivacyFlags[][]? _privateScopeColumns; + private CSharpTypeBodyScope? _csharpInsideTypeBody; + private CSharpCallableParameterScope? _csharpCallableParameterScope; + private bool[]? _csharpSwitchExpressionLines; + private bool _csharpSwitchExpressionLinesInitialized; + private bool[]? _cssQualifiedRuleAncestors; + private string[]? _javaScriptTypeScriptSanitizedLines; + + public PatternScanInputs(string lang, string? filePath, string[] lines) + { + _lang = lang; + _lines = lines; + PythonModulePrefix = lang == "python" + ? GetPythonModulePrefix(filePath) + : null; + + var structuralMaskLanguage = lang == "cython" ? "python" : lang; + var structuralLines = StructuralLineMasker.MaskLines(structuralMaskLanguage, lines); + if (lang is "d" or "julia" or "matlab" or "nim") + structuralLines = ScientificNativeCommentMasker.MaskBlockComments(lang, structuralLines); + structuralLines = DynamicDeclarativeReferenceExtractor.MaskNonCodeLines( + lang, + structuralLines); + if (lang == "tcl") + { + structuralLines = DynamicDeclarativeReferenceExtractor.MaskTclContinuedCommentLines( + lines, + structuralLines); + structuralLines = DynamicDeclarativeReferenceExtractor.MaskTclNonScriptLines( + structuralLines); + } + + StructuralLines = structuralLines; + ScientificBodyScannerLines = lang is "julia" or "matlab" + ? PrepareScientificBodyScannerLines(structuralLines, lang) + : null; + MatlabExplicitOuterClosureByLine = lang == "matlab" && ScientificBodyScannerLines != null + ? BuildMatlabExplicitOuterClosureMap(ScientificBodyScannerLines) + : null; + CssScannerLines = lang == "css" + ? MaskCssScannerLines(lines) + : null; + SassStylusScannerLines = lang is "sass" or "stylus" + ? MaskSassStylusBlockCommentLines(lang, lines) + : null; + ShellScannerLines = lang == "shell" + ? MaskShellHeredocLines(lines) + : null; + if (lang is "prolog" or "ambiguous_pl") + { + PrologMultilineHeads = []; + PrologClauseContinuationLines = BuildPrologClauseContinuationLines( + structuralLines, + PrologMultilineHeads); + } + PowershellEnumBodyLines = lang == "powershell" + ? FindPowerShellEnumBodyLines(structuralLines) + : null; + + int[]?[] csharpMatchColumnToRaw = null!; + CSharpMatchLines = lang == "csharp" + ? BuildCSharpMatchLines(lines, out csharpMatchColumnToRaw) + : null; + CSharpMatchColumnToRaw = csharpMatchColumnToRaw; + GetCSharpLineStartStates = lang == "csharp" + ? BuildCSharpLineStartStates + : null; + GetPrivateScopeColumns = lang is "javascript" or "typescript" + ? BuildPrivateScopeColumns + : null; + GetCSharpSwitchExpressionLines = lang == "csharp" + ? BuildCSharpSwitchExpressionLines + : null; + GetCssQualifiedRuleAncestors = lang == "css" + ? BuildCssQualifiedRuleAncestors + : null; + } + + public string? PythonModulePrefix { get; } + public string[] StructuralLines { get; } + public string[]? ScientificBodyScannerLines { get; } + public bool[]? MatlabExplicitOuterClosureByLine { get; } + public string[]? CssScannerLines { get; } + public string[]? SassStylusScannerLines { get; } + public string[]? ShellScannerLines { get; } + public bool[]? PrologClauseContinuationLines { get; } + public Dictionary? PrologMultilineHeads { get; } + public bool[]? PowershellEnumBodyLines { get; } + public int[]?[] CSharpMatchColumnToRaw { get; } + public string[]? CSharpMatchLines { get; } + public Func? GetCSharpLineStartStates { get; } + public Func? GetPrivateScopeColumns { get; } + public Func? GetCSharpSwitchExpressionLines { get; } + public Func? GetCssQualifiedRuleAncestors { get; } + + public string[] GetJavaScriptTypeScriptSanitizedLines() => + _javaScriptTypeScriptSanitizedLines ??= BuildJavaScriptTypeScriptSanitizedLines(_lines); + + public DartClassBodyScope GetDartInsideClassBody() => + _dartInsideClassBody ??= BuildDartClassBodyScope(StructuralLines); + + public CSharpTypeBodyScope GetCSharpInsideTypeBody() => + _csharpInsideTypeBody ??= BuildCSharpTypeBodyScope(StructuralLines); + + public CSharpCallableParameterScope GetCSharpCallableParameterScope() => + _csharpCallableParameterScope ??= BuildCSharpCallableParameterScope( + StructuralLines, + GetCSharpInsideTypeBody()); + + private CSharpLexState[] BuildCSharpLineStartStates() => + _csharpLineStartStates ??= SymbolExtractor.BuildCSharpLineStartStates(_lines); + + private JavaScriptScopePrivacyFlags[][] BuildPrivateScopeColumns() => + _privateScopeColumns ??= BuildJavaScriptTypeScriptPrivateScopeColumns(_lines, _lang); + + private bool[]? BuildCSharpSwitchExpressionLines() + { + if (!_csharpSwitchExpressionLinesInitialized) + { + _csharpSwitchExpressionLinesInitialized = true; + _csharpSwitchExpressionLines = LinesContain(StructuralLines, "switch", StringComparison.Ordinal) + ? FindCSharpSwitchExpressionLines(StructuralLines) + : null; + } + + return _csharpSwitchExpressionLines; + } + + private bool[] BuildCssQualifiedRuleAncestors() => + _cssQualifiedRuleAncestors ??= FindCssQualifiedRuleAncestors(CssScannerLines!); + } + private static bool TryExtractSpecializedSymbols( long fileId, string? lang, From c81afa121abc4898e9f17f5d282611416a2b1b22 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 14:02:36 +0900 Subject: [PATCH 023/101] Extract symbol pattern line preparation --- .../Symbols/SymbolExtractor.ExtractCore.cs | 206 ++-------- .../SymbolExtractor.ExtractionPhases.cs | 369 ++++++++++++++++++ 2 files changed, 395 insertions(+), 180 deletions(-) diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs index 6a19194c1..776bdff77 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs @@ -122,10 +122,10 @@ private static List ExtractCore( Func GetCSharpCallableParameterScope = scanInputs.GetCSharpCallableParameterScope; var getCSharpSwitchExpressionLines = scanInputs.GetCSharpSwitchExpressionLines; var getCssQualifiedRuleAncestors = scanInputs.GetCssQualifiedRuleAncestors; - var fsharpTypeBodyState = FSharpTypeBodyState.None; var initialSymbolCapacity = EstimateSymbolListInitialCapacity(lines.Length); var symbols = new SymbolExtractionList(initialSymbolCapacity); var extractionState = symbols.ExtractionState; + var scanState = new PatternScanState(); List? pendingRecordPrimaryComponents = null; RecordPrimaryComponentParentIndex? recordPrimaryComponentParentIndex = null; var cssSeenSymbols = lang == "css" @@ -134,188 +134,34 @@ private static List ExtractCore( var dockerfileStageNames = lang == "dockerfile" ? new HashSet(StringComparer.Ordinal) : null; - var csharpSuppressedContinuationUntil = -1; - var csharpSuppressedContinuationResumeLine = -1; - var csharpSuppressedContinuationResumeRawColumn = 0; - var goImportBlock = false; - for (int i = 0; i < lines.Length; i++) { if ((i & 0x3f) == 0) cancellationToken.ThrowIfCancellationRequested(); - if (lang == "csharp" && i <= csharpSuppressedContinuationUntil) - continue; - - var line = lines[i]; - if (lang == "csharp" && IsCSharpLineCommentOnly(line)) - continue; - - if (lang == "go" - && TryHandleGoBlockLine(fileId, line, i, symbols, extractionState, ref goImportBlock)) - { - continue; - } - if (lang == "go") - TryAddGoLabelSymbol(fileId, line, i, symbols, extractionState); - if (lang == "r" && TryAddRPacmanPackageLoaderSymbols(fileId, line, i + 1, symbols, extractionState)) - continue; - - if (lang == "dockerfile") - { - AddDockerfileAdditionalSymbols(fileId, line, i + 1, symbols, dockerfileStageNames!); - } - - var structuralLine = structuralLines[i]; - var cssScannerLine = cssScannerLines?[i]; - var sassStylusScannerLine = sassStylusScannerLines?[i]; - var shellScannerLine = shellScannerLines?[i]; - var matchLine = structuralLine; - if (lang == "css" && cssScannerLine != null) - { - // Use raw CSS text for symbol-name matching so quoted selector payloads and - // @import values stay queryable, while brace/depth scans still rely on the - // separately masked scanner lines. - // CSS のシンボル名マッチは raw line を使い、引用付きセレクタや @import 値を - // 保持する。brace/depth 判定だけ別の scanner line を使う。 - matchLine = line; - } - else if (lang is "sass" or "stylus" && sassStylusScannerLine != null) - { - matchLine = sassStylusScannerLine; - } - else if (lang == "shell" && shellScannerLine != null) - { - matchLine = shellScannerLine; - } - else if (lang == "csharp") - { - matchLine = csharpMatchLines![i]; - } - - var fortranContinuationCandidate = lang == "fortran" - ? TryBuildFortranContinuationMatchLine(lines, i) - : null; - if (fortranContinuationCandidate != null) - matchLine = fortranContinuationCandidate.Value.MatchLine; - - if (lang == "fsharp") - TryAddFSharpTypeMemberSymbols(symbols, fileId, line, i + 1, ref fsharpTypeBodyState); - - if (lang == "fsharp" - && TryAddFSharpRecordFieldsFromContext(symbols, fileId, lines, i, line, i + 1)) - { - continue; - } - - if (lang == "fsharp" && TryAddFSharpActivePatternSymbols(symbols, fileId, line, i + 1)) - continue; - - if (lang == "fsharp" && TryAddFSharpOperatorSymbols(symbols, fileId, line, i + 1)) - continue; - - if (lang == "php") - ExtractPhpImportSymbols(symbols, line, i + 1); - - if (lang is "javascript" or "typescript") - { - if (line.IndexOf("import", StringComparison.Ordinal) >= 0 - || line.IndexOf("require", StringComparison.Ordinal) >= 0 - || line.IndexOf("URL", StringComparison.Ordinal) >= 0 - || line.IndexOf("importScripts", StringComparison.Ordinal) >= 0 - || line.IndexOf("serviceWorker", StringComparison.Ordinal) >= 0 - || line.IndexOf("register", StringComparison.Ordinal) >= 0 - || line.IndexOf("addModule", StringComparison.Ordinal) >= 0 - || line.IndexOf("Worker", StringComparison.Ordinal) >= 0) - { - var jsTsSanitizedLines = GetJavaScriptTypeScriptSanitizedLines(); - var sanitizedLine = jsTsSanitizedLines[i]; - if (sanitizedLine.IndexOf("import", StringComparison.Ordinal) >= 0) - { - ExtractJavaScriptTypeScriptDynamicImportSymbols(fileId, lang, filePath, projectRoot, lines, jsTsSanitizedLines, i, symbols); - ExtractJavaScriptTypeScriptStaticImportModuleSymbols(fileId, lang, filePath, projectRoot, lines, jsTsSanitizedLines, i, symbols); - ExtractJavaScriptTypeScriptImportMetaResolveModuleSymbols(fileId, lang, filePath, projectRoot, lines, jsTsSanitizedLines, i, symbols); - } - - if (sanitizedLine.IndexOf("require", StringComparison.Ordinal) >= 0) - ExtractJavaScriptTypeScriptRequireModuleSymbols(fileId, lang, filePath, projectRoot, lines, jsTsSanitizedLines, i, symbols); - if (sanitizedLine.IndexOf("URL", StringComparison.Ordinal) >= 0) - ExtractJavaScriptTypeScriptNewUrlModuleSymbols(fileId, lang, filePath, projectRoot, lines, jsTsSanitizedLines, i, symbols); - if (sanitizedLine.IndexOf("importScripts", StringComparison.Ordinal) >= 0) - ExtractJavaScriptTypeScriptImportScriptsModuleSymbols(fileId, lang, filePath, projectRoot, lines, jsTsSanitizedLines, i, symbols); - if (sanitizedLine.IndexOf("serviceWorker", StringComparison.Ordinal) >= 0 - || sanitizedLine.IndexOf("register", StringComparison.Ordinal) >= 0) - { - ExtractJavaScriptTypeScriptServiceWorkerRegisterModuleSymbols(fileId, lang, filePath, projectRoot, lines, jsTsSanitizedLines, i, symbols); - } - - if (sanitizedLine.IndexOf("addModule", StringComparison.Ordinal) >= 0) - ExtractJavaScriptTypeScriptWorkletAddModuleSymbols(fileId, lang, filePath, projectRoot, lines, jsTsSanitizedLines, i, symbols); - if (sanitizedLine.IndexOf("Worker", StringComparison.Ordinal) >= 0) - ExtractJavaScriptTypeScriptWorkerConstructorModuleSymbols(fileId, lang, filePath, projectRoot, lines, jsTsSanitizedLines, i, symbols); - } - } - - if (lang is "javascript" or "typescript" - && TryHandleJavaScriptTypeScriptImportEqualsLine(fileId, lang, filePath, projectRoot, line, i + 1, symbols)) + if (!TryPreparePatternLine( + fileId, + lang, + filePath, + projectRoot, + lines, + scanInputs, + scanState, + symbols, + extractionState, + dockerfileStageNames, + i, + out var preparedLine)) { continue; } - if (lang == "cpp" && TryAddCppIndentedAlias(fileId, line, i + 1, symbols)) - continue; - - // Batch `rem` / `@rem` / `::` comment lines contain the same `&` / `(` / `else` / - // `do` boundary tokens that the property regex now accepts for inline `set` - // capture, so `REM & set FAKE=1` or `:: else set FAKE=2` would otherwise leak a - // phantom property. Short-circuit those lines before any pattern fires — batch - // labels never match on `::` / `rem` lines anyway because the label regex - // requires `:`, not `::` or `r`. - // batch の `rem` / `@rem` / `::` コメント行は、inline `set` 捕捉のために property 正規表現が - // 受け付ける `&` / `(` / `else` / `do` の境界トークンを含みうるため、`REM & set FAKE=1` や - // `:: else set FAKE=2` が偽 property を出す恐れがある。パターン適用前に当該行ごと - // 早期スキップする — batch ラベル側は `::` / `rem` 行ではそもそも `:<名前文字>` の要件を - // 満たさないため影響を受けない。 - if (lang == "batch" && IsBatchCommentLine(line)) - continue; - - if (string.IsNullOrWhiteSpace(matchLine)) - continue; - - var patternStartOffset = lang is "javascript" or "typescript" - ? FindNextJavaScriptTypeScriptStatementStart(matchLine, 0) - : 0; - if (lang == "csharp" && patternStartOffset == 0) - { - var firstNonWhitespace = 0; - while (firstNonWhitespace < matchLine.Length && char.IsWhiteSpace(matchLine[firstNonWhitespace])) - firstNonWhitespace++; - - if (firstNonWhitespace < matchLine.Length - && matchLine[firstNonWhitespace] is '}' or ';' or '"') - patternStartOffset = FindNextSameLineNonClosingBraceStatementStart(matchLine, firstNonWhitespace + 1, lang); - } - if (lang == "csharp" && i == csharpSuppressedContinuationResumeLine) - { - patternStartOffset = Math.Max( - patternStartOffset, - TranslateCSharpRawColumnToCollapsed( - csharpMatchColumnToRaw, - i, - csharpSuppressedContinuationResumeRawColumn, - matchLine.Length, - line.Length)); - } - var prologContinuationResumeOffset = -1; - if (prologClauseContinuationLines?[i] == true) - { - var clauseTerminatorColumn = FindFirstTopLevelPrologClauseTerminator(matchLine); - if (clauseTerminatorColumn >= 0) - { - prologContinuationResumeOffset = clauseTerminatorColumn + 1; - patternStartOffset = Math.Max(patternStartOffset, prologContinuationResumeOffset); - } - } + var line = preparedLine.SourceLine; + var matchLine = preparedLine.MatchLine; + var cssScannerLine = preparedLine.CssScannerLine; + var fortranContinuationCandidate = preparedLine.FortranContinuationCandidate; + var patternStartOffset = preparedLine.PatternStartOffset; + var prologContinuationResumeOffset = preparedLine.PrologContinuationResumeOffset; while (patternStartOffset >= 0 && patternStartOffset < matchLine.Length) { var stopAfterFirstPatternMatch = false; @@ -1589,17 +1435,17 @@ private static List ExtractCore( // terminating semicolon so a valid same-line sibling remains visible. // 完全な continuation 行だけを抑止し、終端 semicolon の後から // 再開して有効な same-line sibling を維持する。 - csharpSuppressedContinuationUntil = Math.Max( - csharpSuppressedContinuationUntil, + scanState.CSharpSuppressedContinuationUntil = Math.Max( + scanState.CSharpSuppressedContinuationUntil, expressionEndLineIndex - 1); - csharpSuppressedContinuationResumeLine = expressionEndLineIndex; - csharpSuppressedContinuationResumeRawColumn = + scanState.CSharpSuppressedContinuationResumeLine = expressionEndLineIndex; + scanState.CSharpSuppressedContinuationResumeRawColumn = csharpPropertyCandidate.ExpressionBodyEndLineExclusiveEndColumn.Value; } else { - csharpSuppressedContinuationUntil = Math.Max( - csharpSuppressedContinuationUntil, + scanState.CSharpSuppressedContinuationUntil = Math.Max( + scanState.CSharpSuppressedContinuationUntil, expressionEndLineIndex); } } diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs index b7b8504b1..9ae81ea6a 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs @@ -142,6 +142,375 @@ private bool[] BuildCssQualifiedRuleAncestors() => _cssQualifiedRuleAncestors ??= FindCssQualifiedRuleAncestors(CssScannerLines!); } + private sealed class PatternScanState + { + public FSharpTypeBodyState FSharpTypeBodyState = FSharpTypeBodyState.None; + public bool GoImportBlock; + public int CSharpSuppressedContinuationUntil = -1; + public int CSharpSuppressedContinuationResumeLine = -1; + public int CSharpSuppressedContinuationResumeRawColumn; + } + + private readonly record struct PreparedPatternLine( + string SourceLine, + string MatchLine, + string? CssScannerLine, + FortranContinuationMatchCandidate? FortranContinuationCandidate, + int PatternStartOffset, + int PrologContinuationResumeOffset); + + private static bool TryPreparePatternLine( + long fileId, + string lang, + string? filePath, + string? projectRoot, + string[] lines, + PatternScanInputs scanInputs, + PatternScanState scanState, + SymbolExtractionList symbols, + SymbolExtractionState extractionState, + HashSet? dockerfileStageNames, + int lineIndex, + out PreparedPatternLine preparedLine) + { + preparedLine = default; + if (lang == "csharp" && lineIndex <= scanState.CSharpSuppressedContinuationUntil) + return false; + + var line = lines[lineIndex]; + if (lang == "csharp" && IsCSharpLineCommentOnly(line)) + return false; + + if (lang == "go" + && TryHandleGoBlockLine( + fileId, + line, + lineIndex, + symbols, + extractionState, + ref scanState.GoImportBlock)) + { + return false; + } + + if (lang == "go") + TryAddGoLabelSymbol(fileId, line, lineIndex, symbols, extractionState); + if (lang == "r" + && TryAddRPacmanPackageLoaderSymbols( + fileId, + line, + lineIndex + 1, + symbols, + extractionState)) + { + return false; + } + + if (lang == "dockerfile") + AddDockerfileAdditionalSymbols(fileId, line, lineIndex + 1, symbols, dockerfileStageNames!); + + var structuralLine = scanInputs.StructuralLines[lineIndex]; + var cssScannerLine = scanInputs.CssScannerLines?[lineIndex]; + var matchLine = structuralLine; + if (lang == "css" && cssScannerLine != null) + { + // Use raw CSS text for symbol-name matching so quoted selector payloads and + // @import values stay queryable, while brace/depth scans still rely on the + // separately masked scanner lines. + // CSS のシンボル名マッチは raw line を使い、引用付きセレクタや @import 値を + // 保持する。brace/depth 判定だけ別の scanner line を使う。 + matchLine = line; + } + else if (lang is "sass" or "stylus" && scanInputs.SassStylusScannerLines != null) + { + matchLine = scanInputs.SassStylusScannerLines[lineIndex]; + } + else if (lang == "shell" && scanInputs.ShellScannerLines != null) + { + matchLine = scanInputs.ShellScannerLines[lineIndex]; + } + else if (lang == "csharp") + { + matchLine = scanInputs.CSharpMatchLines![lineIndex]; + } + + var fortranContinuationCandidate = lang == "fortran" + ? TryBuildFortranContinuationMatchLine(lines, lineIndex) + : null; + if (fortranContinuationCandidate != null) + matchLine = fortranContinuationCandidate.Value.MatchLine; + + if (lang == "fsharp") + { + TryAddFSharpTypeMemberSymbols( + symbols, + fileId, + line, + lineIndex + 1, + ref scanState.FSharpTypeBodyState); + } + + if (lang == "fsharp" + && TryAddFSharpRecordFieldsFromContext( + symbols, + fileId, + lines, + lineIndex, + line, + lineIndex + 1)) + { + return false; + } + + if (lang == "fsharp" + && TryAddFSharpActivePatternSymbols(symbols, fileId, line, lineIndex + 1)) + { + return false; + } + + if (lang == "fsharp" + && TryAddFSharpOperatorSymbols(symbols, fileId, line, lineIndex + 1)) + { + return false; + } + + if (lang == "php") + ExtractPhpImportSymbols(symbols, line, lineIndex + 1); + + if (lang is "javascript" or "typescript") + { + AddJavaScriptTypeScriptModuleSymbolsForLine( + fileId, + lang, + filePath, + projectRoot, + lines, + scanInputs, + lineIndex, + symbols); + } + + if (lang is "javascript" or "typescript" + && TryHandleJavaScriptTypeScriptImportEqualsLine( + fileId, + lang, + filePath, + projectRoot, + line, + lineIndex + 1, + symbols)) + { + return false; + } + + if (lang == "cpp" && TryAddCppIndentedAlias(fileId, line, lineIndex + 1, symbols)) + return false; + + // Batch `rem` / `@rem` / `::` comment lines contain the same `&` / `(` / `else` / + // `do` boundary tokens that the property regex now accepts for inline `set` + // capture, so `REM & set FAKE=1` or `:: else set FAKE=2` would otherwise leak a + // phantom property. Short-circuit those lines before any pattern fires — batch + // labels never match on `::` / `rem` lines anyway because the label regex + // requires `:`, not `::` or `r`. + // batch の `rem` / `@rem` / `::` コメント行は、inline `set` 捕捉のために property 正規表現が + // 受け付ける `&` / `(` / `else` / `do` の境界トークンを含みうるため、`REM & set FAKE=1` や + // `:: else set FAKE=2` が偽 property を出す恐れがある。パターン適用前に当該行ごと + // 早期スキップする — batch ラベル側は `::` / `rem` 行ではそもそも `:<名前文字>` の要件を + // 満たさないため影響を受けない。 + if (lang == "batch" && IsBatchCommentLine(line)) + return false; + + if (string.IsNullOrWhiteSpace(matchLine)) + return false; + + var patternStartOffset = lang is "javascript" or "typescript" + ? FindNextJavaScriptTypeScriptStatementStart(matchLine, 0) + : 0; + if (lang == "csharp" && patternStartOffset == 0) + { + var firstNonWhitespace = 0; + while (firstNonWhitespace < matchLine.Length && char.IsWhiteSpace(matchLine[firstNonWhitespace])) + firstNonWhitespace++; + + if (firstNonWhitespace < matchLine.Length + && matchLine[firstNonWhitespace] is '}' or ';' or '"') + { + patternStartOffset = FindNextSameLineNonClosingBraceStatementStart( + matchLine, + firstNonWhitespace + 1, + lang); + } + } + + if (lang == "csharp" && lineIndex == scanState.CSharpSuppressedContinuationResumeLine) + { + patternStartOffset = Math.Max( + patternStartOffset, + TranslateCSharpRawColumnToCollapsed( + scanInputs.CSharpMatchColumnToRaw, + lineIndex, + scanState.CSharpSuppressedContinuationResumeRawColumn, + matchLine.Length, + line.Length)); + } + + var prologContinuationResumeOffset = -1; + if (scanInputs.PrologClauseContinuationLines?[lineIndex] == true) + { + var clauseTerminatorColumn = FindFirstTopLevelPrologClauseTerminator(matchLine); + if (clauseTerminatorColumn >= 0) + { + prologContinuationResumeOffset = clauseTerminatorColumn + 1; + patternStartOffset = Math.Max(patternStartOffset, prologContinuationResumeOffset); + } + } + + preparedLine = new PreparedPatternLine( + line, + matchLine, + cssScannerLine, + fortranContinuationCandidate, + patternStartOffset, + prologContinuationResumeOffset); + return true; + } + + private static void AddJavaScriptTypeScriptModuleSymbolsForLine( + long fileId, + string lang, + string? filePath, + string? projectRoot, + string[] lines, + PatternScanInputs scanInputs, + int lineIndex, + SymbolExtractionList symbols) + { + var line = lines[lineIndex]; + if (line.IndexOf("import", StringComparison.Ordinal) < 0 + && line.IndexOf("require", StringComparison.Ordinal) < 0 + && line.IndexOf("URL", StringComparison.Ordinal) < 0 + && line.IndexOf("importScripts", StringComparison.Ordinal) < 0 + && line.IndexOf("serviceWorker", StringComparison.Ordinal) < 0 + && line.IndexOf("register", StringComparison.Ordinal) < 0 + && line.IndexOf("addModule", StringComparison.Ordinal) < 0 + && line.IndexOf("Worker", StringComparison.Ordinal) < 0) + { + return; + } + + var sanitizedLines = scanInputs.GetJavaScriptTypeScriptSanitizedLines(); + var sanitizedLine = sanitizedLines[lineIndex]; + if (sanitizedLine.IndexOf("import", StringComparison.Ordinal) >= 0) + { + ExtractJavaScriptTypeScriptDynamicImportSymbols( + fileId, + lang, + filePath, + projectRoot, + lines, + sanitizedLines, + lineIndex, + symbols); + ExtractJavaScriptTypeScriptStaticImportModuleSymbols( + fileId, + lang, + filePath, + projectRoot, + lines, + sanitizedLines, + lineIndex, + symbols); + ExtractJavaScriptTypeScriptImportMetaResolveModuleSymbols( + fileId, + lang, + filePath, + projectRoot, + lines, + sanitizedLines, + lineIndex, + symbols); + } + + if (sanitizedLine.IndexOf("require", StringComparison.Ordinal) >= 0) + { + ExtractJavaScriptTypeScriptRequireModuleSymbols( + fileId, + lang, + filePath, + projectRoot, + lines, + sanitizedLines, + lineIndex, + symbols); + } + + if (sanitizedLine.IndexOf("URL", StringComparison.Ordinal) >= 0) + { + ExtractJavaScriptTypeScriptNewUrlModuleSymbols( + fileId, + lang, + filePath, + projectRoot, + lines, + sanitizedLines, + lineIndex, + symbols); + } + + if (sanitizedLine.IndexOf("importScripts", StringComparison.Ordinal) >= 0) + { + ExtractJavaScriptTypeScriptImportScriptsModuleSymbols( + fileId, + lang, + filePath, + projectRoot, + lines, + sanitizedLines, + lineIndex, + symbols); + } + + if (sanitizedLine.IndexOf("serviceWorker", StringComparison.Ordinal) >= 0 + || sanitizedLine.IndexOf("register", StringComparison.Ordinal) >= 0) + { + ExtractJavaScriptTypeScriptServiceWorkerRegisterModuleSymbols( + fileId, + lang, + filePath, + projectRoot, + lines, + sanitizedLines, + lineIndex, + symbols); + } + + if (sanitizedLine.IndexOf("addModule", StringComparison.Ordinal) >= 0) + { + ExtractJavaScriptTypeScriptWorkletAddModuleSymbols( + fileId, + lang, + filePath, + projectRoot, + lines, + sanitizedLines, + lineIndex, + symbols); + } + + if (sanitizedLine.IndexOf("Worker", StringComparison.Ordinal) >= 0) + { + ExtractJavaScriptTypeScriptWorkerConstructorModuleSymbols( + fileId, + lang, + filePath, + projectRoot, + lines, + sanitizedLines, + lineIndex, + symbols); + } + } + private static bool TryExtractSpecializedSymbols( long fileId, string? lang, From 0b5d7fb8c76352d42cab30d68ed5d8f9bbe33885 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 14:14:56 +0900 Subject: [PATCH 024/101] Split MCP query handlers by tool family --- .../Mcp/McpToolHandlers.Graph.Dependencies.cs | 167 + src/CodeIndex/Mcp/McpToolHandlers.Graph.cs | 794 +++++ .../Mcp/McpToolHandlers.Query.Batch.cs | 322 ++ .../Mcp/McpToolHandlers.Query.Search.cs | 460 +++ .../Mcp/McpToolHandlers.Query.Source.cs | 407 +++ .../Mcp/McpToolHandlers.Query.Status.cs | 674 ++++ .../Mcp/McpToolHandlers.Query.Symbols.cs | 288 ++ .../Mcp/McpToolHandlers.QueryTools.cs | 2997 ----------------- 8 files changed, 3112 insertions(+), 2997 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Graph.cs create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Query.Batch.cs create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs delete mode 100644 src/CodeIndex/Mcp/McpToolHandlers.QueryTools.cs diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs b/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs new file mode 100644 index 000000000..ae5975966 --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.Dependencies.cs @@ -0,0 +1,167 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + + private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) + { + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultImpactLimit, adjustments); + var requestedGraphBudget = ReadOptionalIntArgument(args, "graphBudget"); + var graphBudget = Math.Clamp( + requestedGraphBudget ?? QueryCommandRunner.DefaultDependencyCycleGraphBudget, + 1, + QueryCommandRunner.MaxDependencyCycleGraphBudget); + if (requestedGraphBudget.HasValue && requestedGraphBudget.Value != graphBudget) + adjustments.AddClamped("graphBudget", requestedGraphBudget.Value, graphBudget, 1, QueryCommandRunner.MaxDependencyCycleGraphBudget); + var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); + var pathPatterns = ReadScopedPathList(args); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + var includeGenerated = args?["includeGenerated"]?.GetValue() ?? false; + var reverse = args?["reverse"]?.GetValue() ?? false; + var cyclesOnly = args?["cycles"]?.GetValue() ?? false; + var format = args?["format"]?.GetValue()?.ToLowerInvariant() ?? "edgelist"; + var cursorValue = args?["cursor"]?.GetValue(); + if (requestedGraphBudget.HasValue && !cyclesOnly) + return CreateToolErrorResponse(id, "'graphBudget' requires 'cycles=true'."); + if (cursorValue != null && !cyclesOnly) + return CreateToolErrorResponse(id, "'cursor' requires 'cycles=true'."); + if (cursorValue != null && !QueryCommandRunner.TryParseDependencyCycleCursor(cursorValue, out _)) + return CreateToolErrorResponse(id, "'cursor' must be an opaque dependency-cycle next_cursor returned by deps."); + + var cursorOptions = new QueryCommandOptions + { + Lang = lang, + PathPatterns = pathPatterns?.ToList() ?? [], + ExcludePaths = excludePaths, + ExcludeTests = excludeTests, + IncludeGenerated = includeGenerated, + DependencyCycleGraphBudget = graphBudget, + }; + var cursorBaseFingerprint = QueryCommandRunner.BuildDependencyCycleCursorFingerprint(cursorOptions, reverse); + var cursor = cursorValue == null + ? (DependencyCycleCursor?)null + : QueryCommandRunner.TryParseDependencyCycleCursor(cursorValue, out var parsedCursor) + ? parsedCursor + : null; + var pageOffset = cursor?.Offset ?? 0; + + return WithDbReader(id, args, reader => + { + var cycleCandidateRowCount = 0; + var results = cyclesOnly + ? reader.GetFileDependencyCycleCandidates( + checked(graphBudget + 1), + out cycleCandidateRowCount, + lang, + pathPatterns, + excludePaths, + excludeTests, + reverse, + reader.Cancellation) + : reader.GetFileDependencies(limit, lang, pathPatterns, excludePaths, excludeTests, reverse); + var cycleCandidates = cyclesOnly ? results.Take(graphBudget).ToList() : results; + var cursorFingerprint = QueryCommandRunner.BuildDependencyCycleGraphFingerprint( + cursorBaseFingerprint, + cycleCandidates, + cycleCandidateRowCount); + if (cursor is { } suppliedCursor + && !string.Equals(suppliedCursor.Fingerprint, cursorFingerprint, StringComparison.Ordinal)) + return CreateToolErrorResponse(id, "'cursor' does not match the current deps filters, graphBudget, or indexed graph."); + var baseSqlGraphSignal = reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests); + var cycleAnalysis = cyclesOnly + ? QueryCommandRunner.AnalyzeDependencyCycles( + cycleCandidates, + graphBudget, + cycleCandidateRowCount, + limit, + pageOffset, + cursorFingerprint, + reader.Cancellation) + : null; + if (cursor.HasValue && cycleAnalysis != null && pageOffset >= cycleAnalysis.TotalCycleCount) + return CreateToolErrorResponse(id, "'cursor' points beyond the available dependency-cycle result set."); + var cycles = cycleAnalysis?.Cycles ?? []; + var outputEdges = cycleAnalysis?.Edges ?? results; + var sqlGraphSignalPaths = cyclesOnly + ? cycles.Count > 0 + ? cycles.SelectMany(static cycle => cycle) + : cycleCandidates.SelectMany(static result => new[] { result.SourcePath, result.TargetPath }) + : results.SelectMany(static result => new[] { result.SourcePath, result.TargetPath }); + var sqlGraphSignal = results.Count == 0 + ? baseSqlGraphSignal + : QueryCommandRunner.NarrowSqlGraphContractSignalByPaths( + reader, + baseSqlGraphSignal, + sqlGraphSignalPaths, + lang); + var payload = new JsonObject { ["count"] = cyclesOnly ? cycles.Count : results.Count }; + if (cyclesOnly) + payload["cycles"] = QueryCommandRunner.BuildDependencyCyclesJson(cycleAnalysis!.Components, cycleAnalysis.PageOffset); + else if (format == "json-graph") + payload["graph"] = BuildJsonGraphPayload(outputEdges); + else + payload["edges"] = JsonSerializer.SerializeToNode(outputEdges, _jsonOptions); + if (cyclesOnly) + QueryCommandRunner.AddDependencyCycleAnalysisJsonFields(payload, cycleAnalysis!, mcpArguments: true); + payload["format"] = format; + payload["includeGenerated"] = includeGenerated; + payload["generated_code_filter_supported"] = true; + payload["generated_code_scope"] = "source_and_target_files"; + AddSqlGraphContractSignal(payload, sqlGraphSignal); + AddReferenceGraphCompletenessSignal( + payload, + reader, + lang, + pathPatterns, + excludePaths, + excludeTests); + var summary = payload["count"]!.GetValue() > 0 + ? cyclesOnly ? $"Found {ConsoleUi.Counted(cycles.Count, "dependency cycle")}." : $"Found {ConsoleUi.Counted(results.Count, "dependency edge")}." + : cyclesOnly ? "No dependency cycles found." : "No file dependencies found."; + if (results.Count == 0) + AddFreshnessHint(payload, reader); + adjustments.ApplyTo(payload); + return CreateToolResult(id, summary, payload); + }); + } + + private static JsonObject BuildJsonGraphPayload(IReadOnlyList edges) + { + var nodes = new JsonArray(); + var seenNodes = new HashSet(StringComparer.Ordinal); + var graphEdges = new JsonArray(); + foreach (var edge in edges) + { + if (seenNodes.Add(edge.SourcePath)) + nodes.Add(new JsonObject { ["id"] = edge.SourcePath }); + if (seenNodes.Add(edge.TargetPath)) + nodes.Add(new JsonObject { ["id"] = edge.TargetPath }); + + graphEdges.Add(new JsonObject + { + ["source"] = edge.SourcePath, + ["target"] = edge.TargetPath, + ["reference_count"] = edge.ReferenceCount, + ["ranking_score"] = edge.RankingScore, + }); + } + + return new JsonObject { ["nodes"] = nodes, ["edges"] = graphEdges }; + } + +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs new file mode 100644 index 000000000..6d5ea5ac5 --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Graph.cs @@ -0,0 +1,794 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + + private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) + { + if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) + return CreateToolErrorResponse(id, requiredError!); + if (query.Length > QueryLimits.MaxQueryLength) + return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); + if (IsBareVerbatimQueryToken(query)) + return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); + + var adjustments = new ArgumentAdjustmentCollector(); + var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); + var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + if (!TryReadLspCompatibleArgument(args, out var lspCompatible, out var lspCompatibleError)) + return CreateToolErrorResponse(id, lspCompatibleError!); + var offset = ReadOffset(args, adjustments); + if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) + return maxLineWidthError; + var pathPatterns = ReadScopedPathList(args); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + var format = ReadResponseFormat(args); + if (ValidateResponseFormat(format) is string formatError) + return CreateToolErrorResponse(id, formatError); + var countOnly = ReadCountOnly(args) || format == "count"; + if (!TryResolveNameExactArgument(args, "references", out var exact, out var exactError)) + return CreateToolErrorResponse(id, exactError!); + + return WithDbReader(id, args, reader => + { + if (countOnly) + { + var countOnlyTotal = reader.CountSearchReferencesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count; + var histogramResults = countOnlyTotal > 0 + ? reader.SearchReferences(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth) + : []; + var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); + countOnlyPayload["query"] = query; + countOnlyPayload["kind"] = kind; + countOnlyPayload["lang"] = lang; + countOnlyPayload["path"] = PathEcho(pathPatterns); + countOnlyPayload["excludeTests"] = excludeTests; + AddHdlGraphContractSignal( + countOnlyPayload, + reader.GetHdlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests)); + adjustments.ApplyTo(countOnlyPayload); + return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "reference")}.", countOnlyPayload); + } + + var results = reader.SearchReferences(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth, offset: offset); + var truncated = TrimToRequestedLimit(results, limit); + var total = truncated || offset > 0 + ? reader.CountSearchReferencesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count + : results.Count; + if (lspCompatible) + QueryCommandRunner.AttachLspLocations(results); + var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); + var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( + reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), + results.Select(result => result.Lang), + lang, + graphSupport.GraphLanguage); + var exactSignal = reader.GetReferencesExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); + var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( + exact && reader._hasReferencesTable, + () => reader.CountSearchReferences(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false) > 0, + () => reader.CountSearchReferences(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false), + () => reader.SearchReferences(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false), + r => r.SymbolName); + var payload = new JsonObject + { + ["query"] = query, + ["kind"] = kind, + ["lang"] = lang, + ["lspCompatible"] = lspCompatible, + ["maxLineWidth"] = maxLineWidth, + ["path"] = PathEcho(pathPatterns), + ["excludeTests"] = excludeTests, + ["graph_language"] = graphSupport.GraphLanguage, + ["graph_supported"] = graphSupport.GraphSupported, + ["graph_support_reason"] = graphSupport.GraphSupportReason, + ["results"] = ToJsonArray(results) + }; + AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); + if (format == "compact") + ApplyCompactResults(payload, results, result => result.Path, result => result.Line, result => result.Column); + if (exact) + AddExactGraphSignal(payload, exactSignal); + AddSqlGraphContractSignal(payload, sqlGraphSignal); + AddHdlGraphContractSignal( + payload, + reader.GetHdlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests)); + if (results.Count == 0) + { + AddExactZeroHint(payload, exactZeroHint); + AddSymbolRecoveryHint(payload, query, "references", lang, kind, PathEcho(pathPatterns)); + AddFreshnessHint(payload, reader); + } + else + { + var topReference = results[0]; + AddNextStepSuggestion( + payload, + "excerpt", + BuildExcerptArgs(topReference.Path, topReference.Line, topReference.Line), + "Use excerpt on representative usage sites before editing; use callers or callees when you need call graph impact."); + } + adjustments.ApplyTo(payload); + return CreateToolResult(id, + BuildGraphSummary("reference", "references", results.Count, graphSupport.GraphLanguage, graphSupport.GraphSupported, graphSupport.GraphSupportReason), + payload); + }); + } + + private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) + { + if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) + return CreateToolErrorResponse(id, requiredError!); + if (query.Length > QueryLimits.MaxQueryLength) + return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); + if (IsBareVerbatimQueryToken(query)) + return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); + + var adjustments = new ArgumentAdjustmentCollector(); + var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); + if (IsNonCallGraphReferenceKind(kind)) + return CreateToolErrorResponse(id, BuildNonCallGraphKindRejectionMessage("callers", kind!)); + var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + var offset = ReadOffset(args, adjustments); + var pathPatterns = ReadScopedPathList(args); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + if (!TryResolveNameExactArgument(args, "callers", out var exact, out var exactError)) + return CreateToolErrorResponse(id, exactError!); + if (!TryReadReferenceRankMode(args, out var rankMode, out var rankModeError)) + return CreateToolErrorResponse(id, rankModeError!); + var format = ReadResponseFormat(args); + if (ValidateResponseFormat(format) is string formatError) + return CreateToolErrorResponse(id, formatError); + var countOnly = ReadCountOnly(args) || format == "count"; + var rawKinds = args?["rawKinds"]?.GetValue() ?? false; + + return WithDbReader(id, args, reader => + { + if (countOnly) + { + var countOnlyTotal = reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count; + var histogramResults = countOnlyTotal > 0 + ? reader.GetCallers(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode) + : []; + var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); + countOnlyPayload["query"] = query; + countOnlyPayload["kind"] = kind; + countOnlyPayload["rawKinds"] = rawKinds; + countOnlyPayload["lang"] = lang; + countOnlyPayload["path"] = PathEcho(pathPatterns); + countOnlyPayload["excludeTests"] = excludeTests; + AddReferenceGraphCompletenessSignal( + countOnlyPayload, + reader, + lang, + pathPatterns, + excludePaths, + excludeTests); + adjustments.ApplyTo(countOnlyPayload); + return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "caller")}.", countOnlyPayload); + } + + var results = reader.GetCallers(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, offset: offset); + var truncated = TrimToRequestedLimit(results, limit); + var total = truncated || offset > 0 + ? reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count + : results.Count; + var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); + var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( + reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), + results.Select(result => result.Lang), + lang, + graphSupport.GraphLanguage); + var exactSignal = reader.GetCallersExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); + var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( + exact && reader._hasReferencesTable, + () => reader.CountCallers(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds) > 0, + () => reader.CountCallers(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds), + () => reader.GetCallers(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, rankMode: rankMode), + r => r.CalleeName); + var payload = new JsonObject + { + ["query"] = query, + ["kind"] = kind, + ["rawKinds"] = rawKinds, + ["lang"] = lang, + ["path"] = PathEcho(pathPatterns), + ["excludeTests"] = excludeTests, + ["rankBy"] = QueryCommandRunner.FormatReferenceRankMode(rankMode), + ["graph_language"] = graphSupport.GraphLanguage, + ["graph_supported"] = graphSupport.GraphSupported, + ["graph_support_reason"] = graphSupport.GraphSupportReason, + ["results"] = ToJsonArray(results) + }; + AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); + if (format == "compact") + ApplyCompactResults(payload, results, result => result.Path, result => result.FirstLine); + payload["aggregate_truncated"] = results.Any(result => result.AggregateTruncated); + if (exact) + AddExactGraphSignal(payload, exactSignal); + AddSqlGraphContractSignal(payload, sqlGraphSignal); + AddReferenceGraphCompletenessSignal( + payload, + reader, + lang, + pathPatterns, + excludePaths, + excludeTests); + if (results.Count == 0) + { + AddExactZeroHint(payload, exactZeroHint); + AddSymbolRecoveryHint(payload, query, "callers", lang, kind, PathEcho(pathPatterns)); + AddFreshnessHint(payload, reader); + } + else + { + var topCaller = results[0]; + AddNextStepSuggestion( + payload, + "excerpt", + BuildExcerptArgs(topCaller.Path, topCaller.FirstLine, topCaller.FirstLine), + "Use excerpt on a caller row to understand the concrete call site before widening impact analysis or editing."); + } + adjustments.ApplyTo(payload); + return CreateToolResult(id, + BuildGraphSummary("caller", "callers", results.Count, graphSupport.GraphLanguage, graphSupport.GraphSupported, graphSupport.GraphSupportReason), + payload); + }); + } + + private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) + { + if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) + return CreateToolErrorResponse(id, requiredError!); + if (query.Length > QueryLimits.MaxQueryLength) + return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); + if (IsBareVerbatimQueryToken(query)) + return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); + + var adjustments = new ArgumentAdjustmentCollector(); + var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); + if (IsNonCallGraphReferenceKind(kind)) + return CreateToolErrorResponse(id, BuildNonCallGraphKindRejectionMessage("callees", kind!)); + var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + var offset = ReadOffset(args, adjustments); + var pathPatterns = ReadScopedPathList(args); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + if (!TryResolveNameExactArgument(args, "callees", out var exact, out var exactError)) + return CreateToolErrorResponse(id, exactError!); + if (!TryReadReferenceRankMode(args, out var rankMode, out var rankModeError)) + return CreateToolErrorResponse(id, rankModeError!); + var format = ReadResponseFormat(args); + if (ValidateResponseFormat(format) is string formatError) + return CreateToolErrorResponse(id, formatError); + var countOnly = ReadCountOnly(args) || format == "count"; + var rawKinds = args?["rawKinds"]?.GetValue() ?? false; + + return WithDbReader(id, args, reader => + { + if (countOnly) + { + var countOnlyTotal = reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count; + var histogramResults = countOnlyTotal > 0 + ? reader.GetCallees(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode) + : []; + var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); + countOnlyPayload["query"] = query; + countOnlyPayload["kind"] = kind; + countOnlyPayload["rawKinds"] = rawKinds; + countOnlyPayload["lang"] = lang; + countOnlyPayload["path"] = PathEcho(pathPatterns); + countOnlyPayload["excludeTests"] = excludeTests; + AddReferenceGraphCompletenessSignal( + countOnlyPayload, + reader, + lang, + pathPatterns, + excludePaths, + excludeTests); + adjustments.ApplyTo(countOnlyPayload); + return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "callee")}.", countOnlyPayload); + } + + var results = reader.GetCallees(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, offset: offset); + var truncated = TrimToRequestedLimit(results, limit); + var total = truncated || offset > 0 + ? reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count + : results.Count; + var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); + var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( + reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), + results.Select(result => result.Lang), + lang, + graphSupport.GraphLanguage); + var exactSignal = reader.GetCalleesExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); + var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( + exact && reader._hasReferencesTable, + () => reader.CountCallees(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds) > 0, + () => reader.CountCallees(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds), + () => reader.GetCallees(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, rankMode: rankMode), + r => r.CallerName); + var payload = new JsonObject + { + ["query"] = query, + ["kind"] = kind, + ["rawKinds"] = rawKinds, + ["lang"] = lang, + ["path"] = PathEcho(pathPatterns), + ["excludeTests"] = excludeTests, + ["rankBy"] = QueryCommandRunner.FormatReferenceRankMode(rankMode), + ["graph_language"] = graphSupport.GraphLanguage, + ["graph_supported"] = graphSupport.GraphSupported, + ["graph_support_reason"] = graphSupport.GraphSupportReason, + ["results"] = ToJsonArray(results) + }; + AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); + if (format == "compact") + ApplyCompactResults(payload, results, result => result.Path, result => result.FirstLine); + payload["aggregate_truncated"] = results.Any(result => result.AggregateTruncated); + if (exact) + AddExactGraphSignal(payload, exactSignal); + AddSqlGraphContractSignal(payload, sqlGraphSignal); + AddReferenceGraphCompletenessSignal( + payload, + reader, + lang, + pathPatterns, + excludePaths, + excludeTests); + if (results.Count == 0) + { + AddExactZeroHint(payload, exactZeroHint); + AddSymbolRecoveryHint(payload, query, "callees", lang, kind, PathEcho(pathPatterns)); + AddFreshnessHint(payload, reader); + } + else + { + var topCallee = results[0]; + AddNextStepSuggestion( + payload, + "excerpt", + BuildExcerptArgs(topCallee.Path, topCallee.FirstLine, topCallee.FirstLine), + "Use excerpt on a callee row to inspect the concrete dependency before changing the caller or callee."); + } + adjustments.ApplyTo(payload); + return CreateToolResult(id, + BuildGraphSummary("callee", "callees", results.Count, graphSupport.GraphLanguage, graphSupport.GraphSupported, graphSupport.GraphSupportReason), + payload); + }); + } + + private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) + { + var query = args?["query"]?.GetValue(); + if (query != null && query.Length > QueryLimits.MaxQueryLength) + return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); + var adjustments = new ArgumentAdjustmentCollector(); + var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + var pathPatterns = ReadScopedPathList(args); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + if (!TryReadSinceArgument(args, out var since, out var sinceError)) + return CreateToolErrorResponse(id, sinceError!); + var orderBySize = args?["orderBySize"]?.GetValue() ?? false; + var rawBytes = args?["rawBytes"]?.GetValue() ?? false; + + return WithDbReader(id, args, reader => + { + var results = reader.ListFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, since, orderBySize || rawBytes); + if (results.Count == 0) + { + var payload = new JsonObject + { + ["query"] = query, + ["lang"] = lang, + ["path"] = PathEcho(pathPatterns), + ["excludeTests"] = excludeTests, + ["orderBySize"] = orderBySize, + ["rawBytes"] = rawBytes, + ["count"] = 0, + ["results"] = new JsonArray() + }; + if (rawBytes) + { + payload["raw_bytes_payload_supported"] = false; + payload["raw_bytes_note"] = "MCP returns indexed file size metadata; raw file bytes are not returned."; + } + AddFreshnessHint(payload, reader); + adjustments.ApplyTo(payload); + return CreateToolResult(id, "No files found.", payload); + } + + var structured = new JsonObject + { + ["query"] = query, + ["lang"] = lang, + ["path"] = PathEcho(pathPatterns), + ["excludeTests"] = excludeTests, + ["orderBySize"] = orderBySize, + ["rawBytes"] = rawBytes, + ["count"] = results.Count, + ["results"] = JsonSerializer.SerializeToNode(results, _jsonOptions) + }; + if (rawBytes) + { + structured["raw_bytes_payload_supported"] = false; + structured["raw_bytes_note"] = "MCP returns indexed file size metadata; raw file bytes are not returned."; + } + adjustments.ApplyTo(structured); + return CreateToolResult(id, ConsoleUi.FoundSummary(results.Count, "file"), structured); + }); + } + + private JsonNode ExecuteMap(JsonNode? id, JsonNode? args) + { + var adjustments = new ArgumentAdjustmentCollector(); + var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultMapLimit, adjustments); + var pathPatterns = ReadScopedPathList(args); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + var sections = ReadStringList(args, "sections").Select(section => section.ToLowerInvariant()).ToHashSet(StringComparer.Ordinal); + var depth = ReadMapDepth(args, adjustments); + var minEntrypointConfidence = args?["minEntrypointConfidence"]?.GetValue() ?? 0; + if (minEntrypointConfidence is < 0 or > 1) + return CreateToolErrorResponse(id, "minEntrypointConfidence must be between 0.0 and 1.0"); + + return WithDbReader(id, args, reader => + { + var map = reader.GetRepoMap( + limit, + lang, + pathPatterns, + excludePaths, + excludeTests, + minEntrypointConfidence, + moduleDepth: depth); + WorkspaceMetadataEnricher.Enrich(map, _dbPath, _dbPathExplicit); + var structured = JsonSerializer.SerializeToNode(map, _jsonOptions)!.AsObject(); + if (depth is >= 0) + structured["depth"] = depth.Value; + if (sections.Count > 0) + ApplyMapSectionFilter(structured, sections); + structured["limit"] = limit; + structured["lang"] = lang; + structured["path"] = PathEcho(pathPatterns); + structured["excludeTests"] = excludeTests; + structured["minEntrypointConfidence"] = minEntrypointConfidence; + var hasFilter = (pathPatterns is { Count: > 0 }) || excludePaths.Count > 0 || excludeTests || lang != null; + if (map.FileCount == 0 && hasFilter) + AddFreshnessHint(structured, reader); + adjustments.ApplyTo(structured); + var summary = map.FileCount > 0 + ? "Repo map returned." + : hasFilter ? "No files found matching the given filters." : "Repo map returned."; + return CreateToolResult(id, summary, structured); + }); + } + + private static void ApplyMapSectionFilter(JsonObject structured, IReadOnlySet sections) + { + var keep = new HashSet(StringComparer.Ordinal) + { + "api_version", "fileCount", "totalLines", "totalSymbols", "totalReferences", + "indexedAt", "latestModified", "workspaceIndexedAt", "workspaceLatestModified", + "projectRoot", "gitHead", "gitIsDirty", "indexed_head_commit", "indexed_head_sha", + "indexed_head_branch", "indexed_head_timestamp", "commits_ahead_of_indexed_head", + "worktree_head_changed", "head_freshness", + "graphTableAvailable", "limit", "lang", "path", "excludeTests", "depth", "minEntrypointConfidence", + }; + foreach (var section in sections) + AddMapSectionStructuredProperties(keep, section); + foreach (var key in structured.Select(property => property.Key).Where(key => !keep.Contains(key)).ToList()) + structured.Remove(key); + structured["sections"] = new JsonArray(sections.Select(section => JsonValue.Create(section)).ToArray()); + structured["sectionProperties"] = BuildMapSectionStructuredProperties(sections); + } + + private static readonly IReadOnlyDictionary MapSectionStructuredProperties = new Dictionary(StringComparer.Ordinal) + { + ["languages"] = ["languages"], + ["tree"] = ["modules"], + ["modules"] = ["modules"], + ["hotspots"] = ["topFiles", "symbolRichFiles", "referenceRichFiles", "entrypoints"], + ["metrics"] = ["largestFiles"], + }; + + private static void AddMapSectionStructuredProperties(HashSet keep, string section) + { + if (!MapSectionStructuredProperties.TryGetValue(section, out var properties)) + return; + + foreach (var property in properties) + keep.Add(property); + } + + private static JsonObject BuildMapSectionStructuredProperties(IReadOnlySet sections) + { + var payload = new JsonObject(); + foreach (var section in sections) + { + if (!MapSectionStructuredProperties.TryGetValue(section, out var properties)) + continue; + + payload[section] = new JsonArray(properties.Select(property => JsonValue.Create(property)).ToArray()); + } + + return payload; + } + + private JsonNode ExecuteAnalyzeSymbol(JsonNode? id, JsonNode? args) + { + if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) + return CreateToolErrorResponse(id, requiredError!); + if (query.Length > QueryLimits.MaxQueryLength) + return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); + if (IsBareVerbatimQueryToken(query)) + return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); + + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultMapLimit, adjustments); + var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); + var includeBody = args?["includeBody"]?.GetValue() ?? false; + if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) + return maxLineWidthError; + var pathPatterns = ReadScopedPathList(args); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + if (!TryResolveNameExactArgument(args, "analyze_symbol", out var exact, out var exactError)) + return CreateToolErrorResponse(id, exactError!); + var format = ReadResponseFormat(args); + if (ValidateResponseFormat(format) is string formatError) + return CreateToolErrorResponse(id, formatError); + var countOnly = ReadCountOnly(args) || format == "count"; + + return WithDbReader(id, args, reader => + { + var analysis = reader.AnalyzeSymbol(query, limit, lang, includeBody, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth); + var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignal( + reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), + DbReader.IsSqlLanguage(lang) + || DbReader.IsSqlLanguage(analysis.GraphLanguage) + || DbReader.IsSqlLanguage(analysis.File?.Lang) + || DbReader.ContainsSqlLanguage(analysis.Definitions.Select(definition => definition.Lang)) + || DbReader.ContainsSqlLanguage(analysis.References.Select(reference => reference.Lang)) + || DbReader.ContainsSqlLanguage(analysis.Callers.Select(caller => caller.Lang)) + || DbReader.ContainsSqlLanguage(analysis.Callees.Select(callee => callee.Lang))); + analysis.SqlGraphContractReady = sqlGraphSignal.Relevant ? sqlGraphSignal.Ready : null; + analysis.SqlGraphContractDegradedReason = sqlGraphSignal.Relevant ? sqlGraphSignal.DegradedReason : null; + WorkspaceMetadataEnricher.Enrich(analysis, _dbPath, _dbPathExplicit); + ApplyExcerptRecoveryDbPath(analysis.Definitions); + ApplyExcerptRecoveryDbPath(analysis.References); + ApplyExcerptRecoveryDbPath(analysis.Callers); + ApplyExcerptRecoveryDbPath(analysis.Callees); + var pathEcho = PathEcho(pathPatterns); + var structured = countOnly + ? BuildAnalyzeSymbolCountPayload(analysis, lang, pathEcho, excludeTests, maxLineWidth) + : format == "compact" + ? BuildAnalyzeSymbolCompactPayload(analysis, lang, pathEcho, excludeTests, maxLineWidth) + : ToAnalyzeSymbolJsonObject(analysis); + AddSqlGraphContractSignal(structured, sqlGraphSignal); + AddHdlGraphContractSignal( + structured, + reader.GetHdlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests)); + structured.Remove("exactZeroHint"); + AddExactZeroHint(structured, analysis.ExactZeroHint); + structured["maxLineWidth"] = maxLineWidth; + structured["lang"] = lang; + structured["path"] = pathEcho; + structured["excludeTests"] = excludeTests; + adjustments.ApplyTo(structured); + return CreateToolResult(id, BuildAnalyzeSymbolSummary(analysis), structured); + }); + } + + private static string BuildAnalyzeSymbolSummary(SymbolAnalysisResult analysis) + { + if (analysis.ExactZeroHint != null) + { + var relaxedCount = analysis.ExactZeroHint.RelaxedCount ?? analysis.ExactZeroHint.SampleNames.Count; + return $"Symbol analysis returned. Substring would return {ConsoleUi.Counted(relaxedCount, "similarly named symbol")}."; + } + + return "Symbol analysis returned."; + } + + private static void AddExactGraphSignal(JsonObject payload, ExactQuerySignal signal) + { + payload["exact_index_available"] = signal.ExactIndexAvailable; + if (signal.DegradedReason != null) + payload["degraded_reason"] = signal.DegradedReason; + // MCP uses snake_case response keys consistently; do not add camelCase aliases here. + } + + private static void AddSqlGraphContractSignal(JsonObject payload, SqlGraphContractSignal signal) + { + if (!signal.Relevant) + return; + + payload["sql_graph_contract_ready"] = signal.Ready; + if (!signal.Ready) + { + payload["degraded"] = true; + if (signal.DegradedReason != null) + { + payload["sql_graph_contract_degraded_reason"] = signal.DegradedReason; + } + } + } + + private static void AddHdlGraphContractSignal(JsonObject payload, HdlGraphContractSignal signal) + { + if (!signal.Relevant) + return; + + payload["hdl_graph_contract_ready"] = signal.Ready; + if (!signal.Ready) + { + payload["degraded"] = true; + if (signal.DegradedReason != null) + payload["hdl_graph_contract_degraded_reason"] = signal.DegradedReason; + } + } + + private void AddReferenceGraphCompletenessSignal( + JsonObject payload, + DbReader reader, + string? lang = null, + IReadOnlyList? pathPatterns = null, + IReadOnlyList? excludePaths = null, + bool excludeTests = false) + { + AddHdlGraphContractSignal( + payload, + reader.GetHdlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests)); + AddReferenceGraphCompletenessSignal( + payload, + reader, + reader.GetReferenceExtractionCapHits()); + } + + private void AddReferenceGraphCompletenessSignal( + JsonObject payload, + DbReader reader, + ReferenceExtractionCapHitSummary capHits) + { + var complete = reader.IsReferenceGraphComplete(capHits); + var incompleteReasons = reader.GetReferenceGraphIncompleteReasons(capHits); + payload["reference_extraction_limits"] = JsonSerializer.SerializeToNode( + ReferenceExtractor.GetSafetyLimits(), + _jsonOptions); + payload["reference_graph_complete"] = complete; + payload["reference_extraction_cap_hits"] = JsonSerializer.SerializeToNode( + capHits, + _jsonOptions); + if (!complete) + { + payload["reference_graph_incomplete_reasons"] = JsonSerializer.SerializeToNode( + incompleteReasons, + _jsonOptions); + payload["degraded"] = true; + } + } + + private static bool IsBareVerbatimQueryToken(string value) + { + var trimmed = value.Trim(); + return trimmed.Length > 0 && trimmed.All(ch => ch == '@'); + } + + private static Dictionary GetHotspotFamilyMetaSnapshot(DbContext db, Func keyFactory) + { + var languages = FileIndexer.GetHotspotFamilyMarkerLanguages(); + var values = new Dictionary(StringComparer.Ordinal); + var keys = new string[languages.Count]; + for (var i = 0; i < languages.Count; i++) + { + var lang = languages[i]; + keys[i] = keyFactory(lang); + values[lang] = null; + } + + var metaValues = db.GetMetaStrings(keys); + for (var i = 0; i < languages.Count; i++) + values[languages[i]] = metaValues.TryGetValue(keys[i], out var value) ? value : null; + + return values; + } + + private static Dictionary GetHotspotFamilyMarkerFingerprints( + FileIndexer indexer, + CancellationToken cancellationToken) => + indexer.GetProjectMarkerFingerprintResults(cancellationToken); + + private static void RestampHotspotFamilyTrust( + DbWriter writer, + IReadOnlySet? reusedLanguages, + IReadOnlyDictionary priorVersions, + IReadOnlyDictionary priorFingerprints, + IReadOnlyDictionary currentFingerprints) + { + var currentVersion = DbContext.HotspotFamilyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); + foreach (var lang in FileIndexer.GetHotspotFamilyMarkerLanguages()) + { + if (!currentFingerprints.TryGetValue(lang, out var currentFingerprint)) + continue; + + if (!currentFingerprint.IsComplete) + { + writer.MarkHotspotFamilyMarkerFingerprintIncomplete(lang, currentFingerprint.Fingerprint); + continue; + } + + priorVersions.TryGetValue(lang, out var priorVersion); + priorFingerprints.TryGetValue(lang, out var priorFingerprint); + if (reusedLanguages?.Contains(lang) != true || (priorVersion == currentVersion && priorFingerprint == currentFingerprint.Fingerprint)) + writer.MarkHotspotFamilyReady(lang, currentFingerprint.Fingerprint); + } + } + + private static Dictionary GetHotspotFamilyTrustMatchesCurrent( + IReadOnlyDictionary priorVersions, + IReadOnlyDictionary priorFingerprints, + IReadOnlyDictionary currentFingerprints) + { + var currentVersion = DbContext.HotspotFamilyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); + var values = new Dictionary(StringComparer.Ordinal); + foreach (var lang in FileIndexer.GetHotspotFamilyMarkerLanguages()) + { + currentFingerprints.TryGetValue(lang, out var currentFingerprint); + priorVersions.TryGetValue(lang, out var priorVersion); + priorFingerprints.TryGetValue(lang, out var priorFingerprint); + values[lang] = currentFingerprint.IsComplete + && priorVersion == currentVersion + && priorFingerprint == currentFingerprint.Fingerprint; + } + + return values; + } + + private static bool AllowReuseWithCurrentHotspotFamilyTrust( + string? lang, + IReadOnlyDictionary hotspotFamilyTrustMatchesCurrent) + { + if (!FileIndexer.SupportsHotspotFamilyMarkerLanguage(lang)) + return true; + + return lang != null + && hotspotFamilyTrustMatchesCurrent.TryGetValue(lang, out var matchesCurrent) + && matchesCurrent; + } + + private static void AddHotspotFamilySignal(JsonObject payload, HotspotFamilySignal signal) + { + payload["hotspot_family_ready"] = signal.Ready; + if (!signal.Ready) + { + payload["degraded"] = true; + if (signal.DegradedReason != null) + { + payload["hotspot_family_degraded_reason"] = signal.DegradedReason; + } + } + } + +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Batch.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Batch.cs new file mode 100644 index 000000000..4fc2115aa --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Batch.cs @@ -0,0 +1,322 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + + private JsonNode ExecuteBatchQueryEstimate(JsonNode? id, JsonArray queries, int responseByteLimit, ArgumentAdjustmentCollector adjustments) + { + var slotEstimates = new JsonArray(); + for (var requestIndex = 0; requestIndex < queries.Count; requestIndex++) + { + var queryObject = queries[requestIndex] as JsonObject; + slotEstimates.Add(BuildBatchSlotDescriptor(requestIndex, queryObject)); + } + + var payload = new JsonObject + { + ["count"] = 0, + ["total_count"] = queries.Count, + ["success_count"] = 0, + ["failure_count"] = 0, + ["partial_failure"] = false, + ["failure_scope"] = "none", + ["cascade_started_at_index"] = null, + ["estimate_only"] = true, + ["metadata"] = new JsonObject + { + ["submitted"] = queries.Count, + ["executed"] = 0, + ["errors"] = 0, + ["total_elapsed_ms"] = 0, + ["success_count"] = 0, + ["failure_count"] = 0, + ["response_byte_limit"] = responseByteLimit, + ["estimated_response_bytes"] = responseByteLimit, + }, + ["slot_estimates"] = slotEstimates, + ["results"] = new JsonArray(), + }; + adjustments.ApplyTo(payload); + + var summary = $"Estimated batch_query envelope for {queries.Count} query slot(s); no slots executed."; + var estimatedResponseBytes = EstimateJsonUtf8Bytes(CreateToolResult(id, summary, payload.DeepClone()), responseByteLimit); + ((JsonObject)payload["metadata"]!)["estimated_response_bytes"] = estimatedResponseBytes; + payload["estimate_exceeds_response_byte_limit"] = estimatedResponseBytes > responseByteLimit; + return CreateToolResult(id, summary, payload); + } + + private static int ReadBatchQueryResponseByteLimit(JsonNode? args, ArgumentAdjustmentCollector adjustments) + { + var serverLimit = GetBatchQueryResponseByteLimit(); + var requested = ReadOptionalIntArgument(args, "maxResponseBytes"); + if (!requested.HasValue) + return serverLimit; + var effective = Math.Min(requested.Value, serverLimit); + if (effective != requested.Value) + adjustments.AddClamped("maxResponseBytes", requested.Value, effective, 1, serverLimit); + return effective; + } + + private static JsonObject BuildBatchSlotDescriptor(int requestIndex, JsonObject? queryObject) + { + var toolName = queryObject?["tool"] is JsonValue toolValue && toolValue.TryGetValue(out var parsedToolName) + ? parsedToolName + : null; + var toolArgs = queryObject?["arguments"]; + var descriptor = new JsonObject + { + ["request_index"] = requestIndex, + ["args_summary"] = BuildArgsSummary(toolArgs), + }; + AddBatchSlotId(descriptor, ReadBatchSlotId(queryObject)); + AddToolDisplayData(descriptor, toolName); + return descriptor; + } + + private static JsonObject BuildBatchSplitHint(int submittedCount, int? cascadeStartedAtIndex, int retainedResultCount) + { + var nextRequestIndex = cascadeStartedAtIndex ?? submittedCount; + return new JsonObject + { + ["reason"] = "response_byte_limit_exceeded", + ["next_request_index"] = nextRequestIndex, + ["suggested_query_count"] = Math.Max(1, retainedResultCount), + ["resume_cursor"] = $"batch_query:v1:{nextRequestIndex}", + }; + } + + private static bool RemoveBatchTruncatedQueryToolDisplay(JsonArray truncatedQueries) + { + var changed = false; + foreach (var item in truncatedQueries) + { + if (item is JsonObject entry) + changed |= entry.Remove("tool"); + } + return changed; + } + + private static bool CompactBatchTruncatedQueryArgsSummaries(JsonArray truncatedQueries) + { + var changed = false; + foreach (var item in truncatedQueries) + { + if (item is JsonObject entry + && entry["args_summary"] is JsonValue value + && value.TryGetValue(out var summary) + && summary.Length > 0) + { + entry["args_summary"] = string.Empty; + changed = true; + } + } + return changed; + } + + private static string? ReadBatchSlotId(JsonObject? queryObject) + { + if (TryReadBatchSlotIdValue(queryObject?["slotId"], out var slotId) + || TryReadBatchSlotIdValue(queryObject?["id"], out slotId)) + return McpBoundedText.ForDisplay(slotId!, MaxRequestIdCharacterCount).Text; + return null; + } + + private static bool TryReadBatchSlotIdValue(JsonNode? node, out string? slotId) + { + slotId = null; + if (node is not JsonValue value) + return false; + if (value.TryGetValue(out var text)) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + slotId = text; + return true; + } + if (value.TryGetValue(out var intValue)) + { + slotId = intValue.ToString(CultureInfo.InvariantCulture); + return true; + } + if (value.TryGetValue(out var longValue)) + { + slotId = longValue.ToString(CultureInfo.InvariantCulture); + return true; + } + return false; + } + + private static void AddBatchSlotId(JsonObject entry, string? slotId) + { + if (!string.IsNullOrEmpty(slotId)) + entry["slot_id"] = slotId; + } + + private static int GetBatchQueryResponseByteLimit() + => ReadPositiveIntEnvironmentLimit( + BatchQueryResponseByteLimitEnvVar, + DefaultBatchQueryResponseByteLimit, + MaxBatchQueryResponseByteLimit, + "MCP batch_query response byte limit"); + + private int EstimateJsonUtf8Bytes(JsonNode node, int maxBytes = MaxBatchQueryResponseByteLimit) + { + _ = TryMeasureJsonUtf8BytesWithinLimit(node, _jsonOptions, maxBytes, out var bytesWritten); + return bytesWritten; + } + + private int EstimateBatchResponseBytes(JsonNode? id, string summary, int submittedCount, int successCount, int failureCount, + string failureScope, int? cascadeStartedAtIndex, int responseByteLimit, JsonArray resultsArray, bool truncated, JsonArray truncatedQueries, + ArgumentAdjustmentCollector? adjustments = null) + { + var payload = new JsonObject + { + ["count"] = resultsArray.Count, + ["total_count"] = submittedCount, + ["success_count"] = successCount, + ["failure_count"] = failureCount, + ["partial_failure"] = failureCount > 0 || cascadeStartedAtIndex.HasValue, + ["failure_scope"] = failureScope, + ["cascade_started_at_index"] = cascadeStartedAtIndex, + ["metadata"] = new JsonObject + { + ["submitted"] = submittedCount, + ["executed"] = successCount + failureCount, + ["errors"] = failureCount, + ["total_elapsed_ms"] = 0, + ["success_count"] = successCount, + ["failure_count"] = failureCount, + ["response_byte_limit"] = responseByteLimit, + ["estimated_response_bytes"] = responseByteLimit, + }, + ["results"] = resultsArray.DeepClone(), + }; + if (truncated) + { + payload["truncated"] = true; + payload["truncated_queries"] = truncatedQueries.DeepClone(); + } + adjustments?.ApplyTo(payload); + + return EstimateJsonUtf8Bytes(CreateToolResult(id, summary, payload), responseByteLimit); + } + + private int EstimateBatchAppendBytes(int currentEstimateBytes, JsonObject entry, int executedCount, int successCount, int failureCount) + { + var entryBytes = EstimateJsonUtf8Bytes(entry); + var digitGrowth = CountDecimalDigits(executedCount) + CountDecimalDigits(successCount) + CountDecimalDigits(failureCount); + return SaturatingAdd( + currentEstimateBytes, + entryBytes, + BatchQueryIncrementalEstimatePaddingBytes, + digitGrowth); + } + + private static int CountDecimalDigits(int value) + { + var digits = 1; + while (value >= 10) + { + value /= 10; + digits++; + } + return digits; + } + + private static int SaturatingAdd(params int[] values) + { + var total = 0L; + foreach (var value in values) + { + total += value; + if (total >= int.MaxValue) + return int.MaxValue; + } + return (int)total; + } + + private static string GetBatchFailureScope(int submittedCount, int successCount, int failureCount, int? cascadeStartedAtIndex) + { + if (cascadeStartedAtIndex.HasValue && cascadeStartedAtIndex.Value < submittedCount) + return "cascading"; + return failureCount == 0 ? "none" : "isolated"; + } + + /// + /// Build a compact, single-line summary string of a batch slot's arguments + /// so callers can correlate per-slot timings with what was requested + /// without re-parsing the original payload. + /// バッチスロットの arguments を1行で要約し、呼び出し側がペイロードを + /// 再解析せずスロット別時間と対応付けられるようにする。 + /// + private const int BatchArgsSummaryMaxLength = 200; + private static string BuildArgsSummary(JsonNode? toolArgs) + { + if (toolArgs is not JsonObject obj) + return string.Empty; + if (obj.Count == 0) + return string.Empty; + var parts = new List(obj.Count); + foreach (var kv in obj) + { + var key = McpBoundedText.ForDisplay(kv.Key).Text; + var rendered = RenderBatchArgumentSummaryValue(kv.Value); + parts.Add($"{key}={rendered}"); + } + var joined = string.Join(", ", parts); + if (joined.Length > BatchArgsSummaryMaxLength) + joined = joined.Substring(0, BatchArgsSummaryMaxLength - 1) + "…"; + return joined; + } + + private static string RenderBatchArgumentSummaryValue(JsonNode? value) + { + if (value is null) + return "null"; + if (value is JsonArray arr) + return $"[{arr.Count}]"; + if (value is JsonObject inner) + return $"{{{inner.Count}}}"; + if (value is not JsonValue jsonValue) + return ""; + + return jsonValue.GetValueKind() switch + { + JsonValueKind.String => jsonValue.TryGetValue(out var text) + ? JsonSerializer.Serialize(McpBoundedText.ForDisplay(text).Text) + : "\"\"", + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "null", + JsonValueKind.Number => RenderBatchNumericArgument(jsonValue), + _ => "", + }; + } + + private static string RenderBatchNumericArgument(JsonValue value) + { + if (value.TryGetValue(out var intValue)) + return intValue.ToString(CultureInfo.InvariantCulture); + if (value.TryGetValue(out var longValue)) + return longValue.ToString(CultureInfo.InvariantCulture); + if (value.TryGetValue(out var decimalValue)) + return decimalValue.ToString(CultureInfo.InvariantCulture); + if (value.TryGetValue(out var doubleValue) && double.IsFinite(doubleValue)) + return doubleValue.ToString("R", CultureInfo.InvariantCulture); + return ""; + } + +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs new file mode 100644 index 000000000..b2421b81b --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Search.cs @@ -0,0 +1,460 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + + private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) + { + var listRecipes = args?["listRecipes"]?.GetValue() ?? false; + if (listRecipes) + return ExecuteSearchRecipeList(id); + + var recipeNode = args?["recipe"]; + if (recipeNode is not null) + { + var recipeName = recipeNode.GetValue(); + if (string.IsNullOrWhiteSpace(recipeName)) + return CreateToolErrorResponse(id, "'recipe' must be a non-empty search recipe name."); + return ExecuteSearchRecipe(id, args, recipeName.Trim()); + } + + if (args?["auditScope"] is not null) + return CreateToolErrorResponse(id, "'auditScope' is only supported with recipe execution."); + + if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) + return CreateToolErrorResponse(id, requiredError!); + if (query.Length > QueryLimits.MaxQueryLength) + return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); + + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); + var snippetLines = ReadSnippetLines(args, SearchSnippetFormatter.DefaultSnippetLines, adjustments); + var snippetFocusText = args?["snippetFocus"]?.GetValue() ?? "quality"; + if (!QueryCommandRunner.TryParseSnippetFocusMode(snippetFocusText, out var snippetFocus)) + return CreateToolErrorResponse(id, "snippetFocus must be one of quality, leftmost, proximity"); + if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) + return maxLineWidthError; + var rawQuery = args?["rawQuery"]?.GetValue() ?? false; + SearchCursor? cursor = null; + var cursorValue = args?["cursor"]?.GetValue(); + if (!string.IsNullOrWhiteSpace(cursorValue)) + { + if (!TryParseSearchCursor(cursorValue, out var parsedCursor)) + return CreateToolErrorResponse(id, "'cursor' must be a search pagination cursor returned as `next_cursor` by a previous search response."); + cursor = parsedCursor; + } + var pathPatterns = ReadScopedPathList(args); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + if (!TryReadSinceArgument(args, out var since, out var sinceError)) + return CreateToolErrorResponse(id, sinceError!); + var deduplicate = !(args?["noDedup"]?.GetValue() ?? false); + var format = ReadResponseFormat(args); + if (ValidateResponseFormat(format) is string formatError) + return CreateToolErrorResponse(id, formatError); + var countOnly = ReadCountOnly(args) || format == "count"; + if (!TryResolveSearchExactArgument(args, out var exact, out var exactError)) + return CreateToolErrorResponse(id, exactError!); + var tokenBoundary = args?["tokenBoundary"]?.GetValue() ?? false; + var exactSearch = exact || tokenBoundary; + var prefix = args?["prefix"]?.GetValue() ?? false; + if (tokenBoundary && rawQuery) + return CreateToolErrorResponse(id, "'tokenBoundary' cannot be combined with 'rawQuery'."); + if (prefix && exactSearch) + return CreateToolErrorResponse(id, "'prefix' cannot be combined with 'exact' / 'exactSubstring' / 'tokenBoundary' (exact uses instr(), not FTS5 prefix phrases)."); + if (TryReadSearchGuardFilters(id, args, out var guardFilters) is JsonNode guardError) + return guardError; + if (TryReadSearchGuardScope(id, args, out var guardScope) is JsonNode guardScopeError) + return guardScopeError; + var guardWindow = ReadOptionalIntArgument(args, "guardWindow") ?? DbReader.DefaultSearchGuardWindow; + if (guardWindow < 0 || guardWindow > DbReader.MaxSearchGuardWindow) + return CreateToolErrorResponse(id, $"'guardWindow' must be between 0 and {DbReader.MaxSearchGuardWindow}; got {guardWindow}."); + var suggestExactSubstring = SearchQueryAdvisor.ShouldSuggestExactSubstring(query, rawQuery, exactSearch, prefix); + + return WithDbReader(id, args, reader => + { + if (countOnly) + { + List countResults; + try + { + countResults = reader.Search(query, MaxLimit, lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exactSearch, prefix, guardFilters: guardFilters, guardWindow: guardWindow, guardScope: guardScope, tokenBoundary: tokenBoundary); + } + catch (SearchQueryLimitException) + { + return CreateToolErrorResponse(id, FormatLiteralSearchQueryLimitError()); + } + catch (SearchGuardCandidateLimitException ex) + { + return CreateToolErrorResponse(id, FormatSearchGuardCandidateLimitError(ex)); + } + var truncatedCount = countResults.Count >= MaxLimit; + var payload = BuildCountOnlyPayload(countResults.Count, truncatedCount ? null : countResults.Count, truncatedCount, countResults, result => result.Path); + payload["query"] = query; + payload["rawQuery"] = rawQuery; + if (tokenBoundary) + payload["tokenBoundary"] = true; + payload["snippetFocus"] = snippetFocusText.Trim().ToLowerInvariant(); + payload["path"] = PathEcho(pathPatterns); + payload["excludeTests"] = excludeTests; + AddSearchStabilityMetadata(payload, reader, cursor, []); + if (suggestExactSubstring) + AddExactSubstringRecoveryHint(payload, query); + if (countResults.Count == 0) + AddFtsQueryDiagnostics(payload, DbReader.AnalyzeFtsQuery(query, rawQuery, prefix, lang)); + adjustments.ApplyTo(payload); + return CreateToolResult(id, $"Counted {countResults.Count} search result(s).", payload); + } + + List results; + try + { + results = reader.Search(query, FetchLimitForEnvelope(limit), lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exactSearch, prefix, cursor: cursor, guardFilters: guardFilters, guardWindow: guardWindow, guardScope: guardScope, tokenBoundary: tokenBoundary); + } + catch (SearchQueryLimitException) + { + return CreateToolErrorResponse(id, FormatLiteralSearchQueryLimitError()); + } + catch (SearchGuardCandidateLimitException ex) + { + return CreateToolErrorResponse(id, FormatSearchGuardCandidateLimitError(ex)); + } + var ftsDiagnostics = DbReader.AnalyzeFtsQuery(query, rawQuery, prefix, lang); + var truncated = TrimToRequestedLimit(results, limit); + if (results.Count == 0) + { + var payload = new JsonObject + { + ["query"] = query, + ["rawQuery"] = rawQuery, + ["tokenBoundary"] = tokenBoundary, + ["snippetLines"] = snippetLines, + ["snippetFocus"] = snippetFocusText.Trim().ToLowerInvariant(), + ["maxLineWidth"] = maxLineWidth, + ["path"] = PathEcho(pathPatterns), + ["excludeTests"] = excludeTests, + ["results"] = new JsonArray() + }; + AddSearchStabilityMetadata(payload, reader, cursor, results); + AddFtsQueryDiagnostics(payload, ftsDiagnostics); + AddResultEnvelope(payload, 0, 0, truncated: false); + if (suggestExactSubstring) + { + AddExactSubstringRecoveryHint(payload, query); + } + else + { + AddRecoveryHint( + payload, + "no_results", + "search returned no rows; try removing lang/path filters, using prefix for token-prefix matches, or using exactSubstring for literal punctuation or emoji.", + "search", + new JsonObject { ["query"] = query, ["limit"] = 5 }); + } + AddFreshnessHint(payload, reader); + adjustments.ApplyTo(payload); + return CreateToolResult(id, "No results found.", payload); + } + + var queryContext = SearchSnippetFormatter.PrepareQueryContext(query); + var compactResults = SearchSnippetFormatter + .ToCompactResults(results, queryContext, snippetLines, exactSearch, maxLineWidth, lang, snippetFocus, exposeLiteralHighlights: exactSearch) + .ToList(); + foreach (var compact in compactResults) + SearchSnippetFormatter.ApplyOutputMetadata(compact, snippetLines, maxLineWidth, exactSearch, rawQuery); + var structured = new JsonObject + { + ["query"] = query, + ["rawQuery"] = rawQuery, + ["tokenBoundary"] = tokenBoundary, + ["cursor"] = cursorValue, + ["snippetLines"] = snippetLines, + ["snippetFocus"] = snippetFocusText.Trim().ToLowerInvariant(), + ["maxLineWidth"] = maxLineWidth, + ["path"] = PathEcho(pathPatterns), + ["excludeTests"] = excludeTests, + ["results"] = ToJsonArray(compactResults) + }; + AddSearchStabilityMetadata(structured, reader, cursor, results, truncated); + AddResultEnvelope(structured, results.Count, truncated ? null : results.Count, truncated); + if (format == "compact") + ApplyCompactResults( + structured, + compactResults, + result => result.Path, + result => result.MatchLines.Count > 0 ? result.MatchLines[0] : result.ChunkStartLine); + var topResult = results[0]; + AddNextStepSuggestion( + structured, + "excerpt", + BuildExcerptArgs(topResult.Path, topResult.StartLine, topResult.EndLine), + "Use excerpt on the top hit before editing; for symbol changes, follow with definition or references to confirm declarations and usage sites."); + if (suggestExactSubstring) + AddExactSubstringRecoveryHint(structured, query); + adjustments.ApplyTo(structured); + // Include top file paths in summary for quick AI orientation + // AIが素早く位置把握できるよう、サマリにトップファイルパスを含める + var topPaths = results.Select(r => r.Path).Distinct().Take(3); + var summary = $"Found {results.Count} search result(s) in {string.Join(", ", topPaths)}."; + return CreateToolResult(id, summary, structured); + }); + } + + private JsonNode ExecuteSearchRecipeList(JsonNode? id) + { + var registry = SearchAuditRecipes.Load(); + var payload = new JsonObject + { + ["count"] = registry.Recipes.Count, + ["recipes"] = ToSearchRecipeArray(registry.Recipes) + }; + AddSearchRecipeSourceDiagnostics(payload, registry.Diagnostics); + return CreateToolResult(id, $"Found {registry.Recipes.Count} search recipe(s).", payload); + } + + private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipeName) + { + var registry = SearchAuditRecipes.Load(); + var recipe = registry.Recipes.FirstOrDefault(r => string.Equals(r.Name, recipeName, StringComparison.OrdinalIgnoreCase)); + if (recipe is null) + { + var available = string.Join(", ", registry.Recipes.Select(r => r.Name)); + return CreateToolErrorResponse(id, $"unknown search recipe '{recipeName}'. Available recipes: {available}."); + } + + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); + var snippetLines = ReadSnippetLines(args, SearchSnippetFormatter.DefaultSnippetLines, adjustments); + if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) + return maxLineWidthError; + var pathPatterns = ReadScopedPathList(args); + List requestedPathPatterns = pathPatterns is null ? [] : [.. pathPatterns]; + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + if (!TryResolveMcpRecipeAuditScope(args, recipe, ref pathPatterns, excludePaths, ref excludeTests, out var auditScope, out var auditScopeError)) + return CreateToolErrorResponse(id, auditScopeError!); + if (!TryReadSinceArgument(args, out var since, out var sinceError)) + return CreateToolErrorResponse(id, sinceError!); + var deduplicate = !(args?["noDedup"]?.GetValue() ?? false); + if (args?["tokenBoundary"]?.GetValue() ?? false) + return CreateToolErrorResponse(id, "'tokenBoundary' is only supported for ad hoc search, not recipe execution."); + if (!TryResolveSearchExactArgument(args, out var userExact, out var exactError)) + return CreateToolErrorResponse(id, exactError!); + var hasExactOverride = args?["exact"] is not null || args?["exactSubstring"] is not null; + if (args?["prefix"]?.GetValue() ?? false) + return CreateToolErrorResponse(id, "'prefix' cannot be combined with recipe execution."); + if (args?["cursor"] is not null) + return CreateToolErrorResponse(id, "'cursor' is not supported for recipe execution."); + if (TryReadSearchGuardFilters(id, args, out var guardFilters) is JsonNode guardError) + return guardError; + if (TryReadSearchGuardScope(id, args, out var guardScope) is JsonNode guardScopeError) + return guardScopeError; + var guardWindow = ReadOptionalIntArgument(args, "guardWindow") ?? DbReader.DefaultSearchGuardWindow; + if (guardWindow < 0 || guardWindow > DbReader.MaxSearchGuardWindow) + return CreateToolErrorResponse(id, $"'guardWindow' must be between 0 and {DbReader.MaxSearchGuardWindow}; got {guardWindow}."); + + return WithDbReader(id, args, reader => + { + var queryResults = new JsonArray(); + var total = 0; + foreach (var recipeQuery in recipe.Queries) + { + var exact = hasExactOverride ? userExact : recipeQuery.ExactSubstring; + ResolveMcpRecipeQueryScope( + recipeQuery, + pathPatterns, + excludePaths, + out var queryPathPatterns, + out var queryExcludePaths); + var requiredPathPatterns = GetMcpSearchRecipeRequiredPathPatterns(requestedPathPatterns, recipeQuery); + List results; + try + { + results = reader.Search( + recipeQuery.Query, + FetchLimitForSearchRecipeEnvelope(limit), + lang, + false, + queryPathPatterns, + queryExcludePaths, + excludeTests, + deduplicate, + since, + exact, + false, + guardFilters: guardFilters, + guardWindow: guardWindow, + guardScope: guardScope, + requiredPathPatterns: requiredPathPatterns, + resultRanking: recipeQuery.ResultRanking); + } + catch (SearchQueryLimitException) + { + return CreateToolErrorResponse(id, FormatLiteralSearchQueryLimitError()); + } + catch (SearchGuardCandidateLimitException ex) + { + return CreateToolErrorResponse(id, FormatSearchRecipeGuardCandidateLimitError(recipe.Name, recipeQuery.Name, ex)); + } + + var queryContext = SearchSnippetFormatter.PrepareQueryContext(recipeQuery.Query); + var compactResults = SearchSnippetFormatter + .ToCompactResults(results, queryContext, snippetLines, exact, maxLineWidth, exposeLiteralHighlights: exact) + .Where(result => MatchesRecipeFacetMetadata(result, recipeQuery)) + .ToList(); + var truncated = TrimToRequestedLimit(compactResults, limit); + foreach (var compact in compactResults) + SearchSnippetFormatter.ApplyOutputMetadata(compact, snippetLines, maxLineWidth, exact, rawFts: false); + total += compactResults.Count; + queryResults.Add(new JsonObject + { + ["name"] = recipeQuery.Name, + ["query"] = recipeQuery.Query, + ["description"] = recipeQuery.Description, + ["recommended_labels"] = ToJsonArray(recipeQuery.RecommendedLabels), + ["false_positive_guidance"] = recipeQuery.FalsePositiveGuidance, + ["exact_substring"] = exact, + ["match_origins"] = ToJsonArray(recipeQuery.MatchOrigins), + ["exclude_origins"] = ToJsonArray(recipeQuery.ExcludeOrigins), + ["result_kinds"] = ToJsonArray(recipeQuery.ResultKinds), + ["count"] = compactResults.Count, + ["top_files"] = BuildTopFileHistogram(compactResults, result => result.Path), + ["truncated"] = truncated, + ["results"] = ToJsonArray(compactResults) + }); + } + + var payload = new JsonObject + { + ["recipe"] = ToSearchRecipeJson(recipe), + ["query_count"] = recipe.Queries.Count, + ["result_count"] = total, + ["limit_per_query"] = limit, + ["snippetLines"] = snippetLines, + ["maxLineWidth"] = maxLineWidth, + ["lang"] = lang, + ["audit_scope"] = auditScope, + ["path"] = PathEcho(pathPatterns), + ["excludePaths"] = PathEcho(excludePaths), + ["excludeTests"] = excludeTests, + ["queries"] = queryResults + }; + AddFreshnessHint(payload, reader); + AddSearchRecipeSourceDiagnostics(payload, registry.Diagnostics); + adjustments.ApplyTo(payload); + var summary = total == 0 + ? $"Recipe '{recipe.Name}' returned no search results." + : $"Recipe '{recipe.Name}' returned {total} search result(s) across {recipe.Queries.Count} query(ies)."; + return CreateToolResult(id, summary, payload); + }); + } + + private static bool TryResolveMcpRecipeAuditScope( + JsonNode? args, + SearchAuditRecipe recipe, + ref List? pathPatterns, + List excludePaths, + ref bool excludeTests, + out string auditScope, + out string? error) + { + var requestedScope = args?["auditScope"]?.GetValue(); + auditScope = string.IsNullOrWhiteSpace(requestedScope) + ? recipe.DefaultScope + : requestedScope.Trim(); + error = null; + + if (!string.Equals(auditScope, SearchAuditRecipes.DefaultAuditScope, StringComparison.Ordinal) + && !string.Equals(auditScope, SearchAuditRecipes.AllAuditScope, StringComparison.Ordinal)) + { + error = "'auditScope' must be either 'source' or 'all'."; + return false; + } + + if (string.Equals(auditScope, SearchAuditRecipes.DefaultAuditScope, StringComparison.Ordinal)) + { + if ((pathPatterns is null || pathPatterns.Count == 0) && recipe.DefaultPathPatterns.Count > 0) + pathPatterns = [.. recipe.DefaultPathPatterns]; + AddDistinct(excludePaths, recipe.DefaultExcludePaths); + excludeTests = true; + } + + return true; + } + + private static void ResolveMcpRecipeQueryScope( + SearchAuditRecipeQuery query, + List? recipePathPatterns, + List recipeExcludePaths, + out List? queryPathPatterns, + out List queryExcludePaths) + { + queryPathPatterns = query.PathPatterns.Count > 0 + ? [.. query.PathPatterns] + : recipePathPatterns is null ? null : [.. recipePathPatterns]; + queryExcludePaths = [.. recipeExcludePaths]; + AddDistinct(queryExcludePaths, query.ExcludePaths); + } + + private static IReadOnlyList? GetMcpSearchRecipeRequiredPathPatterns( + IReadOnlyList requestedPathPatterns, + SearchAuditRecipeQuery query) + => requestedPathPatterns.Count > 0 && query.PathPatterns.Count > 0 + ? requestedPathPatterns + : null; + + private static void AddDistinct(List target, IEnumerable values) + { + foreach (var value in values) + { + if (!target.Contains(value, StringComparer.Ordinal)) + target.Add(value); + } + } + + private JsonArray ToSearchRecipeArray(IEnumerable recipes) + => new(recipes.Select(recipe => ToSearchRecipeJson(recipe)).ToArray()); + + private JsonObject ToSearchRecipeJson(SearchAuditRecipe recipe) + => new() + { + ["name"] = recipe.Name, + ["description"] = recipe.Description, + ["recommended_labels"] = ToJsonArray(recipe.RecommendedLabels), + ["default_scope"] = recipe.DefaultScope, + ["default_path_patterns"] = ToJsonArray(recipe.DefaultPathPatterns), + ["default_exclude_paths"] = ToJsonArray(recipe.DefaultExcludePaths), + ["queries"] = new JsonArray(recipe.Queries.Select(query => new JsonObject + { + ["name"] = query.Name, + ["query"] = query.Query, + ["description"] = query.Description, + ["recommended_labels"] = ToJsonArray(query.RecommendedLabels), + ["false_positive_guidance"] = query.FalsePositiveGuidance, + ["exact_substring"] = query.ExactSubstring + }).ToArray()) + }; + + private static void AddSearchRecipeSourceDiagnostics(JsonObject payload, IReadOnlyList diagnostics) + { + if (diagnostics.Count == 0) + return; + payload["recipe_source_diagnostics"] = new JsonArray(diagnostics.Select(diagnostic => JsonValue.Create(diagnostic)).ToArray()); + } + +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs new file mode 100644 index 000000000..4a880cad0 --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Source.cs @@ -0,0 +1,407 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + + private JsonNode ExecuteOutline(JsonNode? id, JsonNode? args) + { + if (!TryReadRequiredPathParameter(args, "path", out var path, out var requiredError)) + return CreateToolErrorResponse(id, requiredError!); + + return WithDbReader(id, args, reader => + { + var outline = reader.GetOutline(path); + if (outline == null) + { + var emptyPayload = new JsonObject + { + ["path"] = path, + ["error"] = "file not found in index" + }; + AddFreshnessHint(emptyPayload, reader); + return CreateToolResult(id, "File not found in index.", emptyPayload); + } + + var structured = JsonSerializer.SerializeToNode(outline, _jsonOptions)!.AsObject(); + AddNextStepSuggestion( + structured, + "excerpt", + new JsonObject { ["path"] = path, ["startLine"] = 1, ["endLine"] = Math.Min(outline.TotalLines, 80) }, + "Use excerpt for only the relevant outline range instead of reading the whole file."); + return CreateToolResult(id, $"Outline: {ConsoleUi.Counted(outline.SymbolCount, "symbol")} in {ConsoleUi.Counted(outline.TotalLines, "line")}.", structured); + }); + } + + private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) + { + if (!TryReadRequiredPathParameter(args, "path", out var path, out var requiredError)) + return CreateToolErrorResponse(id, requiredError!); + + var startLine = ReadOptionalIntArgument(args, "startLine"); + if (startLine == null || startLine <= 0) + return CreateToolErrorResponse(id, "Missing or invalid required parameter: startLine"); + + var endLine = ReadOptionalIntArgument(args, "endLine") ?? startLine.Value; + if (endLine < startLine.Value) + return CreateToolErrorResponse(id, "endLine must be greater than or equal to startLine"); + + var beforeValue = ReadOptionalIntArgument(args, "before"); + if (beforeValue.HasValue && beforeValue.Value < 0) + return CreateToolErrorResponse(id, $"before must be in [0, {MaxContextLines}]"); + var before = ClampContextLines(beforeValue ?? 0); + + var afterValue = ReadOptionalIntArgument(args, "after"); + if (afterValue.HasValue && afterValue.Value < 0) + return CreateToolErrorResponse(id, $"after must be in [0, {MaxContextLines}]"); + var after = ClampContextLines(afterValue ?? 0); + var contextTruncated = beforeValue > MaxContextLines || afterValue > MaxContextLines; + + var focusLine = ReadOptionalIntArgument(args, "focusLine"); + var focusColumn = ReadOptionalIntArgument(args, "focusColumn"); + var focusLengthValue = ReadOptionalIntArgument(args, "focusLength"); + if (focusLengthValue.HasValue && focusLengthValue.Value <= 0) + return CreateToolErrorResponse(id, "focusLength must be greater than or equal to 1"); + var focusLength = focusLengthValue ?? 1; + var explicitFocusLength = args?["focusLength"] != null; + if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) + return maxLineWidthError; + if (!TryReadMaxOutputBytes(args, out var maxOutputBytes, out var maxOutputBytesError)) + return CreateToolErrorResponse(id, maxOutputBytesError!); + + if (focusLine.HasValue && focusLine.Value <= 0) + return CreateToolErrorResponse(id, "focusLine must be greater than or equal to 1"); + if (focusColumn.HasValue && focusColumn.Value <= 0) + return CreateToolErrorResponse(id, "focusColumn must be greater than or equal to 1"); + if (!focusColumn.HasValue && explicitFocusLength) + return CreateToolErrorResponse(id, "focusLength requires focusColumn"); + + return WithDbReader(id, args, reader => + { + if (focusLine.HasValue) + { + var file = reader.GetFileByPath(path); + if (file != null) + { + // `before` is bounded by MaxContextLines and `startLine` by `int.MaxValue`, but + // `endLine` is caller-supplied: int + int can still overflow when endLine is + // close to `int.MaxValue`. Use long intermediates so the clamp sees the real + // window before narrowing back to int (#1528). + // `before` は MaxContextLines、`startLine` は `int.MaxValue` で押さえているが、 + // `endLine` は呼び出し側入力で `int.MaxValue` 近傍なら int 同士の加算が overflow し得る。 + // long 中間変数で実窓を確定させてから int に戻す(#1528)。 + var requestedStart = (int)Math.Max(1L, (long)startLine.Value - before); + var requestedEnd = (int)Math.Min(file.Lines, (long)endLine + after); + if (focusLine.Value < requestedStart || focusLine.Value > requestedEnd) + return CreateToolErrorResponse(id, $"focusLine ({focusLine.Value}) must be within the returned excerpt range ({requestedStart}-{requestedEnd})"); + } + } + if (focusColumn.HasValue) + { + var focusLineLength = reader.GetExcerptFocusLineLength( + path, + startLine.Value, + endLine, + before, + after, + focusLine ?? startLine.Value); + if (focusLineLength.HasValue && focusColumn.Value > focusLineLength.Value) + return CreateToolErrorResponse(id, $"focusColumn ({focusColumn.Value}) must be within the focused line length ({focusLineLength.Value})"); + } + + var excerpt = reader.GetExcerpt(path, startLine.Value, endLine, before, after, maxLineWidth, focusLine ?? startLine.Value, focusColumn, focusLength); + if (excerpt == null) + { + var emptyPayload = new JsonObject + { + ["path"] = path, + ["count"] = 0 + }; + AddRecoveryHint( + emptyPayload, + "file_or_range_not_indexed", + "excerpt found no indexed content for the requested range; verify the path with files or outline, then retry with an indexed line range.", + "outline", + new JsonObject { ["path"] = path }); + AddFreshnessHint(emptyPayload, reader); + return CreateToolResult(id, "No excerpt found.", emptyPayload); + } + + ExcerptRecoveryCommandFormatter.ApplyDbPath(excerpt, _dbPath); + var payload = JsonSerializer.SerializeToNode(excerpt, _jsonOptions)!.AsObject(); + ApplyExcerptOutputBudget(payload, maxOutputBytes); + payload["maxOutputBytes"] = maxOutputBytes; + payload["before"] = before; + payload["after"] = after; + payload["contextTruncated"] = contextTruncated; + payload["maxLineWidth"] = maxLineWidth; + if (focusLine.HasValue) + payload["focusLine"] = focusLine.Value; + if (focusColumn.HasValue) + payload["focusColumn"] = focusColumn.Value; + payload["focusLength"] = focusLength; + AddNextStepSuggestion( + payload, + "outline", + new JsonObject { ["path"] = excerpt.Path }, + "Use outline to navigate neighboring symbols before requesting more ranges from the same file."); + return CreateToolResult(id, "Excerpt returned.", payload); + }); + } + + private static bool TryReadMaxOutputBytes(JsonNode? args, out int maxOutputBytes, out string? error) + { + maxOutputBytes = DefaultExcerptOutputByteLimit; + error = null; + if (args?["maxOutputBytes"] is not JsonNode node) + return true; + if (node is not JsonValue value || !value.TryGetValue(out var requested)) + { + error = "maxOutputBytes must be an integer"; + return false; + } + if (requested <= 0) + { + error = "maxOutputBytes must be greater than or equal to 1"; + return false; + } + maxOutputBytes = Math.Min(requested, DefaultExcerptOutputByteLimit); + return true; + } + + internal static void ApplyExcerptOutputBudget(JsonObject payload, int maxOutputBytes) + { + var contentKey = payload.ContainsKey("content") ? "content" : "Content"; + if (payload[contentKey]?.GetValue() is not string content) + return; + if (Encoding.UTF8.GetByteCount(content) <= maxOutputBytes) + return; + + var builder = new StringBuilder(); + var retainedLineCount = 0; + var firstRetainedLine = true; + foreach (var line in content.Replace("\r\n", "\n").Split('\n')) + { + var candidate = firstRetainedLine ? line : builder.ToString() + "\n" + line; + if (Encoding.UTF8.GetByteCount(candidate) > maxOutputBytes) + break; + builder.Clear(); + builder.Append(candidate); + retainedLineCount++; + firstRetainedLine = false; + } + payload[contentKey] = builder.ToString(); + TrimExcerptCoordinatePayload(payload, retainedLineCount); + payload["contentTruncated"] = true; + payload["truncated"] = true; + payload["truncation_reason"] = "output_size_cap"; + } + + private static void TrimExcerptCoordinatePayload(JsonObject payload, int retainedLineCount) + { + var spansKey = FirstPayloadKey(payload, "contentLineSpans", "content_line_spans", "ContentLineSpans"); + var retainedSpans = new List(); + var hasSpanMapping = false; + if (spansKey is not null && payload[spansKey] is JsonArray spans) + { + hasSpanMapping = true; + var trimmedSpans = new JsonArray(); + foreach (var spanNode in spans) + { + if (spanNode is not JsonObject span) + continue; + var contentLine = GetPayloadInt(span, "contentLine", "content_line", "ContentLine"); + if (!contentLine.HasValue || contentLine.Value > retainedLineCount) + continue; + + trimmedSpans.Add(span.DeepClone()); + var sourceLine = GetPayloadInt(span, "sourceLine", "source_line", "SourceLine"); + var sourceStartColumn = GetPayloadInt(span, "sourceStartColumn", "source_start_column", "SourceStartColumn"); + var sourceEndColumn = GetPayloadInt(span, "sourceEndColumn", "source_end_column", "SourceEndColumn"); + if (sourceLine.HasValue && sourceStartColumn.HasValue && sourceEndColumn.HasValue) + retainedSpans.Add(new ExcerptPayloadSpan(sourceLine.Value, sourceStartColumn.Value, sourceEndColumn.Value)); + } + + payload[spansKey] = trimmedSpans; + } + + var tokensKey = FirstPayloadKey(payload, "semanticTokens", "semantic_tokens", "SemanticTokens"); + if (tokensKey is null || payload[tokensKey] is not JsonArray tokens) + return; + if (!hasSpanMapping) + { + if (retainedLineCount == 0) + payload[tokensKey] = new JsonArray(); + return; + } + + var trimmedTokens = new JsonArray(); + if (retainedLineCount > 0 && retainedSpans.Count > 0) + { + foreach (var tokenNode in tokens) + { + if (tokenNode is not JsonObject token) + continue; + var startLine = GetPayloadInt(token, "startLine", "start_line", "StartLine"); + var endLine = GetPayloadInt(token, "endLine", "end_line", "EndLine"); + var startColumn = GetPayloadInt(token, "startColumn", "start_column", "StartColumn"); + var endColumn = GetPayloadInt(token, "endColumn", "end_column", "EndColumn"); + if (!startLine.HasValue || !endLine.HasValue || !startColumn.HasValue || !endColumn.HasValue) + continue; + if (retainedSpans.Any(span => + startLine.Value == span.SourceLine && + endLine.Value == span.SourceLine && + startColumn.Value >= span.SourceStartColumn && + endColumn.Value <= span.SourceEndColumn)) + { + trimmedTokens.Add(token.DeepClone()); + } + } + } + + payload[tokensKey] = trimmedTokens; + } + + private static string? FirstPayloadKey(JsonObject payload, params string[] keys) + => keys.FirstOrDefault(payload.ContainsKey); + + private static int? GetPayloadInt(JsonObject obj, params string[] keys) + { + foreach (var key in keys) + { + if (obj[key] is JsonNode node) + return node.GetValue(); + } + + return null; + } + + private readonly record struct ExcerptPayloadSpan(int SourceLine, int SourceStartColumn, int SourceEndColumn); + + private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) + { + if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) + return CreateToolErrorResponse(id, requiredError!); + if (query.Length > QueryLimits.MaxQueryLength) + return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); + + var pathPatterns = ReadScopedPathList(args); + if (pathPatterns == null || pathPatterns.Count == 0) + return CreateToolErrorResponse(id, HasBlankPathFilter(args) + ? "Parameter \"path\" cannot be empty or whitespace-only" + : "Missing required parameter: path"); + + var adjustments = new ArgumentAdjustmentCollector(); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + var beforeValue = ReadOptionalIntArgument(args, "before"); + if (beforeValue.HasValue && beforeValue.Value < 0) + return CreateToolErrorResponse(id, "before must be greater than or equal to 0"); + var before = ClampContextLines(beforeValue ?? 0); + + var afterValue = ReadOptionalIntArgument(args, "after"); + if (afterValue.HasValue && afterValue.Value < 0) + return CreateToolErrorResponse(id, "after must be greater than or equal to 0"); + var after = ClampContextLines(afterValue ?? 0); + var contextTruncated = beforeValue > MaxContextLines || afterValue > MaxContextLines; + var snippetLinesValue = ReadOptionalIntArgument(args, "snippetLines"); + if (snippetLinesValue.HasValue && (snippetLinesValue.Value <= 0 || snippetLinesValue.Value > SearchSnippetFormatter.MaxSnippetLines)) + return CreateToolErrorResponse(id, $"snippetLines must be in [1, {SearchSnippetFormatter.MaxSnippetLines}]"); + if (snippetLinesValue.HasValue) + { + var surroundingLines = snippetLinesValue.Value - 1; + if (!beforeValue.HasValue) + before = surroundingLines / 2; + if (!afterValue.HasValue) + after = surroundingLines - before; + } + var focusLine = args?["focusLine"]?.GetValue(); + if (focusLine.HasValue && focusLine.Value <= 0) + return CreateToolErrorResponse(id, "focusLine must be greater than or equal to 1"); + var focusColumn = args?["focusColumn"]?.GetValue(); + if (focusColumn.HasValue && focusColumn.Value <= 0) + return CreateToolErrorResponse(id, "focusColumn must be greater than or equal to 1"); + if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) + return maxLineWidthError; + var exact = args?["exact"]?.GetValue() ?? false; + var regex = args?["regex"]?.GetValue() ?? false; + + return WithDbReader(id, args, reader => + { + List results; + try + { + results = reader.FindInFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, before, after, exact, maxLineWidth, focusLine, focusColumn, regex).Results; + } + catch (RegexMatchTimeoutException ex) when (regex) + { + return CreateToolErrorResponse( + id, + RegexTimeoutPolicy.FormatFindTimeout(ex), + category: RegexTimeoutPolicy.RegexTimeoutCategory, + suggestion: RegexTimeoutPolicy.McpFindTimeoutSuggestion, + retrySafe: true, + extraData: new JsonObject + { + ["error_code"] = CommandErrorCodes.RegexMatchTimeout, + ["timeout_ms"] = ex.MatchTimeout.TotalMilliseconds, + }); + } + catch (ArgumentException) when (regex) + { + return CreateToolErrorResponse(id, "invalid regular expression. Check regex syntax and retry."); + } + var structured = new JsonObject + { + ["query"] = query, + ["path"] = PathEcho(pathPatterns), + ["excludeTests"] = excludeTests, + ["before"] = before, + ["after"] = after, + ["contextTruncated"] = contextTruncated, + ["maxLineWidth"] = maxLineWidth, + ["exact"] = exact, + ["regex"] = regex, + ["count"] = results.Count, + ["fileCount"] = results.Select(r => r.Path).Distinct().Count(), + ["results"] = JsonSerializer.SerializeToNode(results, _jsonOptions), + }; + if (snippetLinesValue.HasValue) + structured["snippetLines"] = snippetLinesValue.Value; + if (focusLine.HasValue) + structured["focusLine"] = focusLine.Value; + if (focusColumn.HasValue) + structured["focusColumn"] = focusColumn.Value; + if (results.Count == 0) + { + AddFreshnessHint(structured, reader); + adjustments.ApplyTo(structured); + return CreateToolResult(id, "No matches found.", structured); + } + + var fileCount = structured["fileCount"]!.GetValue(); + adjustments.ApplyTo(structured); + return CreateToolResult(id, $"Found {ConsoleUi.Counted(results.Count, "in-file match", "in-file matches")} across {ConsoleUi.Counted(fileCount, "file")}.", structured); + }); + } + + private static int ClampContextLines(int value) + { + return Math.Min(value, MaxContextLines); + } + +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs new file mode 100644 index 000000000..36a8047dd --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs @@ -0,0 +1,674 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + + private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) + { + var checkWorkspace = args?["check"]?.GetValue() ?? false; + var staleAfterSeconds = ReadOptionalIntArgument(args, "staleAfterSeconds") ?? (int)TimeSpan.FromDays(1).TotalSeconds; + if (staleAfterSeconds <= 0) + return CreateToolErrorResponse(id, "staleAfterSeconds must be greater than or equal to 1"); + var explain = args?["explain"]?.GetValue()?.Trim().ToLowerInvariant(); + if (explain is not (null or "freshness" or "readiness" or "all")) + return CreateToolErrorResponse(id, "explain must be one of freshness, readiness, all"); + var format = ReadResponseFormat(args); + if (format is not ("full" or "compact")) + return CreateToolErrorResponse(id, "format must be one of full, compact"); + if (!TryReadStatusProjectionFields(args, out var projectionFields, out var projectionError)) + return CreateToolErrorResponse(id, projectionError!); + if (!TryReadStatusScopes(args, out var statusScopes, out var scopeError)) + return CreateToolErrorResponse(id, scopeError!); + var includeConfig = args?["config"]?.GetValue() ?? false; + var includeLogPath = args?["logPath"]?.GetValue() ?? false; + var runUpdateCheck = args?["updateCheck"]?.GetValue() ?? false; + + string? unavailableProjectionError = null; + var response = WithDbReader(id, args, reader => + { + var requestToken = _currentRequestToken.Value; + var status = reader.GetStatus(); + QueryCommandRunner.ApplyStatusSymbolKindLimits(status, reader.GetSymbolKindCounts()); + WorkspaceMetadataEnricher.Enrich(status, _dbPath, _dbPathExplicit, requestToken); + status.DbFileMode = DbContext.GetUnixFileModeString( + _dbPath, + status.DatabasePermissionPolicy, + out var databasePermissionDiagnostic); + if (databasePermissionDiagnostic != null) + { + status.DatabasePermissionDiagnostics ??= []; + status.DatabasePermissionDiagnostics.Add(databasePermissionDiagnostic); + } + var macProfile = MacProfileDetector.DetectCurrentWithDiagnostics(); + status.MacProfile = macProfile.Profile; + if (macProfile.Diagnostics.Count > 0) + status.MacProfileDiagnostics = macProfile.Diagnostics.ToList(); + if (checkWorkspace) + { + status.WorkspaceCheck = IndexFreshnessChecker.Check( + reader, + status.ProjectRoot, + requestToken, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(_dbPath)); + status.IndexMatchesWorkspace = status.WorkspaceCheck.Checked + ? status.WorkspaceCheck.MatchesWorkspace + : null; + status.StaleAfterSeconds = staleAfterSeconds; + if (status.IndexedAt.HasValue) + status.IndexAgeSeconds = Math.Max(0, (long)Math.Round((GetUtcNow() - status.IndexedAt.Value).TotalSeconds, MidpointRounding.AwayFromZero)); + } + ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(status.ProjectRoot); + status.GraphSupportedLanguages = ReferenceExtractor.GetSupportedLanguages(status.ProjectRoot).OrderBy(l => l).ToList(); + status.Extractors = ExtractorPluginRegistry.GetStatusSnapshot(status.ProjectRoot); + status.GitExecutable = GitHelper.GetGitExecutableStatus(); + var postExtractionHookSnapshot = PostExtractionHookRunner.DiscoverDefaultMetadata(); + var postExtractionHooks = postExtractionHookSnapshot.Hooks; + if (postExtractionHookSnapshot.Diagnostics.Count > 0) + status.HookDiagnostics = postExtractionHookSnapshot.Diagnostics.ToList(); + var trustOverrides = ExtractorPluginRegistry.GetAcceptedTrustOverrides(status.ProjectRoot) + .Concat(postExtractionHookSnapshot.TrustOverrides) + .Concat(GitHelper.GetAcceptedTrustOverrides(status.GitExecutable)) + .ToList(); + if (trustOverrides.Count > 0) + status.TrustOverrides = trustOverrides; + if (postExtractionHooks.Count > 0) + { + status.Hooks = postExtractionHooks + .Select(hook => new PostExtractionHookStatus + { + Id = hook.Id, + Name = hook.Name, + AssemblyPath = hook.AssemblyPath, + TypeName = hook.TypeName, + CallbackBudgetMs = (long)Math.Round(postExtractionHookSnapshot.CallbackBudget.TotalMilliseconds, MidpointRounding.AwayFromZero), + LoadContextLifecycle = PostExtractionHookRunner.HookLoadContextLifecycle, + }) + .ToList(); + } + status.Version = _version; + requestToken.ThrowIfCancellationRequested(); + status.UpdateCheck = runUpdateCheck + ? (StatusUpdateCheckForTesting ?? UpdateChecker.Check)(_version, requestToken) + : null; + if (!status.FoldReady) + { + status.DegradedReason = DegradationReasonCodes.BuildFoldNotReadyExplanation(status.FoldReadyReason); + status.RecommendedAction = BuildFoldBackfillCommand(_dbPath, _dbPathExplicit); + status.AlternativeAction = BuildFoldRebuildRepairCommand(status.ProjectRoot, _dbPath, _dbPathExplicit); + } + status.Summary = QueryCommandRunner.BuildStatusSummary(status); + var checkFailures = checkWorkspace + ? BuildMcpStatusCheckFailures(status, statusScopes) + : []; + if (checkWorkspace) + status.FailedChecks = checkFailures.Select(failure => failure.Name).ToList(); + + var structured = JsonSerializer.SerializeToNode(status, _jsonOptions)!.AsObject(); + structured["project_root"] = status.ProjectRoot; + structured["git_head"] = status.GitHead; + structured["git_is_dirty"] = status.GitIsDirty; + structured.Remove("hotspotFamilyReady"); + structured.Remove("hotspotFamilyDegradedReason"); + structured["sql_graph_contract_ready"] = status.SqlGraphContractReady; + if (status.SqlGraphContractDegradedReason != null) + structured["sql_graph_contract_degraded_reason"] = status.SqlGraphContractDegradedReason; + structured["mcp_session"] = BuildMcpSessionStatus(); + var rateLimitDiagnostics = RateLimiter.SnapshotDiagnostics(); + structured["mcp"] = new JsonObject + { + ["limits"] = new JsonObject + { + ["max_request_characters"] = MaxLineCharacterCount, + ["max_request_bytes"] = MaxLineByteLength, + ["max_response_bytes"] = GetMaxResponseBytes(), + ["max_configured_response_bytes"] = MaxConfiguredResponseBytes, + ["batch_response_bytes"] = GetBatchQueryResponseByteLimit(), + ["max_batch_response_bytes"] = MaxBatchQueryResponseByteLimit, + ["batch_query_response_bytes"] = GetBatchQueryResponseByteLimit(), + ["batch_query_max_response_bytes"] = MaxBatchQueryResponseByteLimit, + ["batch_query_max_queries"] = MaxBatchQuerySize, + ["max_pagination_offset"] = MaxMcpPaginationOffset, + ["max_json_depth"] = MaxJsonDepth, + ["max_batch_requests"] = MaxBatchRequestCount, + ["json_rpc_batch_max_requests"] = MaxBatchRequestCount, + ["keep_alive_min_interval_s"] = MinKeepAliveIntervalSeconds, + ["keep_alive_max_interval_s"] = MaxKeepAliveIntervalSeconds, + ["rate_limit_max_rps"] = RateLimiterOptions.MaxRefillTokensPerSecond, + ["rate_limit_max_burst"] = RateLimiterOptions.MaxBurstCapacity, + ["rate_limit_max_buckets"] = RateLimiterOptions.DefaultMaxBucketCount, + }, + ["rate_limit"] = new JsonObject + { + ["enabled"] = RateLimiter.Options.IsEnabled, + ["rps"] = RateLimiter.Options.RefillTokensPerSecond, + ["burst"] = RateLimiter.Options.BurstCapacity, + ["bucket_count"] = rateLimitDiagnostics.BucketCount, + ["bucket_limit"] = rateLimitDiagnostics.MaxBucketCount, + ["bucket_limit_rejection_count"] = rateLimitDiagnostics.BucketLimitRejectionCount, + ["bucket_idle_ttl_seconds"] = rateLimitDiagnostics.BucketIdleTtlSeconds, + ["next_prune_in_ms"] = rateLimitDiagnostics.NextPruneInMs, + ["last_prune_age_ms"] = rateLimitDiagnostics.LastPruneAgeMs.HasValue ? JsonValue.Create(rateLimitDiagnostics.LastPruneAgeMs.Value) : null, + ["last_pruned_bucket_count"] = rateLimitDiagnostics.LastPrunedBucketCount, + }, + ["request_timeouts"] = BuildRequestTimeoutDiagnosticsStatus(), + }; + var effectiveConfig = includeConfig + ? BuildMcpStatusEffectiveConfig(status, staleAfterSeconds, checkWorkspace, runUpdateCheck) + : null; + var logPath = includeLogPath ? GlobalToolLog.ResolveLogDirectoryForStatus() : null; + var explainPayload = explain is null + ? null + : BuildMcpStatusExplain(status, checkFailures, explain); + if (effectiveConfig is not null) + structured["effective_config"] = effectiveConfig.DeepClone(); + if (logPath is not null) + structured["log_path"] = logPath; + if (explainPayload is not null) + structured["explain"] = explainPayload.DeepClone(); + if (format == "compact") + { + structured = BuildMcpCompactStatusPayload(status, checkFailures); + if (effectiveConfig is not null) + structured["effective_config"] = effectiveConfig; + if (logPath is not null) + structured["log_path"] = logPath; + if (explainPayload is not null) + structured["explain"] = explainPayload; + } + if (projectionFields is not null) + { + EnrichToolStructuredContent(structured); + var projected = new JsonObject(); + foreach (var field in projectionFields) + { + if (!structured.TryGetPropertyValue(field, out var value)) + { + unavailableProjectionError = + $"Status field '{field}' is not available in {format} format. Use an exact top-level field name returned by that format."; + return new JsonObject(); + } + projected[field] = value?.DeepClone(); + } + if (!projected.ContainsKey("api_version")) + projected["api_version"] = structured["api_version"]!.DeepClone(); + structured = projected; + } + return CreateToolResult( + id, + "Database stats returned.", + structured, + enrichStructuredContent: projectionFields is null); + }); + return unavailableProjectionError is null + ? response + : CreateToolErrorResponse(id, unavailableProjectionError); + } + + private sealed record McpStatusCheckFailure(string Name, bool IsStale, string Diagnostic); + + private static bool TryReadStatusProjectionFields( + JsonNode? args, + out IReadOnlyList? fields, + out string? error) + { + fields = null; + error = null; + if (args is not JsonObject argsObject || !argsObject.ContainsKey("fields")) + return true; + + var node = argsObject["fields"]; + if (node is null) + { + error = "fields must be a non-empty string or string array."; + return false; + } + IEnumerable values = node is JsonArray array ? array : new JsonNode?[] { node }; + if (node is JsonArray fieldsArray + && (fieldsArray.Count == 0 || fieldsArray.Count > MaxStatusProjectionFields)) + { + error = $"fields must contain between 1 and {MaxStatusProjectionFields} entries."; + return false; + } + + var result = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var totalCharacters = 0; + foreach (var value in values) + { + if (value is not JsonValue jsonValue + || !jsonValue.TryGetValue(out var field) + || string.IsNullOrWhiteSpace(field)) + { + error = "fields entries must be non-empty strings."; + return false; + } + + field = field.Trim(); + if (field.Length > MaxStatusProjectionFieldCharacters) + { + error = $"fields entries must be no longer than {MaxStatusProjectionFieldCharacters} characters."; + return false; + } + if (field.Contains('.', StringComparison.Ordinal) + || field.Contains('[', StringComparison.Ordinal) + || field.Contains(']', StringComparison.Ordinal)) + { + error = "fields supports exact top-level field names only; nested field paths are not supported."; + return false; + } + + totalCharacters += field.Length; + if (totalCharacters > MaxStatusProjectionCharacters) + { + error = $"fields must contain no more than {MaxStatusProjectionCharacters} characters in total."; + return false; + } + if (seen.Add(field)) + result.Add(field); + } + + fields = result; + return true; + } + + private static bool TryReadStatusScopes(JsonNode? args, out HashSet? scopes, out string? error) + { + scopes = null; + error = null; + if (args?["scopes"] is null) + return true; + + var values = ReadStringOrArrayList(args, "scopes") + .Select(scope => scope.Trim().ToLowerInvariant()) + .ToList(); + if (args["scopes"] is JsonArray array && values.Count != array.Count) + { + error = "scopes entries must be non-empty strings."; + return false; + } + if (values.Count == 0) + { + error = "scopes cannot be empty or whitespace-only."; + return false; + } + + scopes = new HashSet(StringComparer.Ordinal); + foreach (var value in values) + { + if (!IsKnownMcpStatusScope(value)) + { + error = $"Invalid status scope '{value}'. Use one of: workspace, graph, issues, sql, hotspot, csharp, fold, newer."; + return false; + } + scopes.Add(value); + } + return true; + } + + private static bool IsKnownMcpStatusScope(string scope) => + scope is "workspace" or "graph" or "issues" or "sql" or "hotspot" or "csharp" or "fold" or "newer"; + + private static IReadOnlyList BuildMcpStatusCheckFailures(StatusResult status, IReadOnlySet? scopes) + { + var failures = new List(); + var checkAll = scopes is not { Count: > 0 }; + bool Includes(string scope) => checkAll || scopes!.Contains(scope); + + if (Includes("workspace")) + { + if (status.WorkspaceCheck?.Checked != true) + { + failures.Add(new McpStatusCheckFailure("workspace_unavailable", true, "[stale] workspace_check unavailable")); + } + else if (!status.WorkspaceCheck.MatchesWorkspace) + { + var check = status.WorkspaceCheck; + failures.Add(new McpStatusCheckFailure( + "workspace_stale", + true, + $"[stale] workspace_check reason={check.Reason} changed={check.ChangedFileCount} missing={check.MissingFileCount} unindexed={check.UnindexedFileCount}")); + } + } + + if (Includes("graph") && !status.GraphTableAvailable) + failures.Add(new McpStatusCheckFailure("graph_table_available", false, "[degraded] graph_table_available=false")); + if (Includes("issues") && !status.IssuesTableAvailable) + failures.Add(new McpStatusCheckFailure("issues_table_available", false, "[degraded] issues_table_available=false")); + if (Includes("issues") && status.IssuesTableAvailable && !status.FileIssuesDataCurrent) + failures.Add(new McpStatusCheckFailure("file_issues_data_current", false, "[degraded] file_issues_data_current=false")); + if (Includes("workspace") && status.MigrationInProgress) + failures.Add(new McpStatusCheckFailure("migration_in_progress", false, "[degraded] migration_in_progress=true")); + if (Includes("sql") && !status.SqlGraphContractReady) + failures.Add(new McpStatusCheckFailure("sql_graph_contract_ready", false, $"[degraded] sql_graph_contract_ready=false reason={status.SqlGraphContractDegradedReason ?? "unknown"}")); + if (Includes("hotspot") && !status.HotspotFamilyReady) + failures.Add(new McpStatusCheckFailure("hotspot_family_ready", false, $"[degraded] hotspot_family_ready=false reason={status.HotspotFamilyDegradedReason ?? "unknown"}")); + if (Includes("csharp") && !status.CSharpSymbolNameReady) + failures.Add(new McpStatusCheckFailure("csharp_symbol_name_ready", false, "[degraded] csharp_symbol_name_ready=false")); + if (Includes("csharp") && !status.CSharpMetadataTargetReady) + failures.Add(new McpStatusCheckFailure("csharp_metadata_target_ready", false, $"[degraded] csharp_metadata_target_ready=false reason={status.CSharpMetadataTargetDegradedReason ?? "unknown"}")); + if (Includes("fold") && !status.FoldReady) + failures.Add(new McpStatusCheckFailure("fold_ready", false, $"[degraded] fold_ready=false reason={status.FoldReadyReason ?? "unknown"}")); + if (Includes("newer") && status.IndexNewerThanReader) + failures.Add(new McpStatusCheckFailure("index_newer_than_reader", false, $"[degraded] index_newer_than_reader=true reason={status.IndexNewerThanReaderReason ?? "unknown"}")); + + return failures; + } + + private JsonObject BuildMcpStatusEffectiveConfig(StatusResult status, int staleAfterSeconds, bool checkWorkspace, bool runUpdateCheck) => new() + { + ["db_path"] = _dbPath, + ["db_explicit"] = _dbPathExplicit, + ["project_root"] = status.ProjectRoot, + ["data_dir"] = status.DataDir, + ["data_dir_source"] = status.DataDirSource, + ["global_tool_log_dir"] = GlobalToolLog.ResolveLogDirectoryForStatus(), + ["stale_after_seconds"] = staleAfterSeconds, + ["check"] = checkWorkspace, + ["update_check_requested"] = runUpdateCheck, + ["version"] = status.Version, + }; + + private JsonObject BuildMcpStatusExplain(StatusResult status, IReadOnlyList failures, string explain) + { + var payload = new JsonObject(); + if (explain is "freshness" or "all") + { + payload["freshness"] = new JsonObject + { + ["index_matches_workspace"] = status.IndexMatchesWorkspace.HasValue ? JsonValue.Create(status.IndexMatchesWorkspace.Value) : null, + ["stale_after_seconds"] = status.StaleAfterSeconds.HasValue ? JsonValue.Create(status.StaleAfterSeconds.Value) : null, + ["index_age_seconds"] = status.IndexAgeSeconds.HasValue ? JsonValue.Create(status.IndexAgeSeconds.Value) : null, + ["workspace_check"] = status.WorkspaceCheck is null ? null : JsonSerializer.SerializeToNode(status.WorkspaceCheck, _jsonOptions), + }; + } + if (explain is "readiness" or "all") + { + payload["readiness"] = BuildMcpStatusReadiness(status); + payload["failed_check_details"] = BuildMcpStatusFailureArray(failures); + } + return payload; + } + + private static JsonObject BuildMcpStatusReadiness(StatusResult status) => new() + { + ["graph_table_available"] = status.GraphTableAvailable, + ["issues_table_available"] = status.IssuesTableAvailable, + ["file_issues_data_current"] = status.FileIssuesDataCurrent, + ["sql_graph_contract_ready"] = status.SqlGraphContractReady, + ["hotspot_family_ready"] = status.HotspotFamilyReady, + ["csharp_symbol_name_ready"] = status.CSharpSymbolNameReady, + ["csharp_metadata_target_ready"] = status.CSharpMetadataTargetReady, + ["fold_ready"] = status.FoldReady, + ["index_newer_than_reader"] = status.IndexNewerThanReader, + ["migration_in_progress"] = status.MigrationInProgress, + }; + + private static JsonArray BuildMcpStatusFailureArray(IReadOnlyList failures) + { + var array = new JsonArray(); + foreach (var failure in failures) + { + array.Add(new JsonObject + { + ["name"] = failure.Name, + ["is_stale"] = failure.IsStale, + ["diagnostic"] = failure.Diagnostic, + }); + } + return array; + } + + private static JsonObject BuildMcpCompactStatusPayload(StatusResult status, IReadOnlyList failures) + { + var payload = new JsonObject + { + ["format"] = "compact", + ["summary"] = status.Summary, + ["version"] = status.Version, + ["project_root"] = status.ProjectRoot, + ["files"] = status.Files, + ["chunks"] = status.Chunks, + ["symbols"] = status.Symbols, + ["references"] = status.References, + ["symbol_kinds"] = JsonSerializer.SerializeToNode(status.SymbolKinds), + ["symbol_kind_limit"] = status.SymbolKindLimit, + ["symbol_kind_name_limit"] = status.SymbolKindNameLimit, + ["symbol_kind_total_count"] = status.SymbolKindTotalCount, + ["symbol_kind_omitted_count"] = status.SymbolKindOmittedCount, + ["symbol_kind_names_truncated"] = status.SymbolKindNamesTruncated, + ["language_count"] = status.Languages.Count, + ["top_languages"] = new JsonArray(status.Languages + .OrderByDescending(kv => kv.Value) + .ThenBy(kv => kv.Key, StringComparer.Ordinal) + .Take(5) + .Select(kv => new JsonObject { ["lang"] = kv.Key, ["files"] = kv.Value }) + .ToArray()), + ["git_head"] = status.GitHead, + ["git_is_dirty"] = status.GitIsDirty.HasValue ? JsonValue.Create(status.GitIsDirty.Value) : null, + ["index_matches_workspace"] = status.IndexMatchesWorkspace.HasValue ? JsonValue.Create(status.IndexMatchesWorkspace.Value) : null, + ["stale_after_seconds"] = status.StaleAfterSeconds.HasValue ? JsonValue.Create(status.StaleAfterSeconds.Value) : null, + ["index_age_seconds"] = status.IndexAgeSeconds.HasValue ? JsonValue.Create(status.IndexAgeSeconds.Value) : null, + ["failed_checks"] = new JsonArray(failures.Select(failure => JsonValue.Create(failure.Name)).ToArray()), + ["failed_check_details"] = BuildMcpStatusFailureArray(failures), + ["readiness"] = BuildMcpStatusReadiness(status), + }; + if (status.WorkspaceCheck is not null) + payload["workspace_check"] = JsonSerializer.SerializeToNode(status.WorkspaceCheck); + if (status.TrustOverrides is { Count: > 0 }) + payload["trust_overrides"] = JsonSerializer.SerializeToNode(status.TrustOverrides); + if (status.GitExecutable is not null) + payload["git_executable"] = JsonSerializer.SerializeToNode(status.GitExecutable); + return payload; + } + + private JsonObject BuildMcpSessionStatus() + { + var state = CurrentInitializeState; + McpSessionSnapshotCapturedForTests?.Invoke(); + var roots = new JsonArray(); + foreach (var root in state.ClientRootDiagnostics) + roots.Add(root); + + var session = new JsonObject + { + ["log_level"] = _mcpLogLevel, + ["roots"] = roots, + }; + if (state.ClientRootsTruncated) + { + session["roots_truncated"] = true; + session["root_count"] = state.ClientRootCount; + session["root_limit"] = MaxClientRootCount; + session["root_uri_length_limit"] = MaxClientRootUriChars; + } + if (state.ClientName is not null || state.ClientVersion is not null) + { + var clientInfo = new JsonObject(); + if (state.ClientNameDisplay is not null) + { + clientInfo["name"] = state.ClientName; + state.ClientNameDisplay.Value.AddMetadata(clientInfo, "name"); + } + if (state.ClientVersionDisplay is not null) + { + clientInfo["version"] = state.ClientVersion; + state.ClientVersionDisplay.Value.AddMetadata(clientInfo, "version"); + } + session["client_info"] = clientInfo; + } + if (state.ClientCapabilities is not null) + { + session["client_capabilities_summary"] = BuildClientCapabilitiesSummary(state, state.ClientCapabilities); + session["client_capabilities"] = state.ClientCapabilities.DeepClone(); + } + if (state.ClientCapabilitiesTruncationReason is not null) + { + session["client_capabilities_truncated"] = true; + session["client_capabilities_truncation_reason"] = state.ClientCapabilitiesTruncationReason; + if (state.ClientCapabilitiesSerializedBytes is { } serializedBytes) + session["client_capabilities_serialized_bytes"] = serializedBytes; + session["client_capabilities_byte_limit"] = MaxClientCapabilitiesJsonBytes; + session["client_capabilities_depth_limit"] = MaxClientCapabilitiesDepth; + if (!session.ContainsKey("client_capabilities_summary")) + session["client_capabilities_summary"] = BuildClientCapabilitiesSummary(state, state.ClientCapabilities); + } + if (_auditLog is not null) + session["audit_log"] = BuildAuditLogStatus(_auditLog.SnapshotDiagnostics()); + session["metrics"] = BuildMetricsStatus(MetricsSink.SnapshotDiagnostics()); + return session; + } + + private JsonObject BuildClientCapabilitiesSummary(InitializeSessionState state, JsonNode? capabilities) + { + var summary = new JsonObject + { + ["roots"] = state.ClientSupportsRoots, + ["sampling"] = state.ClientSupportsSampling, + ["truncated"] = state.ClientCapabilitiesTruncationReason is not null, + ["truncation_reason"] = state.ClientCapabilitiesTruncationReason, + }; + if (state.ClientCapabilitiesSerializedBytes is { } serializedBytes) + summary["serialized_bytes"] = serializedBytes; + if (capabilities is JsonObject obj) + { + summary["top_level_count"] = obj.Count; + summary["top_level_keys"] = new JsonArray(obj + .Select(kv => JsonValue.Create(McpBoundedText.ForDisplay(kv.Key, 64).Text)) + .Take(20) + .ToArray()); + summary["top_level_keys_truncated"] = obj.Count > 20; + if (obj["experimental"] is JsonObject experimental) + { + summary["experimental_count"] = experimental.Count; + summary["experimental_keys"] = new JsonArray(experimental + .Select(kv => JsonValue.Create(McpBoundedText.ForDisplay(kv.Key, 64).Text)) + .Take(20) + .ToArray()); + summary["experimental_keys_truncated"] = experimental.Count > 20; + } + } + return summary; + } + + private static bool IsAuditLogDegraded(AuditLogSink.AuditLogDiagnostics? diagnostics) + => diagnostics is not null + && (diagnostics.DroppedRecordCount > 0 + || diagnostics.RotationDegraded); + + private static JsonObject BuildAuditLogStatus(AuditLogSink.AuditLogDiagnostics diagnostics) + { + var payload = new JsonObject + { + ["enabled"] = true, + ["path"] = diagnostics.Path, + ["include_values"] = diagnostics.IncludeValues, + ["max_bytes"] = diagnostics.MaxBytes, + ["bytes_written"] = diagnostics.BytesWritten, + ["disposed"] = diagnostics.Disposed, + ["queue_capacity"] = diagnostics.QueueCapacity, + ["queue_depth"] = diagnostics.QueueDepth, + ["queued_record_count"] = diagnostics.QueuedRecordCount, + ["written_record_count"] = diagnostics.WrittenRecordCount, + ["dropped_record_count"] = diagnostics.DroppedRecordCount, + ["queue_full_drop_count"] = diagnostics.QueueFullDropCount, + ["serialization_failure_count"] = diagnostics.SerializationFailureCount, + ["write_failure_count"] = diagnostics.WriteFailureCount, + ["rotation_failure_count"] = diagnostics.RotationFailureCount, + ["rotation_cleanup_failure_count"] = diagnostics.RotationCleanupFailureCount, + ["rotation_degraded"] = diagnostics.RotationDegraded, + }; + if (!string.IsNullOrWhiteSpace(diagnostics.LastDropReason)) + payload["last_drop_reason"] = diagnostics.LastDropReason; + if (!string.IsNullOrWhiteSpace(diagnostics.LastRotationFailure)) + payload["last_rotation_failure"] = diagnostics.LastRotationFailure; + return payload; + } + + private static JsonObject BuildMetricsStatus(MetricsDiagnostics? diagnostics) + { + if (diagnostics is null) + return new JsonObject { ["enabled"] = false }; + + var payload = new JsonObject + { + ["enabled"] = true, + ["path"] = diagnostics.Path, + ["max_bytes"] = diagnostics.MaxBytes, + ["bytes_written"] = diagnostics.BytesWritten, + ["disposed"] = diagnostics.Disposed, + ["degraded"] = diagnostics.Degraded, + ["queue_capacity"] = diagnostics.QueueCapacity, + ["queue_depth"] = diagnostics.QueueDepth, + ["queued_event_count"] = diagnostics.QueuedEventCount, + ["written_event_count"] = diagnostics.WrittenEventCount, + ["dropped_event_count"] = diagnostics.DroppedEventCount, + ["queue_full_drop_count"] = diagnostics.QueueFullDropCount, + ["serialization_failure_count"] = diagnostics.SerializationFailureCount, + ["write_failure_count"] = diagnostics.WriteFailureCount, + ["rotation_failure_count"] = diagnostics.RotationFailureCount, + ["batch_flush_count"] = diagnostics.BatchFlushCount, + ["consecutive_failure_count"] = diagnostics.ConsecutiveFailureCount, + ["recovery_count"] = diagnostics.RecoveryCount, + }; + if (diagnostics.NextRetryAt is { } nextRetryAt) + payload["next_retry_at"] = nextRetryAt.ToString("O", CultureInfo.InvariantCulture); + if (diagnostics.LastRecoveryAt is { } lastRecoveryAt) + payload["last_recovery_at"] = lastRecoveryAt.ToString("O", CultureInfo.InvariantCulture); + if (!string.IsNullOrWhiteSpace(diagnostics.LastFailure)) + payload["last_failure"] = diagnostics.LastFailure; + return payload; + } + + private static string BuildFoldBackfillCommand(string dbPath, bool dbPathExplicit) + { + if (!dbPathExplicit) + return "cdidx backfill-fold"; + + return $"cdidx backfill-fold --db {QuoteCommandArgument(ResolveWritableDbPathOrPlaceholder(dbPath))}"; + } + + private static string BuildFoldRebuildRepairCommand(string? projectRoot, string dbPath, bool dbPathExplicit) + { + if (!dbPathExplicit) + return "cdidx index . --rebuild"; + + var resolvedDbPath = ResolveWritableDbPathOrPlaceholder(dbPath); + var targetProject = string.IsNullOrWhiteSpace(projectRoot) + ? "" + : QuoteCommandArgument(projectRoot); + return $"cdidx index {targetProject} --db {QuoteCommandArgument(resolvedDbPath)} --rebuild"; + } + + private static string ResolveWritableDbPathOrPlaceholder(string dbPath) + => DbPathResolver.TryResolveWritableMutationDbPath(dbPath, out var writableDbPath) + ? writableDbPath + : ""; + + private static string QuoteCommandArgument(string value) + { + if (value.Length >= 2 && value[0] == '<' && value[^1] == '>') + return value; + + var fullPath = DbPathResolver.NormalizeDbPath(value); + if (!fullPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + fullPath = Path.GetFullPath(fullPath); + + return fullPath.IndexOfAny([' ', '\t', '"']) >= 0 + ? $"\"{fullPath.Replace("\"", "\\\"", StringComparison.Ordinal)}\"" + : fullPath; + } + +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs new file mode 100644 index 000000000..fcc176f8c --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Symbols.cs @@ -0,0 +1,288 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + + private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) + { + var query = args?["query"]?.GetValue(); + if (query != null && query.Length > QueryLimits.MaxQueryLength) + return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); + + // Validate the raw `names` node before normalization so we can distinguish "property absent" + // from "property present but malformed/empty". ReadStringList alone silently drops both + // non-array shapes and blank entries, which would let invalid input fall through as an + // unfiltered full symbol dump. + // 生の `names` ノードを先に検証し、「未指定」と「指定ありだが不正/空」を区別する。 + // ReadStringList は非配列や空文字列を暗黙に無視するため、不正入力が無条件の全件検索に落ちるのを防ぐ。 + var namesNode = args?["names"]; + var namesProvided = namesNode is not null; + if (namesProvided && namesNode is not JsonArray) + return CreateToolErrorResponse(id, "'names' must be an array of strings."); + var names = ReadStringList(args, "names"); + foreach (var n in names) + { + if (n.Length > QueryLimits.MaxQueryLength) + return CreateToolErrorResponse(id, $"names entry too long (max {QueryLimits.MaxQueryLength} characters)"); + } + if (namesProvided && names.Count == 0) + return CreateToolErrorResponse(id, "'names' is present but contains no usable entries (all were empty or whitespace)."); + var adjustments = new ArgumentAdjustmentCollector(); + var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); + var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) + return maxLineWidthError; + var pathPatterns = ReadScopedPathList(args); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + if (!TryReadSinceArgument(args, out var since, out var sinceError)) + return CreateToolErrorResponse(id, sinceError!); + if (!TryResolveNameExactArgument(args, "symbols", out var exact, out var exactError)) + return CreateToolErrorResponse(id, exactError!); + var visibilityFilters = ReadStringOrCommaSeparatedList(args, "visibility"); + var excludeVisibilityFilters = ReadStringOrCommaSeparatedList(args, "excludeVisibility"); + var format = ReadResponseFormat(args); + if (ValidateResponseFormat(format) is string formatError) + return CreateToolErrorResponse(id, formatError); + var countOnly = ReadCountOnly(args) || format == "count"; + + // Merge query + names into a de-duplicated OR list. `|` is treated as a literal name character + // so operator symbols (e.g. `operator |`) stay searchable; multi-name must use repeated `names[]`. + // query と names を結合して重複排除。`|` は名前文字として扱い、`operator |` などを検索可能にする。 + var rawInputs = new List(); + if (query != null) + rawInputs.Add(query); + rawInputs.AddRange(names); + var hadExplicitNameInput = rawInputs.Count > 0; + var queriesForSearch = rawInputs.Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList(); + if (hadExplicitNameInput && queriesForSearch.Count == 0) + return CreateToolErrorResponse(id, "Symbol name list is empty after normalization. Check for empty 'names' entries or bare '|' separators."); + if (queriesForSearch.Count > QueryCommandRunner.MaxSymbolQueryNames) + return CreateToolErrorResponse(id, $"Too many symbol names ({queriesForSearch.Count}); maximum is {QueryCommandRunner.MaxSymbolQueryNames}. Split the request into smaller batches."); + IReadOnlyList? effectiveQueries = queriesForSearch.Count == 0 ? null : queriesForSearch; + + return WithDbReader(id, args, reader => + { + JsonNode? namesEcho = effectiveQueries == null ? null : JsonSerializer.SerializeToNode(effectiveQueries, _jsonOptions); + var hasExactPredicate = exact && effectiveQueries is { Count: > 0 }; + var exactSignal = reader.GetSymbolsExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, since); + if (countOnly) + { + var countSummary = reader.CountSearchSymbolsTotal(effectiveQueries, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); + var histogramResults = countSummary.Count > 0 + ? reader.SearchSymbols(effectiveQueries, Math.Min(countSummary.Count, MaxLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters) + : []; + var payload = BuildCountOnlyPayload(countSummary.Count, countSummary.Count, truncated: false, histogramResults, result => result.Path); + payload["query"] = query; + payload["names"] = namesEcho; + payload["kind"] = kind; + payload["lang"] = lang; + payload["path"] = PathEcho(pathPatterns); + payload["excludeTests"] = excludeTests; + AddVisibilityFilterEcho(payload, visibilityFilters, excludeVisibilityFilters); + if (hasExactPredicate) + AddExactGraphSignal(payload, exactSignal); + return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countSummary.Count, "symbol")}.", payload); + } + + var results = reader.SearchSymbols(effectiveQueries, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); + var multiNameExactHint = effectiveQueries != null && effectiveQueries.Count > 1; + var exactZeroHint = multiNameExactHint + ? QueryCommandRunner.BuildExactZeroHint( + exact, + () => reader.AnySearchSymbols(effectiveQueries, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), + () => reader.SearchSymbols(effectiveQueries, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), + r => r.Name) + : QueryCommandRunner.BuildExactZeroHint( + exact && effectiveQueries != null && effectiveQueries.Count > 0, + () => reader.CountSearchSymbols(effectiveQueries, QueryCommandRunner.ExactZeroHintProbeLimit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters) > 0, + () => reader.CountSearchSymbols(effectiveQueries, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), + () => reader.SearchSymbols(effectiveQueries, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), + r => r.Name); + if (results.Count == 0) + { + var payload = new JsonObject + { + ["query"] = query, + ["names"] = namesEcho, + ["kind"] = kind, + ["lang"] = lang, + ["path"] = PathEcho(pathPatterns), + ["excludeTests"] = excludeTests, + ["count"] = 0, + ["results"] = new JsonArray() + }; + AddVisibilityFilterEcho(payload, visibilityFilters, excludeVisibilityFilters); + if (hasExactPredicate) + AddExactGraphSignal(payload, exactSignal); + AddExactZeroHint(payload, exactZeroHint); + AddFreshnessHint(payload, reader); + adjustments.ApplyTo(payload); + return CreateToolResult(id, "No symbols found.", payload); + } + + var structured = new JsonObject + { + ["query"] = query, + ["names"] = namesEcho, + ["kind"] = kind, + ["lang"] = lang, + ["path"] = PathEcho(pathPatterns), + ["excludeTests"] = excludeTests, + ["count"] = results.Count, + ["results"] = ToJsonArray(results) + }; + AddVisibilityFilterEcho(structured, visibilityFilters, excludeVisibilityFilters); + if (format == "compact") + { + structured["results"] = BuildCompactSymbolRows(results); + structured["format"] = "compact"; + } + if (hasExactPredicate) + AddExactGraphSignal(structured, exactSignal); + var topSymbol = results[0]; + AddNextStepSuggestion( + structured, + "definition", + new JsonObject { ["query"] = topSymbol.Name, ["limit"] = 5, ["exactName"] = true }, + "Use definition to confirm the declaration for the best symbol candidate; then use references, callers, or callees depending on the change."); + adjustments.ApplyTo(structured); + return CreateToolResult(id, ConsoleUi.FoundSummary(results.Count, "symbol"), structured); + }); + } + + private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) + { + if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) + return CreateToolErrorResponse(id, requiredError!); + if (query.Length > QueryLimits.MaxQueryLength) + return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); + if (IsBareVerbatimQueryToken(query)) + return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); + + var adjustments = new ArgumentAdjustmentCollector(); + var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); + var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); + var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); + var includeBody = args?["includeBody"]?.GetValue() ?? false; + if (!TryReadLspCompatibleArgument(args, out var lspCompatible, out var lspCompatibleError)) + return CreateToolErrorResponse(id, lspCompatibleError!); + var pathPatterns = ReadScopedPathList(args); + var excludePaths = ReadStringList(args, "excludePaths"); + var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + if (!TryReadSinceArgument(args, out var since, out var sinceError)) + return CreateToolErrorResponse(id, sinceError!); + if (!TryResolveNameExactArgument(args, "definition", out var exact, out var exactError)) + return CreateToolErrorResponse(id, exactError!); + var visibilityFilters = ReadStringOrCommaSeparatedList(args, "visibility"); + var excludeVisibilityFilters = ReadStringOrCommaSeparatedList(args, "excludeVisibility"); + var format = ReadResponseFormat(args); + if (ValidateResponseFormat(format) is string formatError) + return CreateToolErrorResponse(id, formatError); + + return WithDbReader(id, args, reader => + { + var results = reader.GetDefinitions(query, FetchLimitForEnvelope(limit), kind, lang, includeBody, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); + var truncated = TrimToRequestedLimit(results, limit); + if (format == "count") + { + var total = truncated + ? reader.CountDefinitionsTotal(query, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters).Count + : results.Count; + var countPayload = BuildCountOnlyPayload(total, total, truncated: false, results, result => result.Path); + countPayload["query"] = query; + countPayload["kind"] = kind; + countPayload["lang"] = lang; + countPayload["path"] = PathEcho(pathPatterns); + countPayload["excludeTests"] = excludeTests; + AddVisibilityFilterEcho(countPayload, visibilityFilters, excludeVisibilityFilters); + adjustments.ApplyTo(countPayload); + return CreateToolResult(id, $"Counted {ConsoleUi.Counted(total, "definition")}.", countPayload); + } + if (lspCompatible) + QueryCommandRunner.AttachLspLocations(results); + ApplyExcerptRecoveryDbPath(results); + var exactSignal = reader.GetDefinitionExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, since); + var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( + exact, + () => reader.CountSearchSymbols(query, QueryCommandRunner.ExactZeroHintProbeLimit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters) > 0, + () => reader.CountSearchSymbols(query, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), + () => reader.SearchSymbols(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), + r => r.Name); + var payload = new JsonObject + { + ["query"] = query, + ["kind"] = kind, + ["lang"] = lang, + ["includeBody"] = includeBody, + ["lspCompatible"] = lspCompatible, + ["path"] = PathEcho(pathPatterns), + ["excludeTests"] = excludeTests, + ["results"] = ToJsonArray(results) + }; + AddVisibilityFilterEcho(payload, visibilityFilters, excludeVisibilityFilters); + AddResultEnvelope(payload, results.Count, truncated ? null : results.Count, truncated); + if (format == "compact") + ApplyCompactResults(payload, results, result => result.Path, result => result.StartLine); + if (exact) + AddExactGraphSignal(payload, exactSignal); + if (results.Count == 0) + { + AddExactZeroHint(payload, exactZeroHint); + AddSymbolRecoveryHint(payload, query, "definition", lang, kind, PathEcho(pathPatterns)); + AddFreshnessHint(payload, reader); + } + else + { + AddNextStepSuggestion( + payload, + "references", + new JsonObject { ["query"] = results[0].Name, ["limit"] = 5, ["exactName"] = true }, + "Use references to inspect usage sites before changing this definition; then use excerpt for the relevant definition or reference ranges."); + } + adjustments.ApplyTo(payload); + return CreateToolResult(id, + ConsoleUi.FoundSummary(results.Count, "definition"), + payload); + }); + } + + private void ApplyExcerptRecoveryDbPath(IEnumerable results) + { + foreach (var result in results) + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, _dbPath); + } + + private void ApplyExcerptRecoveryDbPath(IEnumerable results) + { + foreach (var result in results) + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, _dbPath); + } + + private void ApplyExcerptRecoveryDbPath(IEnumerable results) + { + foreach (var result in results) + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, _dbPath); + } + + private void ApplyExcerptRecoveryDbPath(IEnumerable results) + { + foreach (var result in results) + ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, _dbPath); + } + +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.QueryTools.cs b/src/CodeIndex/Mcp/McpToolHandlers.QueryTools.cs deleted file mode 100644 index 0b4722103..000000000 --- a/src/CodeIndex/Mcp/McpToolHandlers.QueryTools.cs +++ /dev/null @@ -1,2997 +0,0 @@ -using System.Globalization; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; -using CodeIndex.Cli; -using CodeIndex.Database; -using CodeIndex.Diagnostics; -using CodeIndex.Indexer; -using CodeIndex.Indexer.Extensibility; -using CodeIndex.Indexer.Hooks; -using CodeIndex.Models; - -namespace CodeIndex.Mcp; - -public partial class McpServer -{ - private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) - { - var listRecipes = args?["listRecipes"]?.GetValue() ?? false; - if (listRecipes) - return ExecuteSearchRecipeList(id); - - var recipeNode = args?["recipe"]; - if (recipeNode is not null) - { - var recipeName = recipeNode.GetValue(); - if (string.IsNullOrWhiteSpace(recipeName)) - return CreateToolErrorResponse(id, "'recipe' must be a non-empty search recipe name."); - return ExecuteSearchRecipe(id, args, recipeName.Trim()); - } - - if (args?["auditScope"] is not null) - return CreateToolErrorResponse(id, "'auditScope' is only supported with recipe execution."); - - if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) - return CreateToolErrorResponse(id, requiredError!); - if (query.Length > QueryLimits.MaxQueryLength) - return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); - - var adjustments = new ArgumentAdjustmentCollector(); - var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); - var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var snippetLines = ReadSnippetLines(args, SearchSnippetFormatter.DefaultSnippetLines, adjustments); - var snippetFocusText = args?["snippetFocus"]?.GetValue() ?? "quality"; - if (!QueryCommandRunner.TryParseSnippetFocusMode(snippetFocusText, out var snippetFocus)) - return CreateToolErrorResponse(id, "snippetFocus must be one of quality, leftmost, proximity"); - if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) - return maxLineWidthError; - var rawQuery = args?["rawQuery"]?.GetValue() ?? false; - SearchCursor? cursor = null; - var cursorValue = args?["cursor"]?.GetValue(); - if (!string.IsNullOrWhiteSpace(cursorValue)) - { - if (!TryParseSearchCursor(cursorValue, out var parsedCursor)) - return CreateToolErrorResponse(id, "'cursor' must be a search pagination cursor returned as `next_cursor` by a previous search response."); - cursor = parsedCursor; - } - var pathPatterns = ReadScopedPathList(args); - var excludePaths = ReadStringList(args, "excludePaths"); - var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - if (!TryReadSinceArgument(args, out var since, out var sinceError)) - return CreateToolErrorResponse(id, sinceError!); - var deduplicate = !(args?["noDedup"]?.GetValue() ?? false); - var format = ReadResponseFormat(args); - if (ValidateResponseFormat(format) is string formatError) - return CreateToolErrorResponse(id, formatError); - var countOnly = ReadCountOnly(args) || format == "count"; - if (!TryResolveSearchExactArgument(args, out var exact, out var exactError)) - return CreateToolErrorResponse(id, exactError!); - var tokenBoundary = args?["tokenBoundary"]?.GetValue() ?? false; - var exactSearch = exact || tokenBoundary; - var prefix = args?["prefix"]?.GetValue() ?? false; - if (tokenBoundary && rawQuery) - return CreateToolErrorResponse(id, "'tokenBoundary' cannot be combined with 'rawQuery'."); - if (prefix && exactSearch) - return CreateToolErrorResponse(id, "'prefix' cannot be combined with 'exact' / 'exactSubstring' / 'tokenBoundary' (exact uses instr(), not FTS5 prefix phrases)."); - if (TryReadSearchGuardFilters(id, args, out var guardFilters) is JsonNode guardError) - return guardError; - if (TryReadSearchGuardScope(id, args, out var guardScope) is JsonNode guardScopeError) - return guardScopeError; - var guardWindow = ReadOptionalIntArgument(args, "guardWindow") ?? DbReader.DefaultSearchGuardWindow; - if (guardWindow < 0 || guardWindow > DbReader.MaxSearchGuardWindow) - return CreateToolErrorResponse(id, $"'guardWindow' must be between 0 and {DbReader.MaxSearchGuardWindow}; got {guardWindow}."); - var suggestExactSubstring = SearchQueryAdvisor.ShouldSuggestExactSubstring(query, rawQuery, exactSearch, prefix); - - return WithDbReader(id, args, reader => - { - if (countOnly) - { - List countResults; - try - { - countResults = reader.Search(query, MaxLimit, lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exactSearch, prefix, guardFilters: guardFilters, guardWindow: guardWindow, guardScope: guardScope, tokenBoundary: tokenBoundary); - } - catch (SearchQueryLimitException) - { - return CreateToolErrorResponse(id, FormatLiteralSearchQueryLimitError()); - } - catch (SearchGuardCandidateLimitException ex) - { - return CreateToolErrorResponse(id, FormatSearchGuardCandidateLimitError(ex)); - } - var truncatedCount = countResults.Count >= MaxLimit; - var payload = BuildCountOnlyPayload(countResults.Count, truncatedCount ? null : countResults.Count, truncatedCount, countResults, result => result.Path); - payload["query"] = query; - payload["rawQuery"] = rawQuery; - if (tokenBoundary) - payload["tokenBoundary"] = true; - payload["snippetFocus"] = snippetFocusText.Trim().ToLowerInvariant(); - payload["path"] = PathEcho(pathPatterns); - payload["excludeTests"] = excludeTests; - AddSearchStabilityMetadata(payload, reader, cursor, []); - if (suggestExactSubstring) - AddExactSubstringRecoveryHint(payload, query); - if (countResults.Count == 0) - AddFtsQueryDiagnostics(payload, DbReader.AnalyzeFtsQuery(query, rawQuery, prefix, lang)); - adjustments.ApplyTo(payload); - return CreateToolResult(id, $"Counted {countResults.Count} search result(s).", payload); - } - - List results; - try - { - results = reader.Search(query, FetchLimitForEnvelope(limit), lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exactSearch, prefix, cursor: cursor, guardFilters: guardFilters, guardWindow: guardWindow, guardScope: guardScope, tokenBoundary: tokenBoundary); - } - catch (SearchQueryLimitException) - { - return CreateToolErrorResponse(id, FormatLiteralSearchQueryLimitError()); - } - catch (SearchGuardCandidateLimitException ex) - { - return CreateToolErrorResponse(id, FormatSearchGuardCandidateLimitError(ex)); - } - var ftsDiagnostics = DbReader.AnalyzeFtsQuery(query, rawQuery, prefix, lang); - var truncated = TrimToRequestedLimit(results, limit); - if (results.Count == 0) - { - var payload = new JsonObject - { - ["query"] = query, - ["rawQuery"] = rawQuery, - ["tokenBoundary"] = tokenBoundary, - ["snippetLines"] = snippetLines, - ["snippetFocus"] = snippetFocusText.Trim().ToLowerInvariant(), - ["maxLineWidth"] = maxLineWidth, - ["path"] = PathEcho(pathPatterns), - ["excludeTests"] = excludeTests, - ["results"] = new JsonArray() - }; - AddSearchStabilityMetadata(payload, reader, cursor, results); - AddFtsQueryDiagnostics(payload, ftsDiagnostics); - AddResultEnvelope(payload, 0, 0, truncated: false); - if (suggestExactSubstring) - { - AddExactSubstringRecoveryHint(payload, query); - } - else - { - AddRecoveryHint( - payload, - "no_results", - "search returned no rows; try removing lang/path filters, using prefix for token-prefix matches, or using exactSubstring for literal punctuation or emoji.", - "search", - new JsonObject { ["query"] = query, ["limit"] = 5 }); - } - AddFreshnessHint(payload, reader); - adjustments.ApplyTo(payload); - return CreateToolResult(id, "No results found.", payload); - } - - var queryContext = SearchSnippetFormatter.PrepareQueryContext(query); - var compactResults = SearchSnippetFormatter - .ToCompactResults(results, queryContext, snippetLines, exactSearch, maxLineWidth, lang, snippetFocus, exposeLiteralHighlights: exactSearch) - .ToList(); - foreach (var compact in compactResults) - SearchSnippetFormatter.ApplyOutputMetadata(compact, snippetLines, maxLineWidth, exactSearch, rawQuery); - var structured = new JsonObject - { - ["query"] = query, - ["rawQuery"] = rawQuery, - ["tokenBoundary"] = tokenBoundary, - ["cursor"] = cursorValue, - ["snippetLines"] = snippetLines, - ["snippetFocus"] = snippetFocusText.Trim().ToLowerInvariant(), - ["maxLineWidth"] = maxLineWidth, - ["path"] = PathEcho(pathPatterns), - ["excludeTests"] = excludeTests, - ["results"] = ToJsonArray(compactResults) - }; - AddSearchStabilityMetadata(structured, reader, cursor, results, truncated); - AddResultEnvelope(structured, results.Count, truncated ? null : results.Count, truncated); - if (format == "compact") - ApplyCompactResults( - structured, - compactResults, - result => result.Path, - result => result.MatchLines.Count > 0 ? result.MatchLines[0] : result.ChunkStartLine); - var topResult = results[0]; - AddNextStepSuggestion( - structured, - "excerpt", - BuildExcerptArgs(topResult.Path, topResult.StartLine, topResult.EndLine), - "Use excerpt on the top hit before editing; for symbol changes, follow with definition or references to confirm declarations and usage sites."); - if (suggestExactSubstring) - AddExactSubstringRecoveryHint(structured, query); - adjustments.ApplyTo(structured); - // Include top file paths in summary for quick AI orientation - // AIが素早く位置把握できるよう、サマリにトップファイルパスを含める - var topPaths = results.Select(r => r.Path).Distinct().Take(3); - var summary = $"Found {results.Count} search result(s) in {string.Join(", ", topPaths)}."; - return CreateToolResult(id, summary, structured); - }); - } - - private JsonNode ExecuteSearchRecipeList(JsonNode? id) - { - var registry = SearchAuditRecipes.Load(); - var payload = new JsonObject - { - ["count"] = registry.Recipes.Count, - ["recipes"] = ToSearchRecipeArray(registry.Recipes) - }; - AddSearchRecipeSourceDiagnostics(payload, registry.Diagnostics); - return CreateToolResult(id, $"Found {registry.Recipes.Count} search recipe(s).", payload); - } - - private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipeName) - { - var registry = SearchAuditRecipes.Load(); - var recipe = registry.Recipes.FirstOrDefault(r => string.Equals(r.Name, recipeName, StringComparison.OrdinalIgnoreCase)); - if (recipe is null) - { - var available = string.Join(", ", registry.Recipes.Select(r => r.Name)); - return CreateToolErrorResponse(id, $"unknown search recipe '{recipeName}'. Available recipes: {available}."); - } - - var adjustments = new ArgumentAdjustmentCollector(); - var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); - var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var snippetLines = ReadSnippetLines(args, SearchSnippetFormatter.DefaultSnippetLines, adjustments); - if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) - return maxLineWidthError; - var pathPatterns = ReadScopedPathList(args); - List requestedPathPatterns = pathPatterns is null ? [] : [.. pathPatterns]; - var excludePaths = ReadStringList(args, "excludePaths"); - var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - if (!TryResolveMcpRecipeAuditScope(args, recipe, ref pathPatterns, excludePaths, ref excludeTests, out var auditScope, out var auditScopeError)) - return CreateToolErrorResponse(id, auditScopeError!); - if (!TryReadSinceArgument(args, out var since, out var sinceError)) - return CreateToolErrorResponse(id, sinceError!); - var deduplicate = !(args?["noDedup"]?.GetValue() ?? false); - if (args?["tokenBoundary"]?.GetValue() ?? false) - return CreateToolErrorResponse(id, "'tokenBoundary' is only supported for ad hoc search, not recipe execution."); - if (!TryResolveSearchExactArgument(args, out var userExact, out var exactError)) - return CreateToolErrorResponse(id, exactError!); - var hasExactOverride = args?["exact"] is not null || args?["exactSubstring"] is not null; - if (args?["prefix"]?.GetValue() ?? false) - return CreateToolErrorResponse(id, "'prefix' cannot be combined with recipe execution."); - if (args?["cursor"] is not null) - return CreateToolErrorResponse(id, "'cursor' is not supported for recipe execution."); - if (TryReadSearchGuardFilters(id, args, out var guardFilters) is JsonNode guardError) - return guardError; - if (TryReadSearchGuardScope(id, args, out var guardScope) is JsonNode guardScopeError) - return guardScopeError; - var guardWindow = ReadOptionalIntArgument(args, "guardWindow") ?? DbReader.DefaultSearchGuardWindow; - if (guardWindow < 0 || guardWindow > DbReader.MaxSearchGuardWindow) - return CreateToolErrorResponse(id, $"'guardWindow' must be between 0 and {DbReader.MaxSearchGuardWindow}; got {guardWindow}."); - - return WithDbReader(id, args, reader => - { - var queryResults = new JsonArray(); - var total = 0; - foreach (var recipeQuery in recipe.Queries) - { - var exact = hasExactOverride ? userExact : recipeQuery.ExactSubstring; - ResolveMcpRecipeQueryScope( - recipeQuery, - pathPatterns, - excludePaths, - out var queryPathPatterns, - out var queryExcludePaths); - var requiredPathPatterns = GetMcpSearchRecipeRequiredPathPatterns(requestedPathPatterns, recipeQuery); - List results; - try - { - results = reader.Search( - recipeQuery.Query, - FetchLimitForSearchRecipeEnvelope(limit), - lang, - false, - queryPathPatterns, - queryExcludePaths, - excludeTests, - deduplicate, - since, - exact, - false, - guardFilters: guardFilters, - guardWindow: guardWindow, - guardScope: guardScope, - requiredPathPatterns: requiredPathPatterns, - resultRanking: recipeQuery.ResultRanking); - } - catch (SearchQueryLimitException) - { - return CreateToolErrorResponse(id, FormatLiteralSearchQueryLimitError()); - } - catch (SearchGuardCandidateLimitException ex) - { - return CreateToolErrorResponse(id, FormatSearchRecipeGuardCandidateLimitError(recipe.Name, recipeQuery.Name, ex)); - } - - var queryContext = SearchSnippetFormatter.PrepareQueryContext(recipeQuery.Query); - var compactResults = SearchSnippetFormatter - .ToCompactResults(results, queryContext, snippetLines, exact, maxLineWidth, exposeLiteralHighlights: exact) - .Where(result => MatchesRecipeFacetMetadata(result, recipeQuery)) - .ToList(); - var truncated = TrimToRequestedLimit(compactResults, limit); - foreach (var compact in compactResults) - SearchSnippetFormatter.ApplyOutputMetadata(compact, snippetLines, maxLineWidth, exact, rawFts: false); - total += compactResults.Count; - queryResults.Add(new JsonObject - { - ["name"] = recipeQuery.Name, - ["query"] = recipeQuery.Query, - ["description"] = recipeQuery.Description, - ["recommended_labels"] = ToJsonArray(recipeQuery.RecommendedLabels), - ["false_positive_guidance"] = recipeQuery.FalsePositiveGuidance, - ["exact_substring"] = exact, - ["match_origins"] = ToJsonArray(recipeQuery.MatchOrigins), - ["exclude_origins"] = ToJsonArray(recipeQuery.ExcludeOrigins), - ["result_kinds"] = ToJsonArray(recipeQuery.ResultKinds), - ["count"] = compactResults.Count, - ["top_files"] = BuildTopFileHistogram(compactResults, result => result.Path), - ["truncated"] = truncated, - ["results"] = ToJsonArray(compactResults) - }); - } - - var payload = new JsonObject - { - ["recipe"] = ToSearchRecipeJson(recipe), - ["query_count"] = recipe.Queries.Count, - ["result_count"] = total, - ["limit_per_query"] = limit, - ["snippetLines"] = snippetLines, - ["maxLineWidth"] = maxLineWidth, - ["lang"] = lang, - ["audit_scope"] = auditScope, - ["path"] = PathEcho(pathPatterns), - ["excludePaths"] = PathEcho(excludePaths), - ["excludeTests"] = excludeTests, - ["queries"] = queryResults - }; - AddFreshnessHint(payload, reader); - AddSearchRecipeSourceDiagnostics(payload, registry.Diagnostics); - adjustments.ApplyTo(payload); - var summary = total == 0 - ? $"Recipe '{recipe.Name}' returned no search results." - : $"Recipe '{recipe.Name}' returned {total} search result(s) across {recipe.Queries.Count} query(ies)."; - return CreateToolResult(id, summary, payload); - }); - } - - private static bool TryResolveMcpRecipeAuditScope( - JsonNode? args, - SearchAuditRecipe recipe, - ref List? pathPatterns, - List excludePaths, - ref bool excludeTests, - out string auditScope, - out string? error) - { - var requestedScope = args?["auditScope"]?.GetValue(); - auditScope = string.IsNullOrWhiteSpace(requestedScope) - ? recipe.DefaultScope - : requestedScope.Trim(); - error = null; - - if (!string.Equals(auditScope, SearchAuditRecipes.DefaultAuditScope, StringComparison.Ordinal) - && !string.Equals(auditScope, SearchAuditRecipes.AllAuditScope, StringComparison.Ordinal)) - { - error = "'auditScope' must be either 'source' or 'all'."; - return false; - } - - if (string.Equals(auditScope, SearchAuditRecipes.DefaultAuditScope, StringComparison.Ordinal)) - { - if ((pathPatterns is null || pathPatterns.Count == 0) && recipe.DefaultPathPatterns.Count > 0) - pathPatterns = [.. recipe.DefaultPathPatterns]; - AddDistinct(excludePaths, recipe.DefaultExcludePaths); - excludeTests = true; - } - - return true; - } - - private static void ResolveMcpRecipeQueryScope( - SearchAuditRecipeQuery query, - List? recipePathPatterns, - List recipeExcludePaths, - out List? queryPathPatterns, - out List queryExcludePaths) - { - queryPathPatterns = query.PathPatterns.Count > 0 - ? [.. query.PathPatterns] - : recipePathPatterns is null ? null : [.. recipePathPatterns]; - queryExcludePaths = [.. recipeExcludePaths]; - AddDistinct(queryExcludePaths, query.ExcludePaths); - } - - private static IReadOnlyList? GetMcpSearchRecipeRequiredPathPatterns( - IReadOnlyList requestedPathPatterns, - SearchAuditRecipeQuery query) - => requestedPathPatterns.Count > 0 && query.PathPatterns.Count > 0 - ? requestedPathPatterns - : null; - - private static void AddDistinct(List target, IEnumerable values) - { - foreach (var value in values) - { - if (!target.Contains(value, StringComparer.Ordinal)) - target.Add(value); - } - } - - private JsonArray ToSearchRecipeArray(IEnumerable recipes) - => new(recipes.Select(recipe => ToSearchRecipeJson(recipe)).ToArray()); - - private JsonObject ToSearchRecipeJson(SearchAuditRecipe recipe) - => new() - { - ["name"] = recipe.Name, - ["description"] = recipe.Description, - ["recommended_labels"] = ToJsonArray(recipe.RecommendedLabels), - ["default_scope"] = recipe.DefaultScope, - ["default_path_patterns"] = ToJsonArray(recipe.DefaultPathPatterns), - ["default_exclude_paths"] = ToJsonArray(recipe.DefaultExcludePaths), - ["queries"] = new JsonArray(recipe.Queries.Select(query => new JsonObject - { - ["name"] = query.Name, - ["query"] = query.Query, - ["description"] = query.Description, - ["recommended_labels"] = ToJsonArray(query.RecommendedLabels), - ["false_positive_guidance"] = query.FalsePositiveGuidance, - ["exact_substring"] = query.ExactSubstring - }).ToArray()) - }; - - private static void AddSearchRecipeSourceDiagnostics(JsonObject payload, IReadOnlyList diagnostics) - { - if (diagnostics.Count == 0) - return; - payload["recipe_source_diagnostics"] = new JsonArray(diagnostics.Select(diagnostic => JsonValue.Create(diagnostic)).ToArray()); - } - - private JsonNode ExecuteSymbols(JsonNode? id, JsonNode? args) - { - var query = args?["query"]?.GetValue(); - if (query != null && query.Length > QueryLimits.MaxQueryLength) - return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); - - // Validate the raw `names` node before normalization so we can distinguish "property absent" - // from "property present but malformed/empty". ReadStringList alone silently drops both - // non-array shapes and blank entries, which would let invalid input fall through as an - // unfiltered full symbol dump. - // 生の `names` ノードを先に検証し、「未指定」と「指定ありだが不正/空」を区別する。 - // ReadStringList は非配列や空文字列を暗黙に無視するため、不正入力が無条件の全件検索に落ちるのを防ぐ。 - var namesNode = args?["names"]; - var namesProvided = namesNode is not null; - if (namesProvided && namesNode is not JsonArray) - return CreateToolErrorResponse(id, "'names' must be an array of strings."); - var names = ReadStringList(args, "names"); - foreach (var n in names) - { - if (n.Length > QueryLimits.MaxQueryLength) - return CreateToolErrorResponse(id, $"names entry too long (max {QueryLimits.MaxQueryLength} characters)"); - } - if (namesProvided && names.Count == 0) - return CreateToolErrorResponse(id, "'names' is present but contains no usable entries (all were empty or whitespace)."); - var adjustments = new ArgumentAdjustmentCollector(); - var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); - var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); - if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) - return maxLineWidthError; - var pathPatterns = ReadScopedPathList(args); - var excludePaths = ReadStringList(args, "excludePaths"); - var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - if (!TryReadSinceArgument(args, out var since, out var sinceError)) - return CreateToolErrorResponse(id, sinceError!); - if (!TryResolveNameExactArgument(args, "symbols", out var exact, out var exactError)) - return CreateToolErrorResponse(id, exactError!); - var visibilityFilters = ReadStringOrCommaSeparatedList(args, "visibility"); - var excludeVisibilityFilters = ReadStringOrCommaSeparatedList(args, "excludeVisibility"); - var format = ReadResponseFormat(args); - if (ValidateResponseFormat(format) is string formatError) - return CreateToolErrorResponse(id, formatError); - var countOnly = ReadCountOnly(args) || format == "count"; - - // Merge query + names into a de-duplicated OR list. `|` is treated as a literal name character - // so operator symbols (e.g. `operator |`) stay searchable; multi-name must use repeated `names[]`. - // query と names を結合して重複排除。`|` は名前文字として扱い、`operator |` などを検索可能にする。 - var rawInputs = new List(); - if (query != null) - rawInputs.Add(query); - rawInputs.AddRange(names); - var hadExplicitNameInput = rawInputs.Count > 0; - var queriesForSearch = rawInputs.Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList(); - if (hadExplicitNameInput && queriesForSearch.Count == 0) - return CreateToolErrorResponse(id, "Symbol name list is empty after normalization. Check for empty 'names' entries or bare '|' separators."); - if (queriesForSearch.Count > QueryCommandRunner.MaxSymbolQueryNames) - return CreateToolErrorResponse(id, $"Too many symbol names ({queriesForSearch.Count}); maximum is {QueryCommandRunner.MaxSymbolQueryNames}. Split the request into smaller batches."); - IReadOnlyList? effectiveQueries = queriesForSearch.Count == 0 ? null : queriesForSearch; - - return WithDbReader(id, args, reader => - { - JsonNode? namesEcho = effectiveQueries == null ? null : JsonSerializer.SerializeToNode(effectiveQueries, _jsonOptions); - var hasExactPredicate = exact && effectiveQueries is { Count: > 0 }; - var exactSignal = reader.GetSymbolsExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, since); - if (countOnly) - { - var countSummary = reader.CountSearchSymbolsTotal(effectiveQueries, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); - var histogramResults = countSummary.Count > 0 - ? reader.SearchSymbols(effectiveQueries, Math.Min(countSummary.Count, MaxLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters) - : []; - var payload = BuildCountOnlyPayload(countSummary.Count, countSummary.Count, truncated: false, histogramResults, result => result.Path); - payload["query"] = query; - payload["names"] = namesEcho; - payload["kind"] = kind; - payload["lang"] = lang; - payload["path"] = PathEcho(pathPatterns); - payload["excludeTests"] = excludeTests; - AddVisibilityFilterEcho(payload, visibilityFilters, excludeVisibilityFilters); - if (hasExactPredicate) - AddExactGraphSignal(payload, exactSignal); - return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countSummary.Count, "symbol")}.", payload); - } - - var results = reader.SearchSymbols(effectiveQueries, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); - var multiNameExactHint = effectiveQueries != null && effectiveQueries.Count > 1; - var exactZeroHint = multiNameExactHint - ? QueryCommandRunner.BuildExactZeroHint( - exact, - () => reader.AnySearchSymbols(effectiveQueries, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), - () => reader.SearchSymbols(effectiveQueries, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), - r => r.Name) - : QueryCommandRunner.BuildExactZeroHint( - exact && effectiveQueries != null && effectiveQueries.Count > 0, - () => reader.CountSearchSymbols(effectiveQueries, QueryCommandRunner.ExactZeroHintProbeLimit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters) > 0, - () => reader.CountSearchSymbols(effectiveQueries, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), - () => reader.SearchSymbols(effectiveQueries, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), - r => r.Name); - if (results.Count == 0) - { - var payload = new JsonObject - { - ["query"] = query, - ["names"] = namesEcho, - ["kind"] = kind, - ["lang"] = lang, - ["path"] = PathEcho(pathPatterns), - ["excludeTests"] = excludeTests, - ["count"] = 0, - ["results"] = new JsonArray() - }; - AddVisibilityFilterEcho(payload, visibilityFilters, excludeVisibilityFilters); - if (hasExactPredicate) - AddExactGraphSignal(payload, exactSignal); - AddExactZeroHint(payload, exactZeroHint); - AddFreshnessHint(payload, reader); - adjustments.ApplyTo(payload); - return CreateToolResult(id, "No symbols found.", payload); - } - - var structured = new JsonObject - { - ["query"] = query, - ["names"] = namesEcho, - ["kind"] = kind, - ["lang"] = lang, - ["path"] = PathEcho(pathPatterns), - ["excludeTests"] = excludeTests, - ["count"] = results.Count, - ["results"] = ToJsonArray(results) - }; - AddVisibilityFilterEcho(structured, visibilityFilters, excludeVisibilityFilters); - if (format == "compact") - { - structured["results"] = BuildCompactSymbolRows(results); - structured["format"] = "compact"; - } - if (hasExactPredicate) - AddExactGraphSignal(structured, exactSignal); - var topSymbol = results[0]; - AddNextStepSuggestion( - structured, - "definition", - new JsonObject { ["query"] = topSymbol.Name, ["limit"] = 5, ["exactName"] = true }, - "Use definition to confirm the declaration for the best symbol candidate; then use references, callers, or callees depending on the change."); - adjustments.ApplyTo(structured); - return CreateToolResult(id, ConsoleUi.FoundSummary(results.Count, "symbol"), structured); - }); - } - - private JsonNode ExecuteDefinition(JsonNode? id, JsonNode? args) - { - if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) - return CreateToolErrorResponse(id, requiredError!); - if (query.Length > QueryLimits.MaxQueryLength) - return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); - if (IsBareVerbatimQueryToken(query)) - return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); - - var adjustments = new ArgumentAdjustmentCollector(); - var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); - var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); - var includeBody = args?["includeBody"]?.GetValue() ?? false; - if (!TryReadLspCompatibleArgument(args, out var lspCompatible, out var lspCompatibleError)) - return CreateToolErrorResponse(id, lspCompatibleError!); - var pathPatterns = ReadScopedPathList(args); - var excludePaths = ReadStringList(args, "excludePaths"); - var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - if (!TryReadSinceArgument(args, out var since, out var sinceError)) - return CreateToolErrorResponse(id, sinceError!); - if (!TryResolveNameExactArgument(args, "definition", out var exact, out var exactError)) - return CreateToolErrorResponse(id, exactError!); - var visibilityFilters = ReadStringOrCommaSeparatedList(args, "visibility"); - var excludeVisibilityFilters = ReadStringOrCommaSeparatedList(args, "excludeVisibility"); - var format = ReadResponseFormat(args); - if (ValidateResponseFormat(format) is string formatError) - return CreateToolErrorResponse(id, formatError); - - return WithDbReader(id, args, reader => - { - var results = reader.GetDefinitions(query, FetchLimitForEnvelope(limit), kind, lang, includeBody, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); - var truncated = TrimToRequestedLimit(results, limit); - if (format == "count") - { - var total = truncated - ? reader.CountDefinitionsTotal(query, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters).Count - : results.Count; - var countPayload = BuildCountOnlyPayload(total, total, truncated: false, results, result => result.Path); - countPayload["query"] = query; - countPayload["kind"] = kind; - countPayload["lang"] = lang; - countPayload["path"] = PathEcho(pathPatterns); - countPayload["excludeTests"] = excludeTests; - AddVisibilityFilterEcho(countPayload, visibilityFilters, excludeVisibilityFilters); - adjustments.ApplyTo(countPayload); - return CreateToolResult(id, $"Counted {ConsoleUi.Counted(total, "definition")}.", countPayload); - } - if (lspCompatible) - QueryCommandRunner.AttachLspLocations(results); - ApplyExcerptRecoveryDbPath(results); - var exactSignal = reader.GetDefinitionExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, since); - var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( - exact, - () => reader.CountSearchSymbols(query, QueryCommandRunner.ExactZeroHintProbeLimit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters) > 0, - () => reader.CountSearchSymbols(query, limit, kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), - () => reader.SearchSymbols(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), kind, lang, pathPatterns, excludePaths, excludeTests, since, exact: false, visibilityFilters: visibilityFilters, excludeVisibilityFilters: excludeVisibilityFilters), - r => r.Name); - var payload = new JsonObject - { - ["query"] = query, - ["kind"] = kind, - ["lang"] = lang, - ["includeBody"] = includeBody, - ["lspCompatible"] = lspCompatible, - ["path"] = PathEcho(pathPatterns), - ["excludeTests"] = excludeTests, - ["results"] = ToJsonArray(results) - }; - AddVisibilityFilterEcho(payload, visibilityFilters, excludeVisibilityFilters); - AddResultEnvelope(payload, results.Count, truncated ? null : results.Count, truncated); - if (format == "compact") - ApplyCompactResults(payload, results, result => result.Path, result => result.StartLine); - if (exact) - AddExactGraphSignal(payload, exactSignal); - if (results.Count == 0) - { - AddExactZeroHint(payload, exactZeroHint); - AddSymbolRecoveryHint(payload, query, "definition", lang, kind, PathEcho(pathPatterns)); - AddFreshnessHint(payload, reader); - } - else - { - AddNextStepSuggestion( - payload, - "references", - new JsonObject { ["query"] = results[0].Name, ["limit"] = 5, ["exactName"] = true }, - "Use references to inspect usage sites before changing this definition; then use excerpt for the relevant definition or reference ranges."); - } - adjustments.ApplyTo(payload); - return CreateToolResult(id, - ConsoleUi.FoundSummary(results.Count, "definition"), - payload); - }); - } - - private void ApplyExcerptRecoveryDbPath(IEnumerable results) - { - foreach (var result in results) - ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, _dbPath); - } - - private void ApplyExcerptRecoveryDbPath(IEnumerable results) - { - foreach (var result in results) - ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, _dbPath); - } - - private void ApplyExcerptRecoveryDbPath(IEnumerable results) - { - foreach (var result in results) - ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, _dbPath); - } - - private void ApplyExcerptRecoveryDbPath(IEnumerable results) - { - foreach (var result in results) - ExcerptRecoveryCommandFormatter.ApplyDbPath(result.BodyContentRecovery, result.Path, _dbPath); - } - - private JsonNode ExecuteReferences(JsonNode? id, JsonNode? args) - { - if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) - return CreateToolErrorResponse(id, requiredError!); - if (query.Length > QueryLimits.MaxQueryLength) - return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); - if (IsBareVerbatimQueryToken(query)) - return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); - - var adjustments = new ArgumentAdjustmentCollector(); - var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); - var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); - if (!TryReadLspCompatibleArgument(args, out var lspCompatible, out var lspCompatibleError)) - return CreateToolErrorResponse(id, lspCompatibleError!); - var offset = ReadOffset(args, adjustments); - if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) - return maxLineWidthError; - var pathPatterns = ReadScopedPathList(args); - var excludePaths = ReadStringList(args, "excludePaths"); - var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - var format = ReadResponseFormat(args); - if (ValidateResponseFormat(format) is string formatError) - return CreateToolErrorResponse(id, formatError); - var countOnly = ReadCountOnly(args) || format == "count"; - if (!TryResolveNameExactArgument(args, "references", out var exact, out var exactError)) - return CreateToolErrorResponse(id, exactError!); - - return WithDbReader(id, args, reader => - { - if (countOnly) - { - var countOnlyTotal = reader.CountSearchReferencesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count; - var histogramResults = countOnlyTotal > 0 - ? reader.SearchReferences(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth) - : []; - var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); - countOnlyPayload["query"] = query; - countOnlyPayload["kind"] = kind; - countOnlyPayload["lang"] = lang; - countOnlyPayload["path"] = PathEcho(pathPatterns); - countOnlyPayload["excludeTests"] = excludeTests; - AddHdlGraphContractSignal( - countOnlyPayload, - reader.GetHdlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests)); - adjustments.ApplyTo(countOnlyPayload); - return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "reference")}.", countOnlyPayload); - } - - var results = reader.SearchReferences(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth, offset: offset); - var truncated = TrimToRequestedLimit(results, limit); - var total = truncated || offset > 0 - ? reader.CountSearchReferencesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact).Count - : results.Count; - if (lspCompatible) - QueryCommandRunner.AttachLspLocations(results); - var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); - var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( - reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), - results.Select(result => result.Lang), - lang, - graphSupport.GraphLanguage); - var exactSignal = reader.GetReferencesExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); - var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( - exact && reader._hasReferencesTable, - () => reader.CountSearchReferences(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false) > 0, - () => reader.CountSearchReferences(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false), - () => reader.SearchReferences(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false), - r => r.SymbolName); - var payload = new JsonObject - { - ["query"] = query, - ["kind"] = kind, - ["lang"] = lang, - ["lspCompatible"] = lspCompatible, - ["maxLineWidth"] = maxLineWidth, - ["path"] = PathEcho(pathPatterns), - ["excludeTests"] = excludeTests, - ["graph_language"] = graphSupport.GraphLanguage, - ["graph_supported"] = graphSupport.GraphSupported, - ["graph_support_reason"] = graphSupport.GraphSupportReason, - ["results"] = ToJsonArray(results) - }; - AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); - if (format == "compact") - ApplyCompactResults(payload, results, result => result.Path, result => result.Line, result => result.Column); - if (exact) - AddExactGraphSignal(payload, exactSignal); - AddSqlGraphContractSignal(payload, sqlGraphSignal); - AddHdlGraphContractSignal( - payload, - reader.GetHdlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests)); - if (results.Count == 0) - { - AddExactZeroHint(payload, exactZeroHint); - AddSymbolRecoveryHint(payload, query, "references", lang, kind, PathEcho(pathPatterns)); - AddFreshnessHint(payload, reader); - } - else - { - var topReference = results[0]; - AddNextStepSuggestion( - payload, - "excerpt", - BuildExcerptArgs(topReference.Path, topReference.Line, topReference.Line), - "Use excerpt on representative usage sites before editing; use callers or callees when you need call graph impact."); - } - adjustments.ApplyTo(payload); - return CreateToolResult(id, - BuildGraphSummary("reference", "references", results.Count, graphSupport.GraphLanguage, graphSupport.GraphSupported, graphSupport.GraphSupportReason), - payload); - }); - } - - private JsonNode ExecuteCallers(JsonNode? id, JsonNode? args) - { - if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) - return CreateToolErrorResponse(id, requiredError!); - if (query.Length > QueryLimits.MaxQueryLength) - return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); - if (IsBareVerbatimQueryToken(query)) - return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); - - var adjustments = new ArgumentAdjustmentCollector(); - var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); - if (IsNonCallGraphReferenceKind(kind)) - return CreateToolErrorResponse(id, BuildNonCallGraphKindRejectionMessage("callers", kind!)); - var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); - var offset = ReadOffset(args, adjustments); - var pathPatterns = ReadScopedPathList(args); - var excludePaths = ReadStringList(args, "excludePaths"); - var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - if (!TryResolveNameExactArgument(args, "callers", out var exact, out var exactError)) - return CreateToolErrorResponse(id, exactError!); - if (!TryReadReferenceRankMode(args, out var rankMode, out var rankModeError)) - return CreateToolErrorResponse(id, rankModeError!); - var format = ReadResponseFormat(args); - if (ValidateResponseFormat(format) is string formatError) - return CreateToolErrorResponse(id, formatError); - var countOnly = ReadCountOnly(args) || format == "count"; - var rawKinds = args?["rawKinds"]?.GetValue() ?? false; - - return WithDbReader(id, args, reader => - { - if (countOnly) - { - var countOnlyTotal = reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count; - var histogramResults = countOnlyTotal > 0 - ? reader.GetCallers(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode) - : []; - var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); - countOnlyPayload["query"] = query; - countOnlyPayload["kind"] = kind; - countOnlyPayload["rawKinds"] = rawKinds; - countOnlyPayload["lang"] = lang; - countOnlyPayload["path"] = PathEcho(pathPatterns); - countOnlyPayload["excludeTests"] = excludeTests; - AddReferenceGraphCompletenessSignal( - countOnlyPayload, - reader, - lang, - pathPatterns, - excludePaths, - excludeTests); - adjustments.ApplyTo(countOnlyPayload); - return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "caller")}.", countOnlyPayload); - } - - var results = reader.GetCallers(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, offset: offset); - var truncated = TrimToRequestedLimit(results, limit); - var total = truncated || offset > 0 - ? reader.CountCallersTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count - : results.Count; - var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); - var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( - reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), - results.Select(result => result.Lang), - lang, - graphSupport.GraphLanguage); - var exactSignal = reader.GetCallersExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); - var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( - exact && reader._hasReferencesTable, - () => reader.CountCallers(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds) > 0, - () => reader.CountCallers(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds), - () => reader.GetCallers(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, rankMode: rankMode), - r => r.CalleeName); - var payload = new JsonObject - { - ["query"] = query, - ["kind"] = kind, - ["rawKinds"] = rawKinds, - ["lang"] = lang, - ["path"] = PathEcho(pathPatterns), - ["excludeTests"] = excludeTests, - ["rankBy"] = QueryCommandRunner.FormatReferenceRankMode(rankMode), - ["graph_language"] = graphSupport.GraphLanguage, - ["graph_supported"] = graphSupport.GraphSupported, - ["graph_support_reason"] = graphSupport.GraphSupportReason, - ["results"] = ToJsonArray(results) - }; - AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); - if (format == "compact") - ApplyCompactResults(payload, results, result => result.Path, result => result.FirstLine); - payload["aggregate_truncated"] = results.Any(result => result.AggregateTruncated); - if (exact) - AddExactGraphSignal(payload, exactSignal); - AddSqlGraphContractSignal(payload, sqlGraphSignal); - AddReferenceGraphCompletenessSignal( - payload, - reader, - lang, - pathPatterns, - excludePaths, - excludeTests); - if (results.Count == 0) - { - AddExactZeroHint(payload, exactZeroHint); - AddSymbolRecoveryHint(payload, query, "callers", lang, kind, PathEcho(pathPatterns)); - AddFreshnessHint(payload, reader); - } - else - { - var topCaller = results[0]; - AddNextStepSuggestion( - payload, - "excerpt", - BuildExcerptArgs(topCaller.Path, topCaller.FirstLine, topCaller.FirstLine), - "Use excerpt on a caller row to understand the concrete call site before widening impact analysis or editing."); - } - adjustments.ApplyTo(payload); - return CreateToolResult(id, - BuildGraphSummary("caller", "callers", results.Count, graphSupport.GraphLanguage, graphSupport.GraphSupported, graphSupport.GraphSupportReason), - payload); - }); - } - - private JsonNode ExecuteCallees(JsonNode? id, JsonNode? args) - { - if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) - return CreateToolErrorResponse(id, requiredError!); - if (query.Length > QueryLimits.MaxQueryLength) - return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); - if (IsBareVerbatimQueryToken(query)) - return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); - - var adjustments = new ArgumentAdjustmentCollector(); - var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); - if (IsNonCallGraphReferenceKind(kind)) - return CreateToolErrorResponse(id, BuildNonCallGraphKindRejectionMessage("callees", kind!)); - var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); - var offset = ReadOffset(args, adjustments); - var pathPatterns = ReadScopedPathList(args); - var excludePaths = ReadStringList(args, "excludePaths"); - var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - if (!TryResolveNameExactArgument(args, "callees", out var exact, out var exactError)) - return CreateToolErrorResponse(id, exactError!); - if (!TryReadReferenceRankMode(args, out var rankMode, out var rankModeError)) - return CreateToolErrorResponse(id, rankModeError!); - var format = ReadResponseFormat(args); - if (ValidateResponseFormat(format) is string formatError) - return CreateToolErrorResponse(id, formatError); - var countOnly = ReadCountOnly(args) || format == "count"; - var rawKinds = args?["rawKinds"]?.GetValue() ?? false; - - return WithDbReader(id, args, reader => - { - if (countOnly) - { - var countOnlyTotal = reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count; - var histogramResults = countOnlyTotal > 0 - ? reader.GetCallees(query, Math.Min(countOnlyTotal, MaxLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode) - : []; - var countOnlyPayload = BuildCountOnlyPayload(countOnlyTotal, countOnlyTotal, truncated: false, histogramResults, result => result.Path); - countOnlyPayload["query"] = query; - countOnlyPayload["kind"] = kind; - countOnlyPayload["rawKinds"] = rawKinds; - countOnlyPayload["lang"] = lang; - countOnlyPayload["path"] = PathEcho(pathPatterns); - countOnlyPayload["excludeTests"] = excludeTests; - AddReferenceGraphCompletenessSignal( - countOnlyPayload, - reader, - lang, - pathPatterns, - excludePaths, - excludeTests); - adjustments.ApplyTo(countOnlyPayload); - return CreateToolResult(id, $"Counted {ConsoleUi.Counted(countOnlyTotal, "callee")}.", countOnlyPayload); - } - - var results = reader.GetCallees(query, FetchLimitForEnvelope(limit), lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds, rankMode: rankMode, offset: offset); - var truncated = TrimToRequestedLimit(results, limit); - var total = truncated || offset > 0 - ? reader.CountCalleesTotal(query, lang, kind, pathPatterns, excludePaths, excludeTests, exact, rawKinds).Count - : results.Count; - var graphSupport = ResolveGraphSupport(reader, exact, query, lang, pathPatterns, excludePaths, excludeTests); - var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignalByLanguages( - reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), - results.Select(result => result.Lang), - lang, - graphSupport.GraphLanguage); - var exactSignal = reader.GetCalleesExactQuerySignal(lang, pathPatterns, excludePaths, excludeTests, includeSqlGraphContractSignal: sqlGraphSignal.Relevant); - var exactZeroHint = QueryCommandRunner.BuildExactZeroHint( - exact && reader._hasReferencesTable, - () => reader.CountCallees(query, QueryCommandRunner.ExactZeroHintProbeLimit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds) > 0, - () => reader.CountCallees(query, limit, lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds), - () => reader.GetCallees(query, Math.Min(limit, QueryCommandRunner.ExactZeroHintSampleLimit), lang, kind, pathPatterns, excludePaths, excludeTests, exact: false, rawKinds: rawKinds, rankMode: rankMode), - r => r.CallerName); - var payload = new JsonObject - { - ["query"] = query, - ["kind"] = kind, - ["rawKinds"] = rawKinds, - ["lang"] = lang, - ["path"] = PathEcho(pathPatterns), - ["excludeTests"] = excludeTests, - ["rankBy"] = QueryCommandRunner.FormatReferenceRankMode(rankMode), - ["graph_language"] = graphSupport.GraphLanguage, - ["graph_supported"] = graphSupport.GraphSupported, - ["graph_support_reason"] = graphSupport.GraphSupportReason, - ["results"] = ToJsonArray(results) - }; - AddPaginatedResultEnvelope(payload, results.Count, total, truncated, offset); - if (format == "compact") - ApplyCompactResults(payload, results, result => result.Path, result => result.FirstLine); - payload["aggregate_truncated"] = results.Any(result => result.AggregateTruncated); - if (exact) - AddExactGraphSignal(payload, exactSignal); - AddSqlGraphContractSignal(payload, sqlGraphSignal); - AddReferenceGraphCompletenessSignal( - payload, - reader, - lang, - pathPatterns, - excludePaths, - excludeTests); - if (results.Count == 0) - { - AddExactZeroHint(payload, exactZeroHint); - AddSymbolRecoveryHint(payload, query, "callees", lang, kind, PathEcho(pathPatterns)); - AddFreshnessHint(payload, reader); - } - else - { - var topCallee = results[0]; - AddNextStepSuggestion( - payload, - "excerpt", - BuildExcerptArgs(topCallee.Path, topCallee.FirstLine, topCallee.FirstLine), - "Use excerpt on a callee row to inspect the concrete dependency before changing the caller or callee."); - } - adjustments.ApplyTo(payload); - return CreateToolResult(id, - BuildGraphSummary("callee", "callees", results.Count, graphSupport.GraphLanguage, graphSupport.GraphSupported, graphSupport.GraphSupportReason), - payload); - }); - } - - private JsonNode ExecuteFiles(JsonNode? id, JsonNode? args) - { - var query = args?["query"]?.GetValue(); - if (query != null && query.Length > QueryLimits.MaxQueryLength) - return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); - var adjustments = new ArgumentAdjustmentCollector(); - var lang = QueryCommandRunner.NormalizeLangFilterValue(args?["lang"]?.GetValue()); - var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); - var pathPatterns = ReadScopedPathList(args); - var excludePaths = ReadStringList(args, "excludePaths"); - var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - if (!TryReadSinceArgument(args, out var since, out var sinceError)) - return CreateToolErrorResponse(id, sinceError!); - var orderBySize = args?["orderBySize"]?.GetValue() ?? false; - var rawBytes = args?["rawBytes"]?.GetValue() ?? false; - - return WithDbReader(id, args, reader => - { - var results = reader.ListFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, since, orderBySize || rawBytes); - if (results.Count == 0) - { - var payload = new JsonObject - { - ["query"] = query, - ["lang"] = lang, - ["path"] = PathEcho(pathPatterns), - ["excludeTests"] = excludeTests, - ["orderBySize"] = orderBySize, - ["rawBytes"] = rawBytes, - ["count"] = 0, - ["results"] = new JsonArray() - }; - if (rawBytes) - { - payload["raw_bytes_payload_supported"] = false; - payload["raw_bytes_note"] = "MCP returns indexed file size metadata; raw file bytes are not returned."; - } - AddFreshnessHint(payload, reader); - adjustments.ApplyTo(payload); - return CreateToolResult(id, "No files found.", payload); - } - - var structured = new JsonObject - { - ["query"] = query, - ["lang"] = lang, - ["path"] = PathEcho(pathPatterns), - ["excludeTests"] = excludeTests, - ["orderBySize"] = orderBySize, - ["rawBytes"] = rawBytes, - ["count"] = results.Count, - ["results"] = JsonSerializer.SerializeToNode(results, _jsonOptions) - }; - if (rawBytes) - { - structured["raw_bytes_payload_supported"] = false; - structured["raw_bytes_note"] = "MCP returns indexed file size metadata; raw file bytes are not returned."; - } - adjustments.ApplyTo(structured); - return CreateToolResult(id, ConsoleUi.FoundSummary(results.Count, "file"), structured); - }); - } - - private JsonNode ExecuteMap(JsonNode? id, JsonNode? args) - { - var adjustments = new ArgumentAdjustmentCollector(); - var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); - var limit = ReadLimit(args, QueryCommandRunner.DefaultMapLimit, adjustments); - var pathPatterns = ReadScopedPathList(args); - var excludePaths = ReadStringList(args, "excludePaths"); - var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - var sections = ReadStringList(args, "sections").Select(section => section.ToLowerInvariant()).ToHashSet(StringComparer.Ordinal); - var depth = ReadMapDepth(args, adjustments); - var minEntrypointConfidence = args?["minEntrypointConfidence"]?.GetValue() ?? 0; - if (minEntrypointConfidence is < 0 or > 1) - return CreateToolErrorResponse(id, "minEntrypointConfidence must be between 0.0 and 1.0"); - - return WithDbReader(id, args, reader => - { - var map = reader.GetRepoMap( - limit, - lang, - pathPatterns, - excludePaths, - excludeTests, - minEntrypointConfidence, - moduleDepth: depth); - WorkspaceMetadataEnricher.Enrich(map, _dbPath, _dbPathExplicit); - var structured = JsonSerializer.SerializeToNode(map, _jsonOptions)!.AsObject(); - if (depth is >= 0) - structured["depth"] = depth.Value; - if (sections.Count > 0) - ApplyMapSectionFilter(structured, sections); - structured["limit"] = limit; - structured["lang"] = lang; - structured["path"] = PathEcho(pathPatterns); - structured["excludeTests"] = excludeTests; - structured["minEntrypointConfidence"] = minEntrypointConfidence; - var hasFilter = (pathPatterns is { Count: > 0 }) || excludePaths.Count > 0 || excludeTests || lang != null; - if (map.FileCount == 0 && hasFilter) - AddFreshnessHint(structured, reader); - adjustments.ApplyTo(structured); - var summary = map.FileCount > 0 - ? "Repo map returned." - : hasFilter ? "No files found matching the given filters." : "Repo map returned."; - return CreateToolResult(id, summary, structured); - }); - } - - private static void ApplyMapSectionFilter(JsonObject structured, IReadOnlySet sections) - { - var keep = new HashSet(StringComparer.Ordinal) - { - "api_version", "fileCount", "totalLines", "totalSymbols", "totalReferences", - "indexedAt", "latestModified", "workspaceIndexedAt", "workspaceLatestModified", - "projectRoot", "gitHead", "gitIsDirty", "indexed_head_commit", "indexed_head_sha", - "indexed_head_branch", "indexed_head_timestamp", "commits_ahead_of_indexed_head", - "worktree_head_changed", "head_freshness", - "graphTableAvailable", "limit", "lang", "path", "excludeTests", "depth", "minEntrypointConfidence", - }; - foreach (var section in sections) - AddMapSectionStructuredProperties(keep, section); - foreach (var key in structured.Select(property => property.Key).Where(key => !keep.Contains(key)).ToList()) - structured.Remove(key); - structured["sections"] = new JsonArray(sections.Select(section => JsonValue.Create(section)).ToArray()); - structured["sectionProperties"] = BuildMapSectionStructuredProperties(sections); - } - - private static readonly IReadOnlyDictionary MapSectionStructuredProperties = new Dictionary(StringComparer.Ordinal) - { - ["languages"] = ["languages"], - ["tree"] = ["modules"], - ["modules"] = ["modules"], - ["hotspots"] = ["topFiles", "symbolRichFiles", "referenceRichFiles", "entrypoints"], - ["metrics"] = ["largestFiles"], - }; - - private static void AddMapSectionStructuredProperties(HashSet keep, string section) - { - if (!MapSectionStructuredProperties.TryGetValue(section, out var properties)) - return; - - foreach (var property in properties) - keep.Add(property); - } - - private static JsonObject BuildMapSectionStructuredProperties(IReadOnlySet sections) - { - var payload = new JsonObject(); - foreach (var section in sections) - { - if (!MapSectionStructuredProperties.TryGetValue(section, out var properties)) - continue; - - payload[section] = new JsonArray(properties.Select(property => JsonValue.Create(property)).ToArray()); - } - - return payload; - } - - private JsonNode ExecuteAnalyzeSymbol(JsonNode? id, JsonNode? args) - { - if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) - return CreateToolErrorResponse(id, requiredError!); - if (query.Length > QueryLimits.MaxQueryLength) - return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); - if (IsBareVerbatimQueryToken(query)) - return CreateToolErrorResponse(id, "Add a real symbol name after the command; bare verbatim prefixes like `@` are not valid queries."); - - var adjustments = new ArgumentAdjustmentCollector(); - var limit = ReadLimit(args, QueryCommandRunner.DefaultMapLimit, adjustments); - var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); - var includeBody = args?["includeBody"]?.GetValue() ?? false; - if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) - return maxLineWidthError; - var pathPatterns = ReadScopedPathList(args); - var excludePaths = ReadStringList(args, "excludePaths"); - var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - if (!TryResolveNameExactArgument(args, "analyze_symbol", out var exact, out var exactError)) - return CreateToolErrorResponse(id, exactError!); - var format = ReadResponseFormat(args); - if (ValidateResponseFormat(format) is string formatError) - return CreateToolErrorResponse(id, formatError); - var countOnly = ReadCountOnly(args) || format == "count"; - - return WithDbReader(id, args, reader => - { - var analysis = reader.AnalyzeSymbol(query, limit, lang, includeBody, pathPatterns, excludePaths, excludeTests, exact, maxLineWidth); - var sqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignal( - reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests), - DbReader.IsSqlLanguage(lang) - || DbReader.IsSqlLanguage(analysis.GraphLanguage) - || DbReader.IsSqlLanguage(analysis.File?.Lang) - || DbReader.ContainsSqlLanguage(analysis.Definitions.Select(definition => definition.Lang)) - || DbReader.ContainsSqlLanguage(analysis.References.Select(reference => reference.Lang)) - || DbReader.ContainsSqlLanguage(analysis.Callers.Select(caller => caller.Lang)) - || DbReader.ContainsSqlLanguage(analysis.Callees.Select(callee => callee.Lang))); - analysis.SqlGraphContractReady = sqlGraphSignal.Relevant ? sqlGraphSignal.Ready : null; - analysis.SqlGraphContractDegradedReason = sqlGraphSignal.Relevant ? sqlGraphSignal.DegradedReason : null; - WorkspaceMetadataEnricher.Enrich(analysis, _dbPath, _dbPathExplicit); - ApplyExcerptRecoveryDbPath(analysis.Definitions); - ApplyExcerptRecoveryDbPath(analysis.References); - ApplyExcerptRecoveryDbPath(analysis.Callers); - ApplyExcerptRecoveryDbPath(analysis.Callees); - var pathEcho = PathEcho(pathPatterns); - var structured = countOnly - ? BuildAnalyzeSymbolCountPayload(analysis, lang, pathEcho, excludeTests, maxLineWidth) - : format == "compact" - ? BuildAnalyzeSymbolCompactPayload(analysis, lang, pathEcho, excludeTests, maxLineWidth) - : ToAnalyzeSymbolJsonObject(analysis); - AddSqlGraphContractSignal(structured, sqlGraphSignal); - AddHdlGraphContractSignal( - structured, - reader.GetHdlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests)); - structured.Remove("exactZeroHint"); - AddExactZeroHint(structured, analysis.ExactZeroHint); - structured["maxLineWidth"] = maxLineWidth; - structured["lang"] = lang; - structured["path"] = pathEcho; - structured["excludeTests"] = excludeTests; - adjustments.ApplyTo(structured); - return CreateToolResult(id, BuildAnalyzeSymbolSummary(analysis), structured); - }); - } - - private static string BuildAnalyzeSymbolSummary(SymbolAnalysisResult analysis) - { - if (analysis.ExactZeroHint != null) - { - var relaxedCount = analysis.ExactZeroHint.RelaxedCount ?? analysis.ExactZeroHint.SampleNames.Count; - return $"Symbol analysis returned. Substring would return {ConsoleUi.Counted(relaxedCount, "similarly named symbol")}."; - } - - return "Symbol analysis returned."; - } - - private static void AddExactGraphSignal(JsonObject payload, ExactQuerySignal signal) - { - payload["exact_index_available"] = signal.ExactIndexAvailable; - if (signal.DegradedReason != null) - payload["degraded_reason"] = signal.DegradedReason; - // MCP uses snake_case response keys consistently; do not add camelCase aliases here. - } - - private static void AddSqlGraphContractSignal(JsonObject payload, SqlGraphContractSignal signal) - { - if (!signal.Relevant) - return; - - payload["sql_graph_contract_ready"] = signal.Ready; - if (!signal.Ready) - { - payload["degraded"] = true; - if (signal.DegradedReason != null) - { - payload["sql_graph_contract_degraded_reason"] = signal.DegradedReason; - } - } - } - - private static void AddHdlGraphContractSignal(JsonObject payload, HdlGraphContractSignal signal) - { - if (!signal.Relevant) - return; - - payload["hdl_graph_contract_ready"] = signal.Ready; - if (!signal.Ready) - { - payload["degraded"] = true; - if (signal.DegradedReason != null) - payload["hdl_graph_contract_degraded_reason"] = signal.DegradedReason; - } - } - - private void AddReferenceGraphCompletenessSignal( - JsonObject payload, - DbReader reader, - string? lang = null, - IReadOnlyList? pathPatterns = null, - IReadOnlyList? excludePaths = null, - bool excludeTests = false) - { - AddHdlGraphContractSignal( - payload, - reader.GetHdlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests)); - AddReferenceGraphCompletenessSignal( - payload, - reader, - reader.GetReferenceExtractionCapHits()); - } - - private void AddReferenceGraphCompletenessSignal( - JsonObject payload, - DbReader reader, - ReferenceExtractionCapHitSummary capHits) - { - var complete = reader.IsReferenceGraphComplete(capHits); - var incompleteReasons = reader.GetReferenceGraphIncompleteReasons(capHits); - payload["reference_extraction_limits"] = JsonSerializer.SerializeToNode( - ReferenceExtractor.GetSafetyLimits(), - _jsonOptions); - payload["reference_graph_complete"] = complete; - payload["reference_extraction_cap_hits"] = JsonSerializer.SerializeToNode( - capHits, - _jsonOptions); - if (!complete) - { - payload["reference_graph_incomplete_reasons"] = JsonSerializer.SerializeToNode( - incompleteReasons, - _jsonOptions); - payload["degraded"] = true; - } - } - - private static bool IsBareVerbatimQueryToken(string value) - { - var trimmed = value.Trim(); - return trimmed.Length > 0 && trimmed.All(ch => ch == '@'); - } - - private static Dictionary GetHotspotFamilyMetaSnapshot(DbContext db, Func keyFactory) - { - var languages = FileIndexer.GetHotspotFamilyMarkerLanguages(); - var values = new Dictionary(StringComparer.Ordinal); - var keys = new string[languages.Count]; - for (var i = 0; i < languages.Count; i++) - { - var lang = languages[i]; - keys[i] = keyFactory(lang); - values[lang] = null; - } - - var metaValues = db.GetMetaStrings(keys); - for (var i = 0; i < languages.Count; i++) - values[languages[i]] = metaValues.TryGetValue(keys[i], out var value) ? value : null; - - return values; - } - - private static Dictionary GetHotspotFamilyMarkerFingerprints( - FileIndexer indexer, - CancellationToken cancellationToken) => - indexer.GetProjectMarkerFingerprintResults(cancellationToken); - - private static void RestampHotspotFamilyTrust( - DbWriter writer, - IReadOnlySet? reusedLanguages, - IReadOnlyDictionary priorVersions, - IReadOnlyDictionary priorFingerprints, - IReadOnlyDictionary currentFingerprints) - { - var currentVersion = DbContext.HotspotFamilyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); - foreach (var lang in FileIndexer.GetHotspotFamilyMarkerLanguages()) - { - if (!currentFingerprints.TryGetValue(lang, out var currentFingerprint)) - continue; - - if (!currentFingerprint.IsComplete) - { - writer.MarkHotspotFamilyMarkerFingerprintIncomplete(lang, currentFingerprint.Fingerprint); - continue; - } - - priorVersions.TryGetValue(lang, out var priorVersion); - priorFingerprints.TryGetValue(lang, out var priorFingerprint); - if (reusedLanguages?.Contains(lang) != true || (priorVersion == currentVersion && priorFingerprint == currentFingerprint.Fingerprint)) - writer.MarkHotspotFamilyReady(lang, currentFingerprint.Fingerprint); - } - } - - private static Dictionary GetHotspotFamilyTrustMatchesCurrent( - IReadOnlyDictionary priorVersions, - IReadOnlyDictionary priorFingerprints, - IReadOnlyDictionary currentFingerprints) - { - var currentVersion = DbContext.HotspotFamilyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); - var values = new Dictionary(StringComparer.Ordinal); - foreach (var lang in FileIndexer.GetHotspotFamilyMarkerLanguages()) - { - currentFingerprints.TryGetValue(lang, out var currentFingerprint); - priorVersions.TryGetValue(lang, out var priorVersion); - priorFingerprints.TryGetValue(lang, out var priorFingerprint); - values[lang] = currentFingerprint.IsComplete - && priorVersion == currentVersion - && priorFingerprint == currentFingerprint.Fingerprint; - } - - return values; - } - - private static bool AllowReuseWithCurrentHotspotFamilyTrust( - string? lang, - IReadOnlyDictionary hotspotFamilyTrustMatchesCurrent) - { - if (!FileIndexer.SupportsHotspotFamilyMarkerLanguage(lang)) - return true; - - return lang != null - && hotspotFamilyTrustMatchesCurrent.TryGetValue(lang, out var matchesCurrent) - && matchesCurrent; - } - - private static void AddHotspotFamilySignal(JsonObject payload, HotspotFamilySignal signal) - { - payload["hotspot_family_ready"] = signal.Ready; - if (!signal.Ready) - { - payload["degraded"] = true; - if (signal.DegradedReason != null) - { - payload["hotspot_family_degraded_reason"] = signal.DegradedReason; - } - } - } - - private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) - { - var checkWorkspace = args?["check"]?.GetValue() ?? false; - var staleAfterSeconds = ReadOptionalIntArgument(args, "staleAfterSeconds") ?? (int)TimeSpan.FromDays(1).TotalSeconds; - if (staleAfterSeconds <= 0) - return CreateToolErrorResponse(id, "staleAfterSeconds must be greater than or equal to 1"); - var explain = args?["explain"]?.GetValue()?.Trim().ToLowerInvariant(); - if (explain is not (null or "freshness" or "readiness" or "all")) - return CreateToolErrorResponse(id, "explain must be one of freshness, readiness, all"); - var format = ReadResponseFormat(args); - if (format is not ("full" or "compact")) - return CreateToolErrorResponse(id, "format must be one of full, compact"); - if (!TryReadStatusProjectionFields(args, out var projectionFields, out var projectionError)) - return CreateToolErrorResponse(id, projectionError!); - if (!TryReadStatusScopes(args, out var statusScopes, out var scopeError)) - return CreateToolErrorResponse(id, scopeError!); - var includeConfig = args?["config"]?.GetValue() ?? false; - var includeLogPath = args?["logPath"]?.GetValue() ?? false; - var runUpdateCheck = args?["updateCheck"]?.GetValue() ?? false; - - string? unavailableProjectionError = null; - var response = WithDbReader(id, args, reader => - { - var requestToken = _currentRequestToken.Value; - var status = reader.GetStatus(); - QueryCommandRunner.ApplyStatusSymbolKindLimits(status, reader.GetSymbolKindCounts()); - WorkspaceMetadataEnricher.Enrich(status, _dbPath, _dbPathExplicit, requestToken); - status.DbFileMode = DbContext.GetUnixFileModeString( - _dbPath, - status.DatabasePermissionPolicy, - out var databasePermissionDiagnostic); - if (databasePermissionDiagnostic != null) - { - status.DatabasePermissionDiagnostics ??= []; - status.DatabasePermissionDiagnostics.Add(databasePermissionDiagnostic); - } - var macProfile = MacProfileDetector.DetectCurrentWithDiagnostics(); - status.MacProfile = macProfile.Profile; - if (macProfile.Diagnostics.Count > 0) - status.MacProfileDiagnostics = macProfile.Diagnostics.ToList(); - if (checkWorkspace) - { - status.WorkspaceCheck = IndexFreshnessChecker.Check( - reader, - status.ProjectRoot, - requestToken, - internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(_dbPath)); - status.IndexMatchesWorkspace = status.WorkspaceCheck.Checked - ? status.WorkspaceCheck.MatchesWorkspace - : null; - status.StaleAfterSeconds = staleAfterSeconds; - if (status.IndexedAt.HasValue) - status.IndexAgeSeconds = Math.Max(0, (long)Math.Round((GetUtcNow() - status.IndexedAt.Value).TotalSeconds, MidpointRounding.AwayFromZero)); - } - ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(status.ProjectRoot); - status.GraphSupportedLanguages = ReferenceExtractor.GetSupportedLanguages(status.ProjectRoot).OrderBy(l => l).ToList(); - status.Extractors = ExtractorPluginRegistry.GetStatusSnapshot(status.ProjectRoot); - status.GitExecutable = GitHelper.GetGitExecutableStatus(); - var postExtractionHookSnapshot = PostExtractionHookRunner.DiscoverDefaultMetadata(); - var postExtractionHooks = postExtractionHookSnapshot.Hooks; - if (postExtractionHookSnapshot.Diagnostics.Count > 0) - status.HookDiagnostics = postExtractionHookSnapshot.Diagnostics.ToList(); - var trustOverrides = ExtractorPluginRegistry.GetAcceptedTrustOverrides(status.ProjectRoot) - .Concat(postExtractionHookSnapshot.TrustOverrides) - .Concat(GitHelper.GetAcceptedTrustOverrides(status.GitExecutable)) - .ToList(); - if (trustOverrides.Count > 0) - status.TrustOverrides = trustOverrides; - if (postExtractionHooks.Count > 0) - { - status.Hooks = postExtractionHooks - .Select(hook => new PostExtractionHookStatus - { - Id = hook.Id, - Name = hook.Name, - AssemblyPath = hook.AssemblyPath, - TypeName = hook.TypeName, - CallbackBudgetMs = (long)Math.Round(postExtractionHookSnapshot.CallbackBudget.TotalMilliseconds, MidpointRounding.AwayFromZero), - LoadContextLifecycle = PostExtractionHookRunner.HookLoadContextLifecycle, - }) - .ToList(); - } - status.Version = _version; - requestToken.ThrowIfCancellationRequested(); - status.UpdateCheck = runUpdateCheck - ? (StatusUpdateCheckForTesting ?? UpdateChecker.Check)(_version, requestToken) - : null; - if (!status.FoldReady) - { - status.DegradedReason = DegradationReasonCodes.BuildFoldNotReadyExplanation(status.FoldReadyReason); - status.RecommendedAction = BuildFoldBackfillCommand(_dbPath, _dbPathExplicit); - status.AlternativeAction = BuildFoldRebuildRepairCommand(status.ProjectRoot, _dbPath, _dbPathExplicit); - } - status.Summary = QueryCommandRunner.BuildStatusSummary(status); - var checkFailures = checkWorkspace - ? BuildMcpStatusCheckFailures(status, statusScopes) - : []; - if (checkWorkspace) - status.FailedChecks = checkFailures.Select(failure => failure.Name).ToList(); - - var structured = JsonSerializer.SerializeToNode(status, _jsonOptions)!.AsObject(); - structured["project_root"] = status.ProjectRoot; - structured["git_head"] = status.GitHead; - structured["git_is_dirty"] = status.GitIsDirty; - structured.Remove("hotspotFamilyReady"); - structured.Remove("hotspotFamilyDegradedReason"); - structured["sql_graph_contract_ready"] = status.SqlGraphContractReady; - if (status.SqlGraphContractDegradedReason != null) - structured["sql_graph_contract_degraded_reason"] = status.SqlGraphContractDegradedReason; - structured["mcp_session"] = BuildMcpSessionStatus(); - var rateLimitDiagnostics = RateLimiter.SnapshotDiagnostics(); - structured["mcp"] = new JsonObject - { - ["limits"] = new JsonObject - { - ["max_request_characters"] = MaxLineCharacterCount, - ["max_request_bytes"] = MaxLineByteLength, - ["max_response_bytes"] = GetMaxResponseBytes(), - ["max_configured_response_bytes"] = MaxConfiguredResponseBytes, - ["batch_response_bytes"] = GetBatchQueryResponseByteLimit(), - ["max_batch_response_bytes"] = MaxBatchQueryResponseByteLimit, - ["batch_query_response_bytes"] = GetBatchQueryResponseByteLimit(), - ["batch_query_max_response_bytes"] = MaxBatchQueryResponseByteLimit, - ["batch_query_max_queries"] = MaxBatchQuerySize, - ["max_pagination_offset"] = MaxMcpPaginationOffset, - ["max_json_depth"] = MaxJsonDepth, - ["max_batch_requests"] = MaxBatchRequestCount, - ["json_rpc_batch_max_requests"] = MaxBatchRequestCount, - ["keep_alive_min_interval_s"] = MinKeepAliveIntervalSeconds, - ["keep_alive_max_interval_s"] = MaxKeepAliveIntervalSeconds, - ["rate_limit_max_rps"] = RateLimiterOptions.MaxRefillTokensPerSecond, - ["rate_limit_max_burst"] = RateLimiterOptions.MaxBurstCapacity, - ["rate_limit_max_buckets"] = RateLimiterOptions.DefaultMaxBucketCount, - }, - ["rate_limit"] = new JsonObject - { - ["enabled"] = RateLimiter.Options.IsEnabled, - ["rps"] = RateLimiter.Options.RefillTokensPerSecond, - ["burst"] = RateLimiter.Options.BurstCapacity, - ["bucket_count"] = rateLimitDiagnostics.BucketCount, - ["bucket_limit"] = rateLimitDiagnostics.MaxBucketCount, - ["bucket_limit_rejection_count"] = rateLimitDiagnostics.BucketLimitRejectionCount, - ["bucket_idle_ttl_seconds"] = rateLimitDiagnostics.BucketIdleTtlSeconds, - ["next_prune_in_ms"] = rateLimitDiagnostics.NextPruneInMs, - ["last_prune_age_ms"] = rateLimitDiagnostics.LastPruneAgeMs.HasValue ? JsonValue.Create(rateLimitDiagnostics.LastPruneAgeMs.Value) : null, - ["last_pruned_bucket_count"] = rateLimitDiagnostics.LastPrunedBucketCount, - }, - ["request_timeouts"] = BuildRequestTimeoutDiagnosticsStatus(), - }; - var effectiveConfig = includeConfig - ? BuildMcpStatusEffectiveConfig(status, staleAfterSeconds, checkWorkspace, runUpdateCheck) - : null; - var logPath = includeLogPath ? GlobalToolLog.ResolveLogDirectoryForStatus() : null; - var explainPayload = explain is null - ? null - : BuildMcpStatusExplain(status, checkFailures, explain); - if (effectiveConfig is not null) - structured["effective_config"] = effectiveConfig.DeepClone(); - if (logPath is not null) - structured["log_path"] = logPath; - if (explainPayload is not null) - structured["explain"] = explainPayload.DeepClone(); - if (format == "compact") - { - structured = BuildMcpCompactStatusPayload(status, checkFailures); - if (effectiveConfig is not null) - structured["effective_config"] = effectiveConfig; - if (logPath is not null) - structured["log_path"] = logPath; - if (explainPayload is not null) - structured["explain"] = explainPayload; - } - if (projectionFields is not null) - { - EnrichToolStructuredContent(structured); - var projected = new JsonObject(); - foreach (var field in projectionFields) - { - if (!structured.TryGetPropertyValue(field, out var value)) - { - unavailableProjectionError = - $"Status field '{field}' is not available in {format} format. Use an exact top-level field name returned by that format."; - return new JsonObject(); - } - projected[field] = value?.DeepClone(); - } - if (!projected.ContainsKey("api_version")) - projected["api_version"] = structured["api_version"]!.DeepClone(); - structured = projected; - } - return CreateToolResult( - id, - "Database stats returned.", - structured, - enrichStructuredContent: projectionFields is null); - }); - return unavailableProjectionError is null - ? response - : CreateToolErrorResponse(id, unavailableProjectionError); - } - - private sealed record McpStatusCheckFailure(string Name, bool IsStale, string Diagnostic); - - private static bool TryReadStatusProjectionFields( - JsonNode? args, - out IReadOnlyList? fields, - out string? error) - { - fields = null; - error = null; - if (args is not JsonObject argsObject || !argsObject.ContainsKey("fields")) - return true; - - var node = argsObject["fields"]; - if (node is null) - { - error = "fields must be a non-empty string or string array."; - return false; - } - IEnumerable values = node is JsonArray array ? array : new JsonNode?[] { node }; - if (node is JsonArray fieldsArray - && (fieldsArray.Count == 0 || fieldsArray.Count > MaxStatusProjectionFields)) - { - error = $"fields must contain between 1 and {MaxStatusProjectionFields} entries."; - return false; - } - - var result = new List(); - var seen = new HashSet(StringComparer.Ordinal); - var totalCharacters = 0; - foreach (var value in values) - { - if (value is not JsonValue jsonValue - || !jsonValue.TryGetValue(out var field) - || string.IsNullOrWhiteSpace(field)) - { - error = "fields entries must be non-empty strings."; - return false; - } - - field = field.Trim(); - if (field.Length > MaxStatusProjectionFieldCharacters) - { - error = $"fields entries must be no longer than {MaxStatusProjectionFieldCharacters} characters."; - return false; - } - if (field.Contains('.', StringComparison.Ordinal) - || field.Contains('[', StringComparison.Ordinal) - || field.Contains(']', StringComparison.Ordinal)) - { - error = "fields supports exact top-level field names only; nested field paths are not supported."; - return false; - } - - totalCharacters += field.Length; - if (totalCharacters > MaxStatusProjectionCharacters) - { - error = $"fields must contain no more than {MaxStatusProjectionCharacters} characters in total."; - return false; - } - if (seen.Add(field)) - result.Add(field); - } - - fields = result; - return true; - } - - private static bool TryReadStatusScopes(JsonNode? args, out HashSet? scopes, out string? error) - { - scopes = null; - error = null; - if (args?["scopes"] is null) - return true; - - var values = ReadStringOrArrayList(args, "scopes") - .Select(scope => scope.Trim().ToLowerInvariant()) - .ToList(); - if (args["scopes"] is JsonArray array && values.Count != array.Count) - { - error = "scopes entries must be non-empty strings."; - return false; - } - if (values.Count == 0) - { - error = "scopes cannot be empty or whitespace-only."; - return false; - } - - scopes = new HashSet(StringComparer.Ordinal); - foreach (var value in values) - { - if (!IsKnownMcpStatusScope(value)) - { - error = $"Invalid status scope '{value}'. Use one of: workspace, graph, issues, sql, hotspot, csharp, fold, newer."; - return false; - } - scopes.Add(value); - } - return true; - } - - private static bool IsKnownMcpStatusScope(string scope) => - scope is "workspace" or "graph" or "issues" or "sql" or "hotspot" or "csharp" or "fold" or "newer"; - - private static IReadOnlyList BuildMcpStatusCheckFailures(StatusResult status, IReadOnlySet? scopes) - { - var failures = new List(); - var checkAll = scopes is not { Count: > 0 }; - bool Includes(string scope) => checkAll || scopes!.Contains(scope); - - if (Includes("workspace")) - { - if (status.WorkspaceCheck?.Checked != true) - { - failures.Add(new McpStatusCheckFailure("workspace_unavailable", true, "[stale] workspace_check unavailable")); - } - else if (!status.WorkspaceCheck.MatchesWorkspace) - { - var check = status.WorkspaceCheck; - failures.Add(new McpStatusCheckFailure( - "workspace_stale", - true, - $"[stale] workspace_check reason={check.Reason} changed={check.ChangedFileCount} missing={check.MissingFileCount} unindexed={check.UnindexedFileCount}")); - } - } - - if (Includes("graph") && !status.GraphTableAvailable) - failures.Add(new McpStatusCheckFailure("graph_table_available", false, "[degraded] graph_table_available=false")); - if (Includes("issues") && !status.IssuesTableAvailable) - failures.Add(new McpStatusCheckFailure("issues_table_available", false, "[degraded] issues_table_available=false")); - if (Includes("issues") && status.IssuesTableAvailable && !status.FileIssuesDataCurrent) - failures.Add(new McpStatusCheckFailure("file_issues_data_current", false, "[degraded] file_issues_data_current=false")); - if (Includes("workspace") && status.MigrationInProgress) - failures.Add(new McpStatusCheckFailure("migration_in_progress", false, "[degraded] migration_in_progress=true")); - if (Includes("sql") && !status.SqlGraphContractReady) - failures.Add(new McpStatusCheckFailure("sql_graph_contract_ready", false, $"[degraded] sql_graph_contract_ready=false reason={status.SqlGraphContractDegradedReason ?? "unknown"}")); - if (Includes("hotspot") && !status.HotspotFamilyReady) - failures.Add(new McpStatusCheckFailure("hotspot_family_ready", false, $"[degraded] hotspot_family_ready=false reason={status.HotspotFamilyDegradedReason ?? "unknown"}")); - if (Includes("csharp") && !status.CSharpSymbolNameReady) - failures.Add(new McpStatusCheckFailure("csharp_symbol_name_ready", false, "[degraded] csharp_symbol_name_ready=false")); - if (Includes("csharp") && !status.CSharpMetadataTargetReady) - failures.Add(new McpStatusCheckFailure("csharp_metadata_target_ready", false, $"[degraded] csharp_metadata_target_ready=false reason={status.CSharpMetadataTargetDegradedReason ?? "unknown"}")); - if (Includes("fold") && !status.FoldReady) - failures.Add(new McpStatusCheckFailure("fold_ready", false, $"[degraded] fold_ready=false reason={status.FoldReadyReason ?? "unknown"}")); - if (Includes("newer") && status.IndexNewerThanReader) - failures.Add(new McpStatusCheckFailure("index_newer_than_reader", false, $"[degraded] index_newer_than_reader=true reason={status.IndexNewerThanReaderReason ?? "unknown"}")); - - return failures; - } - - private JsonObject BuildMcpStatusEffectiveConfig(StatusResult status, int staleAfterSeconds, bool checkWorkspace, bool runUpdateCheck) => new() - { - ["db_path"] = _dbPath, - ["db_explicit"] = _dbPathExplicit, - ["project_root"] = status.ProjectRoot, - ["data_dir"] = status.DataDir, - ["data_dir_source"] = status.DataDirSource, - ["global_tool_log_dir"] = GlobalToolLog.ResolveLogDirectoryForStatus(), - ["stale_after_seconds"] = staleAfterSeconds, - ["check"] = checkWorkspace, - ["update_check_requested"] = runUpdateCheck, - ["version"] = status.Version, - }; - - private JsonObject BuildMcpStatusExplain(StatusResult status, IReadOnlyList failures, string explain) - { - var payload = new JsonObject(); - if (explain is "freshness" or "all") - { - payload["freshness"] = new JsonObject - { - ["index_matches_workspace"] = status.IndexMatchesWorkspace.HasValue ? JsonValue.Create(status.IndexMatchesWorkspace.Value) : null, - ["stale_after_seconds"] = status.StaleAfterSeconds.HasValue ? JsonValue.Create(status.StaleAfterSeconds.Value) : null, - ["index_age_seconds"] = status.IndexAgeSeconds.HasValue ? JsonValue.Create(status.IndexAgeSeconds.Value) : null, - ["workspace_check"] = status.WorkspaceCheck is null ? null : JsonSerializer.SerializeToNode(status.WorkspaceCheck, _jsonOptions), - }; - } - if (explain is "readiness" or "all") - { - payload["readiness"] = BuildMcpStatusReadiness(status); - payload["failed_check_details"] = BuildMcpStatusFailureArray(failures); - } - return payload; - } - - private static JsonObject BuildMcpStatusReadiness(StatusResult status) => new() - { - ["graph_table_available"] = status.GraphTableAvailable, - ["issues_table_available"] = status.IssuesTableAvailable, - ["file_issues_data_current"] = status.FileIssuesDataCurrent, - ["sql_graph_contract_ready"] = status.SqlGraphContractReady, - ["hotspot_family_ready"] = status.HotspotFamilyReady, - ["csharp_symbol_name_ready"] = status.CSharpSymbolNameReady, - ["csharp_metadata_target_ready"] = status.CSharpMetadataTargetReady, - ["fold_ready"] = status.FoldReady, - ["index_newer_than_reader"] = status.IndexNewerThanReader, - ["migration_in_progress"] = status.MigrationInProgress, - }; - - private static JsonArray BuildMcpStatusFailureArray(IReadOnlyList failures) - { - var array = new JsonArray(); - foreach (var failure in failures) - { - array.Add(new JsonObject - { - ["name"] = failure.Name, - ["is_stale"] = failure.IsStale, - ["diagnostic"] = failure.Diagnostic, - }); - } - return array; - } - - private static JsonObject BuildMcpCompactStatusPayload(StatusResult status, IReadOnlyList failures) - { - var payload = new JsonObject - { - ["format"] = "compact", - ["summary"] = status.Summary, - ["version"] = status.Version, - ["project_root"] = status.ProjectRoot, - ["files"] = status.Files, - ["chunks"] = status.Chunks, - ["symbols"] = status.Symbols, - ["references"] = status.References, - ["symbol_kinds"] = JsonSerializer.SerializeToNode(status.SymbolKinds), - ["symbol_kind_limit"] = status.SymbolKindLimit, - ["symbol_kind_name_limit"] = status.SymbolKindNameLimit, - ["symbol_kind_total_count"] = status.SymbolKindTotalCount, - ["symbol_kind_omitted_count"] = status.SymbolKindOmittedCount, - ["symbol_kind_names_truncated"] = status.SymbolKindNamesTruncated, - ["language_count"] = status.Languages.Count, - ["top_languages"] = new JsonArray(status.Languages - .OrderByDescending(kv => kv.Value) - .ThenBy(kv => kv.Key, StringComparer.Ordinal) - .Take(5) - .Select(kv => new JsonObject { ["lang"] = kv.Key, ["files"] = kv.Value }) - .ToArray()), - ["git_head"] = status.GitHead, - ["git_is_dirty"] = status.GitIsDirty.HasValue ? JsonValue.Create(status.GitIsDirty.Value) : null, - ["index_matches_workspace"] = status.IndexMatchesWorkspace.HasValue ? JsonValue.Create(status.IndexMatchesWorkspace.Value) : null, - ["stale_after_seconds"] = status.StaleAfterSeconds.HasValue ? JsonValue.Create(status.StaleAfterSeconds.Value) : null, - ["index_age_seconds"] = status.IndexAgeSeconds.HasValue ? JsonValue.Create(status.IndexAgeSeconds.Value) : null, - ["failed_checks"] = new JsonArray(failures.Select(failure => JsonValue.Create(failure.Name)).ToArray()), - ["failed_check_details"] = BuildMcpStatusFailureArray(failures), - ["readiness"] = BuildMcpStatusReadiness(status), - }; - if (status.WorkspaceCheck is not null) - payload["workspace_check"] = JsonSerializer.SerializeToNode(status.WorkspaceCheck); - if (status.TrustOverrides is { Count: > 0 }) - payload["trust_overrides"] = JsonSerializer.SerializeToNode(status.TrustOverrides); - if (status.GitExecutable is not null) - payload["git_executable"] = JsonSerializer.SerializeToNode(status.GitExecutable); - return payload; - } - - private JsonObject BuildMcpSessionStatus() - { - var state = CurrentInitializeState; - McpSessionSnapshotCapturedForTests?.Invoke(); - var roots = new JsonArray(); - foreach (var root in state.ClientRootDiagnostics) - roots.Add(root); - - var session = new JsonObject - { - ["log_level"] = _mcpLogLevel, - ["roots"] = roots, - }; - if (state.ClientRootsTruncated) - { - session["roots_truncated"] = true; - session["root_count"] = state.ClientRootCount; - session["root_limit"] = MaxClientRootCount; - session["root_uri_length_limit"] = MaxClientRootUriChars; - } - if (state.ClientName is not null || state.ClientVersion is not null) - { - var clientInfo = new JsonObject(); - if (state.ClientNameDisplay is not null) - { - clientInfo["name"] = state.ClientName; - state.ClientNameDisplay.Value.AddMetadata(clientInfo, "name"); - } - if (state.ClientVersionDisplay is not null) - { - clientInfo["version"] = state.ClientVersion; - state.ClientVersionDisplay.Value.AddMetadata(clientInfo, "version"); - } - session["client_info"] = clientInfo; - } - if (state.ClientCapabilities is not null) - { - session["client_capabilities_summary"] = BuildClientCapabilitiesSummary(state, state.ClientCapabilities); - session["client_capabilities"] = state.ClientCapabilities.DeepClone(); - } - if (state.ClientCapabilitiesTruncationReason is not null) - { - session["client_capabilities_truncated"] = true; - session["client_capabilities_truncation_reason"] = state.ClientCapabilitiesTruncationReason; - if (state.ClientCapabilitiesSerializedBytes is { } serializedBytes) - session["client_capabilities_serialized_bytes"] = serializedBytes; - session["client_capabilities_byte_limit"] = MaxClientCapabilitiesJsonBytes; - session["client_capabilities_depth_limit"] = MaxClientCapabilitiesDepth; - if (!session.ContainsKey("client_capabilities_summary")) - session["client_capabilities_summary"] = BuildClientCapabilitiesSummary(state, state.ClientCapabilities); - } - if (_auditLog is not null) - session["audit_log"] = BuildAuditLogStatus(_auditLog.SnapshotDiagnostics()); - session["metrics"] = BuildMetricsStatus(MetricsSink.SnapshotDiagnostics()); - return session; - } - - private JsonObject BuildClientCapabilitiesSummary(InitializeSessionState state, JsonNode? capabilities) - { - var summary = new JsonObject - { - ["roots"] = state.ClientSupportsRoots, - ["sampling"] = state.ClientSupportsSampling, - ["truncated"] = state.ClientCapabilitiesTruncationReason is not null, - ["truncation_reason"] = state.ClientCapabilitiesTruncationReason, - }; - if (state.ClientCapabilitiesSerializedBytes is { } serializedBytes) - summary["serialized_bytes"] = serializedBytes; - if (capabilities is JsonObject obj) - { - summary["top_level_count"] = obj.Count; - summary["top_level_keys"] = new JsonArray(obj - .Select(kv => JsonValue.Create(McpBoundedText.ForDisplay(kv.Key, 64).Text)) - .Take(20) - .ToArray()); - summary["top_level_keys_truncated"] = obj.Count > 20; - if (obj["experimental"] is JsonObject experimental) - { - summary["experimental_count"] = experimental.Count; - summary["experimental_keys"] = new JsonArray(experimental - .Select(kv => JsonValue.Create(McpBoundedText.ForDisplay(kv.Key, 64).Text)) - .Take(20) - .ToArray()); - summary["experimental_keys_truncated"] = experimental.Count > 20; - } - } - return summary; - } - - private static bool IsAuditLogDegraded(AuditLogSink.AuditLogDiagnostics? diagnostics) - => diagnostics is not null - && (diagnostics.DroppedRecordCount > 0 - || diagnostics.RotationDegraded); - - private static JsonObject BuildAuditLogStatus(AuditLogSink.AuditLogDiagnostics diagnostics) - { - var payload = new JsonObject - { - ["enabled"] = true, - ["path"] = diagnostics.Path, - ["include_values"] = diagnostics.IncludeValues, - ["max_bytes"] = diagnostics.MaxBytes, - ["bytes_written"] = diagnostics.BytesWritten, - ["disposed"] = diagnostics.Disposed, - ["queue_capacity"] = diagnostics.QueueCapacity, - ["queue_depth"] = diagnostics.QueueDepth, - ["queued_record_count"] = diagnostics.QueuedRecordCount, - ["written_record_count"] = diagnostics.WrittenRecordCount, - ["dropped_record_count"] = diagnostics.DroppedRecordCount, - ["queue_full_drop_count"] = diagnostics.QueueFullDropCount, - ["serialization_failure_count"] = diagnostics.SerializationFailureCount, - ["write_failure_count"] = diagnostics.WriteFailureCount, - ["rotation_failure_count"] = diagnostics.RotationFailureCount, - ["rotation_cleanup_failure_count"] = diagnostics.RotationCleanupFailureCount, - ["rotation_degraded"] = diagnostics.RotationDegraded, - }; - if (!string.IsNullOrWhiteSpace(diagnostics.LastDropReason)) - payload["last_drop_reason"] = diagnostics.LastDropReason; - if (!string.IsNullOrWhiteSpace(diagnostics.LastRotationFailure)) - payload["last_rotation_failure"] = diagnostics.LastRotationFailure; - return payload; - } - - private static JsonObject BuildMetricsStatus(MetricsDiagnostics? diagnostics) - { - if (diagnostics is null) - return new JsonObject { ["enabled"] = false }; - - var payload = new JsonObject - { - ["enabled"] = true, - ["path"] = diagnostics.Path, - ["max_bytes"] = diagnostics.MaxBytes, - ["bytes_written"] = diagnostics.BytesWritten, - ["disposed"] = diagnostics.Disposed, - ["degraded"] = diagnostics.Degraded, - ["queue_capacity"] = diagnostics.QueueCapacity, - ["queue_depth"] = diagnostics.QueueDepth, - ["queued_event_count"] = diagnostics.QueuedEventCount, - ["written_event_count"] = diagnostics.WrittenEventCount, - ["dropped_event_count"] = diagnostics.DroppedEventCount, - ["queue_full_drop_count"] = diagnostics.QueueFullDropCount, - ["serialization_failure_count"] = diagnostics.SerializationFailureCount, - ["write_failure_count"] = diagnostics.WriteFailureCount, - ["rotation_failure_count"] = diagnostics.RotationFailureCount, - ["batch_flush_count"] = diagnostics.BatchFlushCount, - ["consecutive_failure_count"] = diagnostics.ConsecutiveFailureCount, - ["recovery_count"] = diagnostics.RecoveryCount, - }; - if (diagnostics.NextRetryAt is { } nextRetryAt) - payload["next_retry_at"] = nextRetryAt.ToString("O", CultureInfo.InvariantCulture); - if (diagnostics.LastRecoveryAt is { } lastRecoveryAt) - payload["last_recovery_at"] = lastRecoveryAt.ToString("O", CultureInfo.InvariantCulture); - if (!string.IsNullOrWhiteSpace(diagnostics.LastFailure)) - payload["last_failure"] = diagnostics.LastFailure; - return payload; - } - - private static string BuildFoldBackfillCommand(string dbPath, bool dbPathExplicit) - { - if (!dbPathExplicit) - return "cdidx backfill-fold"; - - return $"cdidx backfill-fold --db {QuoteCommandArgument(ResolveWritableDbPathOrPlaceholder(dbPath))}"; - } - - private static string BuildFoldRebuildRepairCommand(string? projectRoot, string dbPath, bool dbPathExplicit) - { - if (!dbPathExplicit) - return "cdidx index . --rebuild"; - - var resolvedDbPath = ResolveWritableDbPathOrPlaceholder(dbPath); - var targetProject = string.IsNullOrWhiteSpace(projectRoot) - ? "" - : QuoteCommandArgument(projectRoot); - return $"cdidx index {targetProject} --db {QuoteCommandArgument(resolvedDbPath)} --rebuild"; - } - - private static string ResolveWritableDbPathOrPlaceholder(string dbPath) - => DbPathResolver.TryResolveWritableMutationDbPath(dbPath, out var writableDbPath) - ? writableDbPath - : ""; - - private static string QuoteCommandArgument(string value) - { - if (value.Length >= 2 && value[0] == '<' && value[^1] == '>') - return value; - - var fullPath = DbPathResolver.NormalizeDbPath(value); - if (!fullPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - fullPath = Path.GetFullPath(fullPath); - - return fullPath.IndexOfAny([' ', '\t', '"']) >= 0 - ? $"\"{fullPath.Replace("\"", "\\\"", StringComparison.Ordinal)}\"" - : fullPath; - } - - private JsonNode ExecuteOutline(JsonNode? id, JsonNode? args) - { - if (!TryReadRequiredPathParameter(args, "path", out var path, out var requiredError)) - return CreateToolErrorResponse(id, requiredError!); - - return WithDbReader(id, args, reader => - { - var outline = reader.GetOutline(path); - if (outline == null) - { - var emptyPayload = new JsonObject - { - ["path"] = path, - ["error"] = "file not found in index" - }; - AddFreshnessHint(emptyPayload, reader); - return CreateToolResult(id, "File not found in index.", emptyPayload); - } - - var structured = JsonSerializer.SerializeToNode(outline, _jsonOptions)!.AsObject(); - AddNextStepSuggestion( - structured, - "excerpt", - new JsonObject { ["path"] = path, ["startLine"] = 1, ["endLine"] = Math.Min(outline.TotalLines, 80) }, - "Use excerpt for only the relevant outline range instead of reading the whole file."); - return CreateToolResult(id, $"Outline: {ConsoleUi.Counted(outline.SymbolCount, "symbol")} in {ConsoleUi.Counted(outline.TotalLines, "line")}.", structured); - }); - } - - private JsonNode ExecuteExcerpt(JsonNode? id, JsonNode? args) - { - if (!TryReadRequiredPathParameter(args, "path", out var path, out var requiredError)) - return CreateToolErrorResponse(id, requiredError!); - - var startLine = ReadOptionalIntArgument(args, "startLine"); - if (startLine == null || startLine <= 0) - return CreateToolErrorResponse(id, "Missing or invalid required parameter: startLine"); - - var endLine = ReadOptionalIntArgument(args, "endLine") ?? startLine.Value; - if (endLine < startLine.Value) - return CreateToolErrorResponse(id, "endLine must be greater than or equal to startLine"); - - var beforeValue = ReadOptionalIntArgument(args, "before"); - if (beforeValue.HasValue && beforeValue.Value < 0) - return CreateToolErrorResponse(id, $"before must be in [0, {MaxContextLines}]"); - var before = ClampContextLines(beforeValue ?? 0); - - var afterValue = ReadOptionalIntArgument(args, "after"); - if (afterValue.HasValue && afterValue.Value < 0) - return CreateToolErrorResponse(id, $"after must be in [0, {MaxContextLines}]"); - var after = ClampContextLines(afterValue ?? 0); - var contextTruncated = beforeValue > MaxContextLines || afterValue > MaxContextLines; - - var focusLine = ReadOptionalIntArgument(args, "focusLine"); - var focusColumn = ReadOptionalIntArgument(args, "focusColumn"); - var focusLengthValue = ReadOptionalIntArgument(args, "focusLength"); - if (focusLengthValue.HasValue && focusLengthValue.Value <= 0) - return CreateToolErrorResponse(id, "focusLength must be greater than or equal to 1"); - var focusLength = focusLengthValue ?? 1; - var explicitFocusLength = args?["focusLength"] != null; - if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) - return maxLineWidthError; - if (!TryReadMaxOutputBytes(args, out var maxOutputBytes, out var maxOutputBytesError)) - return CreateToolErrorResponse(id, maxOutputBytesError!); - - if (focusLine.HasValue && focusLine.Value <= 0) - return CreateToolErrorResponse(id, "focusLine must be greater than or equal to 1"); - if (focusColumn.HasValue && focusColumn.Value <= 0) - return CreateToolErrorResponse(id, "focusColumn must be greater than or equal to 1"); - if (!focusColumn.HasValue && explicitFocusLength) - return CreateToolErrorResponse(id, "focusLength requires focusColumn"); - - return WithDbReader(id, args, reader => - { - if (focusLine.HasValue) - { - var file = reader.GetFileByPath(path); - if (file != null) - { - // `before` is bounded by MaxContextLines and `startLine` by `int.MaxValue`, but - // `endLine` is caller-supplied: int + int can still overflow when endLine is - // close to `int.MaxValue`. Use long intermediates so the clamp sees the real - // window before narrowing back to int (#1528). - // `before` は MaxContextLines、`startLine` は `int.MaxValue` で押さえているが、 - // `endLine` は呼び出し側入力で `int.MaxValue` 近傍なら int 同士の加算が overflow し得る。 - // long 中間変数で実窓を確定させてから int に戻す(#1528)。 - var requestedStart = (int)Math.Max(1L, (long)startLine.Value - before); - var requestedEnd = (int)Math.Min(file.Lines, (long)endLine + after); - if (focusLine.Value < requestedStart || focusLine.Value > requestedEnd) - return CreateToolErrorResponse(id, $"focusLine ({focusLine.Value}) must be within the returned excerpt range ({requestedStart}-{requestedEnd})"); - } - } - if (focusColumn.HasValue) - { - var focusLineLength = reader.GetExcerptFocusLineLength( - path, - startLine.Value, - endLine, - before, - after, - focusLine ?? startLine.Value); - if (focusLineLength.HasValue && focusColumn.Value > focusLineLength.Value) - return CreateToolErrorResponse(id, $"focusColumn ({focusColumn.Value}) must be within the focused line length ({focusLineLength.Value})"); - } - - var excerpt = reader.GetExcerpt(path, startLine.Value, endLine, before, after, maxLineWidth, focusLine ?? startLine.Value, focusColumn, focusLength); - if (excerpt == null) - { - var emptyPayload = new JsonObject - { - ["path"] = path, - ["count"] = 0 - }; - AddRecoveryHint( - emptyPayload, - "file_or_range_not_indexed", - "excerpt found no indexed content for the requested range; verify the path with files or outline, then retry with an indexed line range.", - "outline", - new JsonObject { ["path"] = path }); - AddFreshnessHint(emptyPayload, reader); - return CreateToolResult(id, "No excerpt found.", emptyPayload); - } - - ExcerptRecoveryCommandFormatter.ApplyDbPath(excerpt, _dbPath); - var payload = JsonSerializer.SerializeToNode(excerpt, _jsonOptions)!.AsObject(); - ApplyExcerptOutputBudget(payload, maxOutputBytes); - payload["maxOutputBytes"] = maxOutputBytes; - payload["before"] = before; - payload["after"] = after; - payload["contextTruncated"] = contextTruncated; - payload["maxLineWidth"] = maxLineWidth; - if (focusLine.HasValue) - payload["focusLine"] = focusLine.Value; - if (focusColumn.HasValue) - payload["focusColumn"] = focusColumn.Value; - payload["focusLength"] = focusLength; - AddNextStepSuggestion( - payload, - "outline", - new JsonObject { ["path"] = excerpt.Path }, - "Use outline to navigate neighboring symbols before requesting more ranges from the same file."); - return CreateToolResult(id, "Excerpt returned.", payload); - }); - } - - private static bool TryReadMaxOutputBytes(JsonNode? args, out int maxOutputBytes, out string? error) - { - maxOutputBytes = DefaultExcerptOutputByteLimit; - error = null; - if (args?["maxOutputBytes"] is not JsonNode node) - return true; - if (node is not JsonValue value || !value.TryGetValue(out var requested)) - { - error = "maxOutputBytes must be an integer"; - return false; - } - if (requested <= 0) - { - error = "maxOutputBytes must be greater than or equal to 1"; - return false; - } - maxOutputBytes = Math.Min(requested, DefaultExcerptOutputByteLimit); - return true; - } - - internal static void ApplyExcerptOutputBudget(JsonObject payload, int maxOutputBytes) - { - var contentKey = payload.ContainsKey("content") ? "content" : "Content"; - if (payload[contentKey]?.GetValue() is not string content) - return; - if (Encoding.UTF8.GetByteCount(content) <= maxOutputBytes) - return; - - var builder = new StringBuilder(); - var retainedLineCount = 0; - var firstRetainedLine = true; - foreach (var line in content.Replace("\r\n", "\n").Split('\n')) - { - var candidate = firstRetainedLine ? line : builder.ToString() + "\n" + line; - if (Encoding.UTF8.GetByteCount(candidate) > maxOutputBytes) - break; - builder.Clear(); - builder.Append(candidate); - retainedLineCount++; - firstRetainedLine = false; - } - payload[contentKey] = builder.ToString(); - TrimExcerptCoordinatePayload(payload, retainedLineCount); - payload["contentTruncated"] = true; - payload["truncated"] = true; - payload["truncation_reason"] = "output_size_cap"; - } - - private static void TrimExcerptCoordinatePayload(JsonObject payload, int retainedLineCount) - { - var spansKey = FirstPayloadKey(payload, "contentLineSpans", "content_line_spans", "ContentLineSpans"); - var retainedSpans = new List(); - var hasSpanMapping = false; - if (spansKey is not null && payload[spansKey] is JsonArray spans) - { - hasSpanMapping = true; - var trimmedSpans = new JsonArray(); - foreach (var spanNode in spans) - { - if (spanNode is not JsonObject span) - continue; - var contentLine = GetPayloadInt(span, "contentLine", "content_line", "ContentLine"); - if (!contentLine.HasValue || contentLine.Value > retainedLineCount) - continue; - - trimmedSpans.Add(span.DeepClone()); - var sourceLine = GetPayloadInt(span, "sourceLine", "source_line", "SourceLine"); - var sourceStartColumn = GetPayloadInt(span, "sourceStartColumn", "source_start_column", "SourceStartColumn"); - var sourceEndColumn = GetPayloadInt(span, "sourceEndColumn", "source_end_column", "SourceEndColumn"); - if (sourceLine.HasValue && sourceStartColumn.HasValue && sourceEndColumn.HasValue) - retainedSpans.Add(new ExcerptPayloadSpan(sourceLine.Value, sourceStartColumn.Value, sourceEndColumn.Value)); - } - - payload[spansKey] = trimmedSpans; - } - - var tokensKey = FirstPayloadKey(payload, "semanticTokens", "semantic_tokens", "SemanticTokens"); - if (tokensKey is null || payload[tokensKey] is not JsonArray tokens) - return; - if (!hasSpanMapping) - { - if (retainedLineCount == 0) - payload[tokensKey] = new JsonArray(); - return; - } - - var trimmedTokens = new JsonArray(); - if (retainedLineCount > 0 && retainedSpans.Count > 0) - { - foreach (var tokenNode in tokens) - { - if (tokenNode is not JsonObject token) - continue; - var startLine = GetPayloadInt(token, "startLine", "start_line", "StartLine"); - var endLine = GetPayloadInt(token, "endLine", "end_line", "EndLine"); - var startColumn = GetPayloadInt(token, "startColumn", "start_column", "StartColumn"); - var endColumn = GetPayloadInt(token, "endColumn", "end_column", "EndColumn"); - if (!startLine.HasValue || !endLine.HasValue || !startColumn.HasValue || !endColumn.HasValue) - continue; - if (retainedSpans.Any(span => - startLine.Value == span.SourceLine && - endLine.Value == span.SourceLine && - startColumn.Value >= span.SourceStartColumn && - endColumn.Value <= span.SourceEndColumn)) - { - trimmedTokens.Add(token.DeepClone()); - } - } - } - - payload[tokensKey] = trimmedTokens; - } - - private static string? FirstPayloadKey(JsonObject payload, params string[] keys) - => keys.FirstOrDefault(payload.ContainsKey); - - private static int? GetPayloadInt(JsonObject obj, params string[] keys) - { - foreach (var key in keys) - { - if (obj[key] is JsonNode node) - return node.GetValue(); - } - - return null; - } - - private readonly record struct ExcerptPayloadSpan(int SourceLine, int SourceStartColumn, int SourceEndColumn); - - private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) - { - if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) - return CreateToolErrorResponse(id, requiredError!); - if (query.Length > QueryLimits.MaxQueryLength) - return CreateToolErrorResponse(id, QueryLimits.FormatQueryTooLongError()); - - var pathPatterns = ReadScopedPathList(args); - if (pathPatterns == null || pathPatterns.Count == 0) - return CreateToolErrorResponse(id, HasBlankPathFilter(args) - ? "Parameter \"path\" cannot be empty or whitespace-only" - : "Missing required parameter: path"); - - var adjustments = new ArgumentAdjustmentCollector(); - var limit = ReadLimit(args, QueryCommandRunner.DefaultQueryLimit, adjustments); - var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); - var excludePaths = ReadStringList(args, "excludePaths"); - var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - var beforeValue = ReadOptionalIntArgument(args, "before"); - if (beforeValue.HasValue && beforeValue.Value < 0) - return CreateToolErrorResponse(id, "before must be greater than or equal to 0"); - var before = ClampContextLines(beforeValue ?? 0); - - var afterValue = ReadOptionalIntArgument(args, "after"); - if (afterValue.HasValue && afterValue.Value < 0) - return CreateToolErrorResponse(id, "after must be greater than or equal to 0"); - var after = ClampContextLines(afterValue ?? 0); - var contextTruncated = beforeValue > MaxContextLines || afterValue > MaxContextLines; - var snippetLinesValue = ReadOptionalIntArgument(args, "snippetLines"); - if (snippetLinesValue.HasValue && (snippetLinesValue.Value <= 0 || snippetLinesValue.Value > SearchSnippetFormatter.MaxSnippetLines)) - return CreateToolErrorResponse(id, $"snippetLines must be in [1, {SearchSnippetFormatter.MaxSnippetLines}]"); - if (snippetLinesValue.HasValue) - { - var surroundingLines = snippetLinesValue.Value - 1; - if (!beforeValue.HasValue) - before = surroundingLines / 2; - if (!afterValue.HasValue) - after = surroundingLines - before; - } - var focusLine = args?["focusLine"]?.GetValue(); - if (focusLine.HasValue && focusLine.Value <= 0) - return CreateToolErrorResponse(id, "focusLine must be greater than or equal to 1"); - var focusColumn = args?["focusColumn"]?.GetValue(); - if (focusColumn.HasValue && focusColumn.Value <= 0) - return CreateToolErrorResponse(id, "focusColumn must be greater than or equal to 1"); - if (TryGetValidatedMaxLineWidth(id, args, out var maxLineWidth) is JsonNode maxLineWidthError) - return maxLineWidthError; - var exact = args?["exact"]?.GetValue() ?? false; - var regex = args?["regex"]?.GetValue() ?? false; - - return WithDbReader(id, args, reader => - { - List results; - try - { - results = reader.FindInFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, before, after, exact, maxLineWidth, focusLine, focusColumn, regex).Results; - } - catch (RegexMatchTimeoutException ex) when (regex) - { - return CreateToolErrorResponse( - id, - RegexTimeoutPolicy.FormatFindTimeout(ex), - category: RegexTimeoutPolicy.RegexTimeoutCategory, - suggestion: RegexTimeoutPolicy.McpFindTimeoutSuggestion, - retrySafe: true, - extraData: new JsonObject - { - ["error_code"] = CommandErrorCodes.RegexMatchTimeout, - ["timeout_ms"] = ex.MatchTimeout.TotalMilliseconds, - }); - } - catch (ArgumentException) when (regex) - { - return CreateToolErrorResponse(id, "invalid regular expression. Check regex syntax and retry."); - } - var structured = new JsonObject - { - ["query"] = query, - ["path"] = PathEcho(pathPatterns), - ["excludeTests"] = excludeTests, - ["before"] = before, - ["after"] = after, - ["contextTruncated"] = contextTruncated, - ["maxLineWidth"] = maxLineWidth, - ["exact"] = exact, - ["regex"] = regex, - ["count"] = results.Count, - ["fileCount"] = results.Select(r => r.Path).Distinct().Count(), - ["results"] = JsonSerializer.SerializeToNode(results, _jsonOptions), - }; - if (snippetLinesValue.HasValue) - structured["snippetLines"] = snippetLinesValue.Value; - if (focusLine.HasValue) - structured["focusLine"] = focusLine.Value; - if (focusColumn.HasValue) - structured["focusColumn"] = focusColumn.Value; - if (results.Count == 0) - { - AddFreshnessHint(structured, reader); - adjustments.ApplyTo(structured); - return CreateToolResult(id, "No matches found.", structured); - } - - var fileCount = structured["fileCount"]!.GetValue(); - adjustments.ApplyTo(structured); - return CreateToolResult(id, $"Found {ConsoleUi.Counted(results.Count, "in-file match", "in-file matches")} across {ConsoleUi.Counted(fileCount, "file")}.", structured); - }); - } - - private static int ClampContextLines(int value) - { - return Math.Min(value, MaxContextLines); - } - - private JsonNode ExecuteBatchQueryEstimate(JsonNode? id, JsonArray queries, int responseByteLimit, ArgumentAdjustmentCollector adjustments) - { - var slotEstimates = new JsonArray(); - for (var requestIndex = 0; requestIndex < queries.Count; requestIndex++) - { - var queryObject = queries[requestIndex] as JsonObject; - slotEstimates.Add(BuildBatchSlotDescriptor(requestIndex, queryObject)); - } - - var payload = new JsonObject - { - ["count"] = 0, - ["total_count"] = queries.Count, - ["success_count"] = 0, - ["failure_count"] = 0, - ["partial_failure"] = false, - ["failure_scope"] = "none", - ["cascade_started_at_index"] = null, - ["estimate_only"] = true, - ["metadata"] = new JsonObject - { - ["submitted"] = queries.Count, - ["executed"] = 0, - ["errors"] = 0, - ["total_elapsed_ms"] = 0, - ["success_count"] = 0, - ["failure_count"] = 0, - ["response_byte_limit"] = responseByteLimit, - ["estimated_response_bytes"] = responseByteLimit, - }, - ["slot_estimates"] = slotEstimates, - ["results"] = new JsonArray(), - }; - adjustments.ApplyTo(payload); - - var summary = $"Estimated batch_query envelope for {queries.Count} query slot(s); no slots executed."; - var estimatedResponseBytes = EstimateJsonUtf8Bytes(CreateToolResult(id, summary, payload.DeepClone()), responseByteLimit); - ((JsonObject)payload["metadata"]!)["estimated_response_bytes"] = estimatedResponseBytes; - payload["estimate_exceeds_response_byte_limit"] = estimatedResponseBytes > responseByteLimit; - return CreateToolResult(id, summary, payload); - } - - private static int ReadBatchQueryResponseByteLimit(JsonNode? args, ArgumentAdjustmentCollector adjustments) - { - var serverLimit = GetBatchQueryResponseByteLimit(); - var requested = ReadOptionalIntArgument(args, "maxResponseBytes"); - if (!requested.HasValue) - return serverLimit; - var effective = Math.Min(requested.Value, serverLimit); - if (effective != requested.Value) - adjustments.AddClamped("maxResponseBytes", requested.Value, effective, 1, serverLimit); - return effective; - } - - private static JsonObject BuildBatchSlotDescriptor(int requestIndex, JsonObject? queryObject) - { - var toolName = queryObject?["tool"] is JsonValue toolValue && toolValue.TryGetValue(out var parsedToolName) - ? parsedToolName - : null; - var toolArgs = queryObject?["arguments"]; - var descriptor = new JsonObject - { - ["request_index"] = requestIndex, - ["args_summary"] = BuildArgsSummary(toolArgs), - }; - AddBatchSlotId(descriptor, ReadBatchSlotId(queryObject)); - AddToolDisplayData(descriptor, toolName); - return descriptor; - } - - private static JsonObject BuildBatchSplitHint(int submittedCount, int? cascadeStartedAtIndex, int retainedResultCount) - { - var nextRequestIndex = cascadeStartedAtIndex ?? submittedCount; - return new JsonObject - { - ["reason"] = "response_byte_limit_exceeded", - ["next_request_index"] = nextRequestIndex, - ["suggested_query_count"] = Math.Max(1, retainedResultCount), - ["resume_cursor"] = $"batch_query:v1:{nextRequestIndex}", - }; - } - - private static bool RemoveBatchTruncatedQueryToolDisplay(JsonArray truncatedQueries) - { - var changed = false; - foreach (var item in truncatedQueries) - { - if (item is JsonObject entry) - changed |= entry.Remove("tool"); - } - return changed; - } - - private static bool CompactBatchTruncatedQueryArgsSummaries(JsonArray truncatedQueries) - { - var changed = false; - foreach (var item in truncatedQueries) - { - if (item is JsonObject entry - && entry["args_summary"] is JsonValue value - && value.TryGetValue(out var summary) - && summary.Length > 0) - { - entry["args_summary"] = string.Empty; - changed = true; - } - } - return changed; - } - - private static string? ReadBatchSlotId(JsonObject? queryObject) - { - if (TryReadBatchSlotIdValue(queryObject?["slotId"], out var slotId) - || TryReadBatchSlotIdValue(queryObject?["id"], out slotId)) - return McpBoundedText.ForDisplay(slotId!, MaxRequestIdCharacterCount).Text; - return null; - } - - private static bool TryReadBatchSlotIdValue(JsonNode? node, out string? slotId) - { - slotId = null; - if (node is not JsonValue value) - return false; - if (value.TryGetValue(out var text)) - { - if (string.IsNullOrWhiteSpace(text)) - return false; - slotId = text; - return true; - } - if (value.TryGetValue(out var intValue)) - { - slotId = intValue.ToString(CultureInfo.InvariantCulture); - return true; - } - if (value.TryGetValue(out var longValue)) - { - slotId = longValue.ToString(CultureInfo.InvariantCulture); - return true; - } - return false; - } - - private static void AddBatchSlotId(JsonObject entry, string? slotId) - { - if (!string.IsNullOrEmpty(slotId)) - entry["slot_id"] = slotId; - } - - private static int GetBatchQueryResponseByteLimit() - => ReadPositiveIntEnvironmentLimit( - BatchQueryResponseByteLimitEnvVar, - DefaultBatchQueryResponseByteLimit, - MaxBatchQueryResponseByteLimit, - "MCP batch_query response byte limit"); - - private int EstimateJsonUtf8Bytes(JsonNode node, int maxBytes = MaxBatchQueryResponseByteLimit) - { - _ = TryMeasureJsonUtf8BytesWithinLimit(node, _jsonOptions, maxBytes, out var bytesWritten); - return bytesWritten; - } - - private int EstimateBatchResponseBytes(JsonNode? id, string summary, int submittedCount, int successCount, int failureCount, - string failureScope, int? cascadeStartedAtIndex, int responseByteLimit, JsonArray resultsArray, bool truncated, JsonArray truncatedQueries, - ArgumentAdjustmentCollector? adjustments = null) - { - var payload = new JsonObject - { - ["count"] = resultsArray.Count, - ["total_count"] = submittedCount, - ["success_count"] = successCount, - ["failure_count"] = failureCount, - ["partial_failure"] = failureCount > 0 || cascadeStartedAtIndex.HasValue, - ["failure_scope"] = failureScope, - ["cascade_started_at_index"] = cascadeStartedAtIndex, - ["metadata"] = new JsonObject - { - ["submitted"] = submittedCount, - ["executed"] = successCount + failureCount, - ["errors"] = failureCount, - ["total_elapsed_ms"] = 0, - ["success_count"] = successCount, - ["failure_count"] = failureCount, - ["response_byte_limit"] = responseByteLimit, - ["estimated_response_bytes"] = responseByteLimit, - }, - ["results"] = resultsArray.DeepClone(), - }; - if (truncated) - { - payload["truncated"] = true; - payload["truncated_queries"] = truncatedQueries.DeepClone(); - } - adjustments?.ApplyTo(payload); - - return EstimateJsonUtf8Bytes(CreateToolResult(id, summary, payload), responseByteLimit); - } - - private int EstimateBatchAppendBytes(int currentEstimateBytes, JsonObject entry, int executedCount, int successCount, int failureCount) - { - var entryBytes = EstimateJsonUtf8Bytes(entry); - var digitGrowth = CountDecimalDigits(executedCount) + CountDecimalDigits(successCount) + CountDecimalDigits(failureCount); - return SaturatingAdd( - currentEstimateBytes, - entryBytes, - BatchQueryIncrementalEstimatePaddingBytes, - digitGrowth); - } - - private static int CountDecimalDigits(int value) - { - var digits = 1; - while (value >= 10) - { - value /= 10; - digits++; - } - return digits; - } - - private static int SaturatingAdd(params int[] values) - { - var total = 0L; - foreach (var value in values) - { - total += value; - if (total >= int.MaxValue) - return int.MaxValue; - } - return (int)total; - } - - private static string GetBatchFailureScope(int submittedCount, int successCount, int failureCount, int? cascadeStartedAtIndex) - { - if (cascadeStartedAtIndex.HasValue && cascadeStartedAtIndex.Value < submittedCount) - return "cascading"; - return failureCount == 0 ? "none" : "isolated"; - } - - /// - /// Build a compact, single-line summary string of a batch slot's arguments - /// so callers can correlate per-slot timings with what was requested - /// without re-parsing the original payload. - /// バッチスロットの arguments を1行で要約し、呼び出し側がペイロードを - /// 再解析せずスロット別時間と対応付けられるようにする。 - /// - private const int BatchArgsSummaryMaxLength = 200; - private static string BuildArgsSummary(JsonNode? toolArgs) - { - if (toolArgs is not JsonObject obj) - return string.Empty; - if (obj.Count == 0) - return string.Empty; - var parts = new List(obj.Count); - foreach (var kv in obj) - { - var key = McpBoundedText.ForDisplay(kv.Key).Text; - var rendered = RenderBatchArgumentSummaryValue(kv.Value); - parts.Add($"{key}={rendered}"); - } - var joined = string.Join(", ", parts); - if (joined.Length > BatchArgsSummaryMaxLength) - joined = joined.Substring(0, BatchArgsSummaryMaxLength - 1) + "…"; - return joined; - } - - private static string RenderBatchArgumentSummaryValue(JsonNode? value) - { - if (value is null) - return "null"; - if (value is JsonArray arr) - return $"[{arr.Count}]"; - if (value is JsonObject inner) - return $"{{{inner.Count}}}"; - if (value is not JsonValue jsonValue) - return ""; - - return jsonValue.GetValueKind() switch - { - JsonValueKind.String => jsonValue.TryGetValue(out var text) - ? JsonSerializer.Serialize(McpBoundedText.ForDisplay(text).Text) - : "\"\"", - JsonValueKind.True => "true", - JsonValueKind.False => "false", - JsonValueKind.Null => "null", - JsonValueKind.Number => RenderBatchNumericArgument(jsonValue), - _ => "", - }; - } - - private static string RenderBatchNumericArgument(JsonValue value) - { - if (value.TryGetValue(out var intValue)) - return intValue.ToString(CultureInfo.InvariantCulture); - if (value.TryGetValue(out var longValue)) - return longValue.ToString(CultureInfo.InvariantCulture); - if (value.TryGetValue(out var decimalValue)) - return decimalValue.ToString(CultureInfo.InvariantCulture); - if (value.TryGetValue(out var doubleValue) && double.IsFinite(doubleValue)) - return doubleValue.ToString("R", CultureInfo.InvariantCulture); - return ""; - } - - private JsonNode ExecuteDeps(JsonNode? id, JsonNode? args) - { - var adjustments = new ArgumentAdjustmentCollector(); - var limit = ReadLimit(args, QueryCommandRunner.DefaultImpactLimit, adjustments); - var requestedGraphBudget = ReadOptionalIntArgument(args, "graphBudget"); - var graphBudget = Math.Clamp( - requestedGraphBudget ?? QueryCommandRunner.DefaultDependencyCycleGraphBudget, - 1, - QueryCommandRunner.MaxDependencyCycleGraphBudget); - if (requestedGraphBudget.HasValue && requestedGraphBudget.Value != graphBudget) - adjustments.AddClamped("graphBudget", requestedGraphBudget.Value, graphBudget, 1, QueryCommandRunner.MaxDependencyCycleGraphBudget); - var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); - var pathPatterns = ReadScopedPathList(args); - var excludePaths = ReadStringList(args, "excludePaths"); - var excludeTests = args?["excludeTests"]?.GetValue() ?? false; - var includeGenerated = args?["includeGenerated"]?.GetValue() ?? false; - var reverse = args?["reverse"]?.GetValue() ?? false; - var cyclesOnly = args?["cycles"]?.GetValue() ?? false; - var format = args?["format"]?.GetValue()?.ToLowerInvariant() ?? "edgelist"; - var cursorValue = args?["cursor"]?.GetValue(); - if (requestedGraphBudget.HasValue && !cyclesOnly) - return CreateToolErrorResponse(id, "'graphBudget' requires 'cycles=true'."); - if (cursorValue != null && !cyclesOnly) - return CreateToolErrorResponse(id, "'cursor' requires 'cycles=true'."); - if (cursorValue != null && !QueryCommandRunner.TryParseDependencyCycleCursor(cursorValue, out _)) - return CreateToolErrorResponse(id, "'cursor' must be an opaque dependency-cycle next_cursor returned by deps."); - - var cursorOptions = new QueryCommandOptions - { - Lang = lang, - PathPatterns = pathPatterns?.ToList() ?? [], - ExcludePaths = excludePaths, - ExcludeTests = excludeTests, - IncludeGenerated = includeGenerated, - DependencyCycleGraphBudget = graphBudget, - }; - var cursorBaseFingerprint = QueryCommandRunner.BuildDependencyCycleCursorFingerprint(cursorOptions, reverse); - var cursor = cursorValue == null - ? (DependencyCycleCursor?)null - : QueryCommandRunner.TryParseDependencyCycleCursor(cursorValue, out var parsedCursor) - ? parsedCursor - : null; - var pageOffset = cursor?.Offset ?? 0; - - return WithDbReader(id, args, reader => - { - var cycleCandidateRowCount = 0; - var results = cyclesOnly - ? reader.GetFileDependencyCycleCandidates( - checked(graphBudget + 1), - out cycleCandidateRowCount, - lang, - pathPatterns, - excludePaths, - excludeTests, - reverse, - reader.Cancellation) - : reader.GetFileDependencies(limit, lang, pathPatterns, excludePaths, excludeTests, reverse); - var cycleCandidates = cyclesOnly ? results.Take(graphBudget).ToList() : results; - var cursorFingerprint = QueryCommandRunner.BuildDependencyCycleGraphFingerprint( - cursorBaseFingerprint, - cycleCandidates, - cycleCandidateRowCount); - if (cursor is { } suppliedCursor - && !string.Equals(suppliedCursor.Fingerprint, cursorFingerprint, StringComparison.Ordinal)) - return CreateToolErrorResponse(id, "'cursor' does not match the current deps filters, graphBudget, or indexed graph."); - var baseSqlGraphSignal = reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests); - var cycleAnalysis = cyclesOnly - ? QueryCommandRunner.AnalyzeDependencyCycles( - cycleCandidates, - graphBudget, - cycleCandidateRowCount, - limit, - pageOffset, - cursorFingerprint, - reader.Cancellation) - : null; - if (cursor.HasValue && cycleAnalysis != null && pageOffset >= cycleAnalysis.TotalCycleCount) - return CreateToolErrorResponse(id, "'cursor' points beyond the available dependency-cycle result set."); - var cycles = cycleAnalysis?.Cycles ?? []; - var outputEdges = cycleAnalysis?.Edges ?? results; - var sqlGraphSignalPaths = cyclesOnly - ? cycles.Count > 0 - ? cycles.SelectMany(static cycle => cycle) - : cycleCandidates.SelectMany(static result => new[] { result.SourcePath, result.TargetPath }) - : results.SelectMany(static result => new[] { result.SourcePath, result.TargetPath }); - var sqlGraphSignal = results.Count == 0 - ? baseSqlGraphSignal - : QueryCommandRunner.NarrowSqlGraphContractSignalByPaths( - reader, - baseSqlGraphSignal, - sqlGraphSignalPaths, - lang); - var payload = new JsonObject { ["count"] = cyclesOnly ? cycles.Count : results.Count }; - if (cyclesOnly) - payload["cycles"] = QueryCommandRunner.BuildDependencyCyclesJson(cycleAnalysis!.Components, cycleAnalysis.PageOffset); - else if (format == "json-graph") - payload["graph"] = BuildJsonGraphPayload(outputEdges); - else - payload["edges"] = JsonSerializer.SerializeToNode(outputEdges, _jsonOptions); - if (cyclesOnly) - QueryCommandRunner.AddDependencyCycleAnalysisJsonFields(payload, cycleAnalysis!, mcpArguments: true); - payload["format"] = format; - payload["includeGenerated"] = includeGenerated; - payload["generated_code_filter_supported"] = true; - payload["generated_code_scope"] = "source_and_target_files"; - AddSqlGraphContractSignal(payload, sqlGraphSignal); - AddReferenceGraphCompletenessSignal( - payload, - reader, - lang, - pathPatterns, - excludePaths, - excludeTests); - var summary = payload["count"]!.GetValue() > 0 - ? cyclesOnly ? $"Found {ConsoleUi.Counted(cycles.Count, "dependency cycle")}." : $"Found {ConsoleUi.Counted(results.Count, "dependency edge")}." - : cyclesOnly ? "No dependency cycles found." : "No file dependencies found."; - if (results.Count == 0) - AddFreshnessHint(payload, reader); - adjustments.ApplyTo(payload); - return CreateToolResult(id, summary, payload); - }); - } - - private static JsonObject BuildJsonGraphPayload(IReadOnlyList edges) - { - var nodes = new JsonArray(); - var seenNodes = new HashSet(StringComparer.Ordinal); - var graphEdges = new JsonArray(); - foreach (var edge in edges) - { - if (seenNodes.Add(edge.SourcePath)) - nodes.Add(new JsonObject { ["id"] = edge.SourcePath }); - if (seenNodes.Add(edge.TargetPath)) - nodes.Add(new JsonObject { ["id"] = edge.TargetPath }); - - graphEdges.Add(new JsonObject - { - ["source"] = edge.SourcePath, - ["target"] = edge.TargetPath, - ["reference_count"] = edge.ReferenceCount, - ["ranking_score"] = edge.RankingScore, - }); - } - - return new JsonObject { ["nodes"] = nodes, ["edges"] = graphEdges }; - } - -} From d7115ec6270eaae42a4ddbbfac32a63aa2f65453 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 14:20:27 +0900 Subject: [PATCH 025/101] Separate MCP indexing validation and diagnostics --- .../McpToolHandlers.Indexing.Diagnostics.cs | 162 +++++++++++++++ ... => McpToolHandlers.Indexing.Execution.cs} | 194 ------------------ .../McpToolHandlers.Indexing.Validation.cs | 70 +++++++ 3 files changed, 232 insertions(+), 194 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Indexing.Diagnostics.cs rename src/CodeIndex/Mcp/{McpToolHandlers.Indexing.cs => McpToolHandlers.Indexing.Execution.cs} (94%) create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Indexing.Validation.cs diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Diagnostics.cs b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Diagnostics.cs new file mode 100644 index 000000000..c1c8de37d --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Diagnostics.cs @@ -0,0 +1,162 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + + private static IndexFileFailure BuildIndexFileFailure(string projectPath, string filePath, Exception ex, string stage) + { + var relativePath = FileIndexer.NormalizePathSeparators(FileIndexer.GetRelativePathFromDirectory(projectPath, filePath)); + var message = BuildSanitizedIndexFileFailureMessage(stage, ex.GetType().Name, out var messageTruncated); + return new IndexFileFailure(relativePath, stage, ex.GetType().Name, message, messageTruncated); + } + + private static IndexFileFailure BuildScanFailure(FileIndexer.ScanError error) + { + var message = SanitizeAndCapMcpIndexFailureMessage(error.Message, out var messageTruncated); + return new IndexFileFailure( + FileIndexer.NormalizePathSeparators(error.Path), + "scan", + nameof(FileIndexer.ScanError), + message, + messageTruncated); + } + + private static McpIndexDiagnostic BuildMcpIndexExceptionDiagnostic( + string code, + string category, + string stage, + string projectRoot, + string filePath, + Exception ex) + { + var path = SanitizeMcpIndexDiagnosticPath(projectRoot, filePath); + var exceptionType = SanitizeMcpIndexFailureToken(ex.GetType().Name, "Exception"); + var message = SanitizeAndCapMcpIndexFailureMessage( + DiagnosticRedactor.FormatExceptionMessage(ex, MaxMcpIndexFailureMessageLength), + out var messageTruncated); + return new McpIndexDiagnostic(code, category, path, stage, exceptionType, message, messageTruncated); + } + + internal static JsonObject BuildMcpIndexExceptionDiagnosticForTesting( + string code, + string category, + string stage, + string projectRoot, + string filePath, + Exception ex) + => BuildMcpIndexDiagnosticJson(BuildMcpIndexExceptionDiagnostic( + code, + category, + stage, + projectRoot, + filePath, + ex)); + + private static string SanitizeMcpIndexDiagnosticPath(string projectRoot, string path) + { + try + { + var relative = FileIndexer.NormalizePathSeparators(FileIndexer.GetRelativePathFromDirectory(projectRoot, path)); + if (!string.IsNullOrWhiteSpace(relative) + && relative != "." + && !relative.StartsWith("../", StringComparison.Ordinal) + && !Path.IsPathRooted(relative)) + { + return McpBoundedText.ForDisplay(relative, 256).Text; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) + { + } + + return ""; + } + + private static void AddMcpIndexDiagnostics( + JsonObject structured, + IReadOnlyList failures, + IReadOnlyList diagnostics) + { + var total = failures.Count + diagnostics.Count; + if (total == 0) + return; + + var categories = new Dictionary(StringComparer.Ordinal); + var items = new JsonArray(); + var emitted = 0; + foreach (var failure in failures) + { + var diagnostic = new McpIndexDiagnostic( + "recoverable_index_error", + "recoverable_index_error", + failure.Path, + failure.Stage, + failure.ExceptionType, + failure.Message, + failure.MessageTruncated); + AddMcpIndexDiagnosticCategory(categories, diagnostic.Category); + if (emitted < 50) + { + items.Add(BuildMcpIndexDiagnosticJson(diagnostic)); + emitted++; + } + } + + foreach (var diagnostic in diagnostics) + { + AddMcpIndexDiagnosticCategory(categories, diagnostic.Category); + if (emitted < 50) + { + items.Add(BuildMcpIndexDiagnosticJson(diagnostic)); + emitted++; + } + } + + var categoryJson = new JsonObject(); + foreach (var entry in categories.OrderBy(entry => entry.Key, StringComparer.Ordinal)) + categoryJson[entry.Key] = entry.Value; + + structured["diagnostics"] = new JsonObject + { + ["total_count"] = total, + ["sample_count"] = emitted, + ["truncated"] = total > emitted, + ["categories"] = categoryJson, + ["items"] = items, + }; + } + + private static void AddMcpIndexDiagnosticCategory(Dictionary categories, string category) + => categories[category] = categories.TryGetValue(category, out var count) ? count + 1 : 1; + + private static JsonObject BuildMcpIndexDiagnosticJson(McpIndexDiagnostic diagnostic) + => new() + { + ["code"] = diagnostic.Code, + ["category"] = diagnostic.Category, + ["path"] = diagnostic.Path, + ["stage"] = diagnostic.Stage, + ["exception_type"] = diagnostic.ExceptionType, + ["message"] = diagnostic.Message, + ["message_truncated"] = diagnostic.MessageTruncated, + }; + + internal static string BuildSanitizedIndexFileFailureMessageForTesting(string stage, string exceptionType, out bool messageTruncated) => + BuildSanitizedIndexFileFailureMessage(stage, exceptionType, out messageTruncated); + + internal static string SanitizeMcpIndexFailureMessageForTesting(string message, out bool messageTruncated) => + SanitizeAndCapMcpIndexFailureMessage(message, out messageTruncated); + +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.cs b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs similarity index 94% rename from src/CodeIndex/Mcp/McpToolHandlers.Indexing.cs rename to src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs index a7df38890..775996cce 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs @@ -14,55 +14,6 @@ namespace CodeIndex.Mcp; public partial class McpServer { - internal static Action? McpIndexInputSnapshotBarrierForTesting { get; set; } - - private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, JsonNode? progressToken = null) - { - try - { - return await ExecuteIndexCoreAsync(id, args, progressToken).ConfigureAwait(false); - } - catch (McpIndexAuthorizationException ex) - { - return CreateIndexAuthorizationErrorResponse(id, ex); - } - catch (AggregateException ex) when (TryExtractIndexAuthorizationException(ex, out var authorizationException)) - { - return CreateIndexAuthorizationErrorResponse(id, authorizationException); - } - } - - private JsonNode CreateIndexAuthorizationErrorResponse( - JsonNode? id, - McpIndexAuthorizationException exception) - => CreateToolErrorResponse( - id, - "MCP index authorization changed after validation; indexing stopped.", - category: McpErrorEnvelope.CategoryPermissionDenied, - suggestion: "Restore a stable directory mapping within the current working directory and MCP client roots, then retry.", - retrySafe: true, - extraData: new JsonObject - { - ["authorization_failure_reason"] = exception.Reason, - ["checked_root_identity"] = exception.CheckedRootIdentity, - }); - - private static bool TryExtractIndexAuthorizationException( - AggregateException exception, - out McpIndexAuthorizationException authorizationException) - { - foreach (var innerException in exception.Flatten().InnerExceptions) - { - if (innerException is McpIndexAuthorizationException matched) - { - authorizationException = matched; - return true; - } - } - - authorizationException = null!; - return false; - } private async Task ExecuteIndexCoreAsync(JsonNode? id, JsonNode? args, JsonNode? progressToken) { @@ -2363,149 +2314,4 @@ await EmitProgressNotificationAsync( structured); } - private static IndexFileFailure BuildIndexFileFailure(string projectPath, string filePath, Exception ex, string stage) - { - var relativePath = FileIndexer.NormalizePathSeparators(FileIndexer.GetRelativePathFromDirectory(projectPath, filePath)); - var message = BuildSanitizedIndexFileFailureMessage(stage, ex.GetType().Name, out var messageTruncated); - return new IndexFileFailure(relativePath, stage, ex.GetType().Name, message, messageTruncated); - } - - private static IndexFileFailure BuildScanFailure(FileIndexer.ScanError error) - { - var message = SanitizeAndCapMcpIndexFailureMessage(error.Message, out var messageTruncated); - return new IndexFileFailure( - FileIndexer.NormalizePathSeparators(error.Path), - "scan", - nameof(FileIndexer.ScanError), - message, - messageTruncated); - } - - private static McpIndexDiagnostic BuildMcpIndexExceptionDiagnostic( - string code, - string category, - string stage, - string projectRoot, - string filePath, - Exception ex) - { - var path = SanitizeMcpIndexDiagnosticPath(projectRoot, filePath); - var exceptionType = SanitizeMcpIndexFailureToken(ex.GetType().Name, "Exception"); - var message = SanitizeAndCapMcpIndexFailureMessage( - DiagnosticRedactor.FormatExceptionMessage(ex, MaxMcpIndexFailureMessageLength), - out var messageTruncated); - return new McpIndexDiagnostic(code, category, path, stage, exceptionType, message, messageTruncated); - } - - internal static JsonObject BuildMcpIndexExceptionDiagnosticForTesting( - string code, - string category, - string stage, - string projectRoot, - string filePath, - Exception ex) - => BuildMcpIndexDiagnosticJson(BuildMcpIndexExceptionDiagnostic( - code, - category, - stage, - projectRoot, - filePath, - ex)); - - private static string SanitizeMcpIndexDiagnosticPath(string projectRoot, string path) - { - try - { - var relative = FileIndexer.NormalizePathSeparators(FileIndexer.GetRelativePathFromDirectory(projectRoot, path)); - if (!string.IsNullOrWhiteSpace(relative) - && relative != "." - && !relative.StartsWith("../", StringComparison.Ordinal) - && !Path.IsPathRooted(relative)) - { - return McpBoundedText.ForDisplay(relative, 256).Text; - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) - { - } - - return ""; - } - - private static void AddMcpIndexDiagnostics( - JsonObject structured, - IReadOnlyList failures, - IReadOnlyList diagnostics) - { - var total = failures.Count + diagnostics.Count; - if (total == 0) - return; - - var categories = new Dictionary(StringComparer.Ordinal); - var items = new JsonArray(); - var emitted = 0; - foreach (var failure in failures) - { - var diagnostic = new McpIndexDiagnostic( - "recoverable_index_error", - "recoverable_index_error", - failure.Path, - failure.Stage, - failure.ExceptionType, - failure.Message, - failure.MessageTruncated); - AddMcpIndexDiagnosticCategory(categories, diagnostic.Category); - if (emitted < 50) - { - items.Add(BuildMcpIndexDiagnosticJson(diagnostic)); - emitted++; - } - } - - foreach (var diagnostic in diagnostics) - { - AddMcpIndexDiagnosticCategory(categories, diagnostic.Category); - if (emitted < 50) - { - items.Add(BuildMcpIndexDiagnosticJson(diagnostic)); - emitted++; - } - } - - var categoryJson = new JsonObject(); - foreach (var entry in categories.OrderBy(entry => entry.Key, StringComparer.Ordinal)) - categoryJson[entry.Key] = entry.Value; - - structured["diagnostics"] = new JsonObject - { - ["total_count"] = total, - ["sample_count"] = emitted, - ["truncated"] = total > emitted, - ["categories"] = categoryJson, - ["items"] = items, - }; - } - - private static void AddMcpIndexDiagnosticCategory(Dictionary categories, string category) - => categories[category] = categories.TryGetValue(category, out var count) ? count + 1 : 1; - - private static JsonObject BuildMcpIndexDiagnosticJson(McpIndexDiagnostic diagnostic) - => new() - { - ["code"] = diagnostic.Code, - ["category"] = diagnostic.Category, - ["path"] = diagnostic.Path, - ["stage"] = diagnostic.Stage, - ["exception_type"] = diagnostic.ExceptionType, - ["message"] = diagnostic.Message, - ["message_truncated"] = diagnostic.MessageTruncated, - }; - - internal static string BuildSanitizedIndexFileFailureMessageForTesting(string stage, string exceptionType, out bool messageTruncated) => - BuildSanitizedIndexFileFailureMessage(stage, exceptionType, out messageTruncated); - - internal static string SanitizeMcpIndexFailureMessageForTesting(string message, out bool messageTruncated) => - SanitizeAndCapMcpIndexFailureMessage(message, out messageTruncated); - - } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Validation.cs b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Validation.cs new file mode 100644 index 000000000..6eeb73c2d --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Validation.cs @@ -0,0 +1,70 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + internal static Action? McpIndexInputSnapshotBarrierForTesting { get; set; } + + private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, JsonNode? progressToken = null) + { + try + { + return await ExecuteIndexCoreAsync(id, args, progressToken).ConfigureAwait(false); + } + catch (McpIndexAuthorizationException ex) + { + return CreateIndexAuthorizationErrorResponse(id, ex); + } + catch (AggregateException ex) when (TryExtractIndexAuthorizationException(ex, out var authorizationException)) + { + return CreateIndexAuthorizationErrorResponse(id, authorizationException); + } + } + + private JsonNode CreateIndexAuthorizationErrorResponse( + JsonNode? id, + McpIndexAuthorizationException exception) + => CreateToolErrorResponse( + id, + "MCP index authorization changed after validation; indexing stopped.", + category: McpErrorEnvelope.CategoryPermissionDenied, + suggestion: "Restore a stable directory mapping within the current working directory and MCP client roots, then retry.", + retrySafe: true, + extraData: new JsonObject + { + ["authorization_failure_reason"] = exception.Reason, + ["checked_root_identity"] = exception.CheckedRootIdentity, + }); + + private static bool TryExtractIndexAuthorizationException( + AggregateException exception, + out McpIndexAuthorizationException authorizationException) + { + foreach (var innerException in exception.Flatten().InnerExceptions) + { + if (innerException is McpIndexAuthorizationException matched) + { + authorizationException = matched; + return true; + } + } + + authorizationException = null!; + return false; + } + + + + +} From fc9e034389ed553385944e7892dd344eb854b029 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 14:31:30 +0900 Subject: [PATCH 026/101] Extract MCP indexing snapshots and result shaping --- .../Mcp/McpToolHandlers.Indexing.Execution.cs | 333 +++++------------- .../Mcp/McpToolHandlers.Indexing.Results.cs | 200 +++++++++++ .../Mcp/McpToolHandlers.Indexing.Snapshots.cs | 77 ++++ .../McpToolHandlers.Indexing.Validation.cs | 68 ++++ 4 files changed, 429 insertions(+), 249 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Indexing.Results.cs create mode 100644 src/CodeIndex/Mcp/McpToolHandlers.Indexing.Snapshots.cs diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs index 775996cce..d6006584b 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs @@ -37,30 +37,13 @@ private async Task ExecuteIndexCoreAsync(JsonNode? id, JsonNode? args, ? new JsonArray { CaptureMcpIndexMemorySample("start", runStopwatch) } : null; - // Prevent path traversal — only allow indexing within current working directory - // パストラバーサル防止 — カレントディレクトリ配下のみインデックスを許可 - var cwd = Path.GetFullPath("."); - if (!McpPathBoundary.IsPathWithinDirectory(cwd, requestedProjectPath)) - return CreateToolErrorResponse(id, "Path must be within the current working directory"); - await RefreshClientRootsIfNeededAsync().ConfigureAwait(false); - if (!IsPathWithinClientRoots(requestedProjectPath)) - return CreateToolErrorResponse(id, "Path must be within an MCP client root"); - + var authorizationResult = await CaptureIndexAuthorizationAsync(id, requestedProjectPath).ConfigureAwait(false); + if (authorizationResult.ErrorResponse != null) + return authorizationResult.ErrorResponse; + var cwd = authorizationResult.CurrentWorkingDirectory; bool IsPathAuthorized(string path) - => McpPathBoundary.IsPathWithinDirectory(cwd, path) && IsPathWithinClientRoots(path); - if (!McpPathBoundary.TryCaptureIndexRoot( - requestedProjectPath, - IsPathAuthorized, - McpIndexEntryOpenBoundaryForTesting, - McpIndexDirectoryEnumerationBoundaryForTesting, - McpIndexDirectoryEnumerationCompletedForTesting, - out var authorization, - out var authorizationError)) - { - return CreateToolErrorResponse(id, authorizationError!); - } - - using var authorizedRoot = authorization!; + => IsIndexPathAuthorized(cwd, path); + using var authorizedRoot = authorizationResult.Authorization!; using var authorizedExtractorConfiguration = ExtractorPluginRegistry.BeginAuthorizedConfigurationScope(); if (_currentIndexAuditContext.Value is { } auditContext) auditContext.CheckedRootIdentity = authorizedRoot.CheckedRootIdentity; @@ -70,75 +53,23 @@ bool IsPathAuthorized(string path) var unsupportedModesJson = BuildMcpIndexUnsupportedModesJson(unsupportedModes); if (dryRun) - { - var ignoreCase = GitHelper.ResolveIgnoreCase(projectPath, _currentRequestToken.Value); - var dryRunRepositoryRoot = GitHelper.TryGetRepositoryRoot(projectPath, _currentRequestToken.Value); - var dryRunIgnoreRuleRoot = dryRunRepositoryRoot != null && IsPathAuthorized(dryRunRepositoryRoot) - ? dryRunRepositoryRoot - : projectPath; - var dryRunIndexer = new FileIndexer( + return BuildIndexDryRunResult( + id, + indexOptions, projectPath, - ignoreCase, - dryRunIgnoreRuleRoot, - maxFileBytes, - directoryIgnoreCaseProbe: null, - symlinkPolicy: symlinkPolicy, - generatedCodePatterns: IndexCommandRunner.ReadGeneratedCodePatternsFromEnvironment(), - pathAccessValidator: authorizedRoot.EnsureAuthorizedEntry, - openReadForIndexContent: authorizedRoot.OpenAuthorizedRead, - enumerateFileSystemEntries: authorizedRoot.EnumerateAuthorizedFileSystemEntries, - bindConfigurationReadsToFileSystemIdentity: true, - internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(_dbPath)); - var scan = dryRunIndexer.ScanFilesDetailed(cancellationToken: _currentRequestToken.Value); - if (memorySamples != null) - memorySamples.Add(CaptureMcpIndexMemorySample("scan", runStopwatch)); - var dryRunFatalScanErrors = scan.Errors.Where(error => error.IsFatal).ToList(); - var dryRunPayload = new JsonObject - { - ["path"] = projectPath, - ["checked_root_identity"] = authorizedRoot.CheckedRootIdentity, - ["dry_run"] = true, - ["would_rebuild"] = rebuild, - ["max_file_bytes"] = maxFileBytes, - ["index_options"] = optionsPayload, - ["unsupported_modes"] = unsupportedModesJson, - ["summary"] = new JsonObject - { - ["files_scanned"] = scan.Files.Count, - ["scan_errors"] = scan.Errors.Count, - ["fatal_scan_errors"] = dryRunFatalScanErrors.Count, - ["unknown_extension_file_count"] = scan.UnknownExtensionFiles.Count, - ["would_mutate_database"] = false, - }, - ["duration_ms"] = runStopwatch.ElapsedMilliseconds, - ["started_at"] = runStartedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), - ["completed_at"] = GetUtcNow().ToString("o", System.Globalization.CultureInfo.InvariantCulture), - }; - if (memorySamples != null) - { - memorySamples.Add(CaptureMcpIndexMemorySample("finalize", runStopwatch)); - dryRunPayload["memory_trace"] = memorySamples; - } - return CreateToolResult(id, "Index dry run complete.", dryRunPayload); - } + cwd, + authorizedRoot, + unsupportedModesJson, + runStartedAtUtc, + runStopwatch, + memorySamples); if (HasBlockingMcpIndexUnsupportedMode(unsupportedModes)) - { - var unsupportedData = new JsonObject - { - ["unsupported_modes"] = unsupportedModesJson, - ["index_options"] = optionsPayload, - ["index_started"] = false, - ["checked_root_identity"] = authorizedRoot.CheckedRootIdentity, - }; - return CreateToolErrorResponse( + return CreateUnsupportedIndexModeResponse( id, - "MCP index does not support the requested scoped or watch indexing mode; no indexing started.", - category: McpErrorEnvelope.CategoryInvalidArgument, - suggestion: "Use dryRun:true to inspect the plan, remove unsupported scope/watch arguments, or run the equivalent cdidx index command in the CLI.", - retrySafe: false, - extraData: unsupportedData); - } + indexOptions, + unsupportedModesJson, + authorizedRoot.CheckedRootIdentity); if (!McpIndexRunLock.TryAcquire(_dbPath, out var indexLock, out var lockError)) return CreateToolErrorResponse(id, lockError!); @@ -156,46 +87,7 @@ bool IsPathAuthorized(string path) ? new DbContext(openIntent, _dbPath, _currentRequestToken.Value) : null; var db = isolatedRequestDb ?? GetOrOpenSharedDb(openIntent); - var csharpMetadataTargetVersionMetaKey = DbContext.GetMetadataTargetVersionMetaKey("csharp"); - var priorMeta = db.GetMetaStrings( - [ - "fold_key_version", - "fold_key_fingerprint", - DbContext.CSharpSymbolNameContractVersionMetaKey, - DbContext.CSharpStaticInterfaceSourceEvidenceMetaKey, - csharpMetadataTargetVersionMetaKey, - DbContext.SqlGraphContractVersionMetaKey, - DbContext.HdlGraphContractVersionMetaKey, - DbContext.SymbolsOnlyGraphOmittedMetaKey, - DbContext.IndexCompletenessMetaKey, - DbContext.IndexedProjectRootMetaKey, - IndexCommandRunner.SymbolKindFilterMetaKey, - ]); - var priorFoldVersion = priorMeta["fold_key_version"]; - var priorFoldFingerprint = priorMeta["fold_key_fingerprint"]; - var priorCSharpSymbolNameContractVersion = priorMeta[DbContext.CSharpSymbolNameContractVersionMetaKey]; - var priorCSharpStaticInterfaceSourceEvidence = - bool.TryParse( - priorMeta[DbContext.CSharpStaticInterfaceSourceEvidenceMetaKey], - out var parsedCSharpStaticInterfaceSourceEvidence) - ? parsedCSharpStaticInterfaceSourceEvidence - : (bool?)null; - var priorMetadataTargetCsharp = priorMeta[csharpMetadataTargetVersionMetaKey]; - var priorSqlGraphContractVersion = priorMeta[DbContext.SqlGraphContractVersionMetaKey]; - var priorHdlGraphContractVersion = priorMeta[DbContext.HdlGraphContractVersionMetaKey]; - var priorSymbolsOnlyGraphOmitted = string.Equals( - priorMeta[DbContext.SymbolsOnlyGraphOmittedMetaKey], - "true", - StringComparison.OrdinalIgnoreCase); - var priorIndexComplete = string.Equals( - priorMeta[DbContext.IndexCompletenessMetaKey], - "complete", - StringComparison.OrdinalIgnoreCase); - var priorReadiness = db.GetUserVersion(); - var priorHotspotFamilyVersions = GetHotspotFamilyMetaSnapshot(db, DbContext.GetHotspotFamilyVersionMetaKey); - var priorHotspotFamilyMarkerFingerprints = GetHotspotFamilyMetaSnapshot(db, DbContext.GetHotspotFamilyMarkerFingerprintMetaKey); - var priorIndexedProjectRoot = priorMeta[DbContext.IndexedProjectRootMetaKey]; - var priorSymbolKindFilterSignature = priorMeta[IndexCommandRunner.SymbolKindFilterMetaKey]; + var indexSnapshot = CaptureIndexDatabaseSnapshot(db); var requestToken = _currentRequestToken.Value; using var suppressDisposeMaintenanceOnCancellation = requestToken.CanBeCanceled ? requestToken.UnsafeRegister( @@ -239,29 +131,29 @@ bool IsPathAuthorized(string path) }); var currentHotspotFamilyMarkerFingerprints = GetHotspotFamilyMarkerFingerprints(indexer, requestToken); var currentCSharpSymbolNameContractVersion = DbContext.CSharpSymbolNameContractVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); - var csharpSymbolNameContractMatchesCurrent = priorCSharpSymbolNameContractVersion == currentCSharpSymbolNameContractVersion; + var csharpSymbolNameContractMatchesCurrent = indexSnapshot.CSharpSymbolNameContractVersion == currentCSharpSymbolNameContractVersion; var currentMetadataTargetVersion = DbContext.MetadataTargetVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); - var csharpMetadataTargetsNeedRefresh = priorMetadataTargetCsharp != currentMetadataTargetVersion; + var csharpMetadataTargetsNeedRefresh = indexSnapshot.MetadataTargetCSharp != currentMetadataTargetVersion; var currentSqlGraphContractVersion = DbContext.SqlGraphContractVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); - var sqlGraphContractMatchesCurrent = priorSqlGraphContractVersion == currentSqlGraphContractVersion; + var sqlGraphContractMatchesCurrent = indexSnapshot.SqlGraphContractVersion == currentSqlGraphContractVersion; var currentHdlGraphContractVersion = DbContext.HdlGraphContractVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); - var hdlGraphContractMatchesCurrent = priorHdlGraphContractVersion == currentHdlGraphContractVersion; + var hdlGraphContractMatchesCurrent = indexSnapshot.HdlGraphContractVersion == currentHdlGraphContractVersion; var hotspotFamilyTrustMatchesCurrent = GetHotspotFamilyTrustMatchesCurrent( - priorHotspotFamilyVersions, - priorHotspotFamilyMarkerFingerprints, + indexSnapshot.HotspotFamilyVersions, + indexSnapshot.HotspotFamilyMarkerFingerprints, currentHotspotFamilyMarkerFingerprints); var symbolKindFilterMatchesPrior = string.Equals( - priorSymbolKindFilterSignature, + indexSnapshot.SymbolKindFilterSignature, symbolKindFilter.Signature, StringComparison.Ordinal); var priorFilterRetainedCSharpContractMembers = SymbolKindFilter.SignatureRetainsCSharpStaticInterfaceContractMembers( - priorSymbolKindFilterSignature); + indexSnapshot.SymbolKindFilterSignature); var symbolKindFilterMetaMarkedIncomplete = symbolKindFilterMatchesPrior; var normalizedProjectPath = Path.GetFullPath(projectPath); - var normalizedPriorIndexedProjectRoot = string.IsNullOrWhiteSpace(priorIndexedProjectRoot) + var normalizedPriorIndexedProjectRoot = string.IsNullOrWhiteSpace(indexSnapshot.IndexedProjectRoot) ? null - : Path.GetFullPath(priorIndexedProjectRoot); + : Path.GetFullPath(indexSnapshot.IndexedProjectRoot); var projectRootWritten = PathsEqual(normalizedPriorIndexedProjectRoot, normalizedProjectPath); var csharpIndexedProjectRootCompatible = normalizedPriorIndexedProjectRoot == null || projectRootWritten; @@ -273,10 +165,10 @@ bool IsPathAuthorized(string path) var ftsMutated = false; var startedWithNoIndexedFiles = rebuild || !writer.HasAnyIndexedFiles(); if (rebuild || startedWithNoIndexedFiles) - priorCSharpStaticInterfaceSourceEvidence = null; + indexSnapshot.CSharpStaticInterfaceSourceEvidence = null; var requiresConservativeCSharpSourceRefresh = !rebuild && !startedWithNoIndexedFiles - && priorCSharpStaticInterfaceSourceEvidence != false; + && indexSnapshot.CSharpStaticInterfaceSourceEvidence != false; // Delay source-evidence invalidation until scan, workspace preflight, and the final // uncached C# stat check finish. This avoids a committed null/true round trip for a // strict positive no-op while dirty runs still publish safe evidence before row writes. @@ -444,7 +336,7 @@ static string FormatDiagnosticPath(string projectRoot, string path) var scanHadErrors = scanResult.HadErrors; var deferCSharpMutationsForIncompleteScan = !startedWithNoIndexedFiles && scanHadErrors - && priorCSharpStaticInterfaceSourceEvidence != false; + && indexSnapshot.CSharpStaticInterfaceSourceEvidence != false; if (memorySamples != null) memorySamples.Add(CaptureMcpIndexMemorySample("scan", runStopwatch)); var files = scanResult.Files; @@ -559,11 +451,11 @@ static string FormatDiagnosticPath(string projectRoot, string path) var hadCSharpStaticInterfaceContractsBeforePurge = !startedWithNoIndexedFiles && staleFilePurgePlan.Count > 0 && writer.HasCSharpFilesInFileIds(staleFilePurgePlan.FileIds, requestToken) - && (priorCSharpStaticInterfaceSourceEvidence == true + && (indexSnapshot.CSharpStaticInterfaceSourceEvidence == true || writer.HasCSharpStaticInterfaceContractMembersInFileIds( staleFilePurgePlan.FileIds, includeInterfaceDeclarationsAsConservativeEvidence: - priorCSharpStaticInterfaceSourceEvidence == null + indexSnapshot.CSharpStaticInterfaceSourceEvidence == null || !priorFilterRetainedCSharpContractMembers, requestToken)); var knownReadableFileSizes = new Dictionary(files.Count, StringComparer.Ordinal); @@ -594,12 +486,12 @@ void RememberReadableFileSize(string path, long size) McpIndexRetainedPathFilterAllocatedForTesting?.Invoke(fileTargets.Length); } await EmitProgressNotificationAsync(progressToken, 0, files.Count, "Index scan complete; indexing files.").ConfigureAwait(false); - var csharpPositiveNoOpPolicyCandidate = priorCSharpStaticInterfaceSourceEvidence is not null - && priorIndexComplete - && (priorReadiness & DbContext.GraphReadyFlag) != 0 + var csharpPositiveNoOpPolicyCandidate = indexSnapshot.CSharpStaticInterfaceSourceEvidence is not null + && indexSnapshot.IndexComplete + && (indexSnapshot.Readiness & DbContext.GraphReadyFlag) != 0 && !scanHadErrors && !hadCSharpStaticInterfaceContractsBeforePurge - && !priorSymbolsOnlyGraphOmitted + && !indexSnapshot.SymbolsOnlyGraphOmitted && symbolKindFilterMatchesPrior && csharpSymbolNameContractMatchesCurrent && csharpIndexedProjectRootCompatible @@ -639,7 +531,7 @@ bool CanReuseCSharpPrepassTargetWithoutRead(CSharpStaticInterfacePrepass.FileTar || !csharpIndexedProjectRootCompatible || (requiresConservativeCSharpSourceRefresh && !priorPositiveCSharpSourceNoOpCandidate) - || priorSymbolsOnlyGraphOmitted + || indexSnapshot.SymbolsOnlyGraphOmitted || !symbolKindFilterMatchesPrior || !csharpSymbolNameContractMatchesCurrent) return false; @@ -822,7 +714,7 @@ CSharpStaticInterfaceWorkspaceSymbols BuildStableCSharpWorkspace( isExistingSymbolPathExcluded: IsExistingCSharpSymbolPathNowNonCSharp, cancellationToken: requestToken)); forceFullCSharpRefreshFromInvalidatedNoOp = - priorCSharpStaticInterfaceSourceEvidence == true + indexSnapshot.CSharpStaticInterfaceSourceEvidence == true || csharpWorkspace.HasStaticInterfaceContracts; } if (!csharpWorkspace.SourceContractEvidenceComplete) @@ -842,7 +734,7 @@ CSharpStaticInterfaceWorkspaceSymbols BuildStableCSharpWorkspace( && allCSharpPrepassTargetsReusable && !deferCSharpMutationsForIncompleteScan; var csharpSourceEvidenceForStamp = preservePriorPositiveCSharpSourceNoOp - ? priorCSharpStaticInterfaceSourceEvidence == true + ? indexSnapshot.CSharpStaticInterfaceSourceEvidence == true : csharpWorkspace.HasSourceStaticInterfaceContracts; var csharpSourceEvidenceComplete = preservePriorPositiveCSharpSourceNoOp || csharpWorkspace.SourceContractEvidenceComplete; @@ -981,7 +873,7 @@ void CountFreshInsertedRows( { var allowStatReuse = !rebuild && !startedWithNoIndexedFiles - && !priorSymbolsOnlyGraphOmitted + && !indexSnapshot.SymbolsOnlyGraphOmitted && symbolKindFilterMatchesPrior && (target.Language != "csharp" || csharpIndexedProjectRootCompatible) && (target.Language != "csharp" || csharpSymbolNameContractMatchesCurrent) @@ -1162,7 +1054,7 @@ void CountFreshInsertedRows( else { var requiresFullCSharpRefresh = - priorCSharpStaticInterfaceSourceEvidence == true + indexSnapshot.CSharpStaticInterfaceSourceEvidence == true || csharpWorkspace.HasStaticInterfaceContracts; forceFullCSharpRefreshFromInvalidatedNoOp = requiresFullCSharpRefresh; csharpSourceEvidenceForStamp = csharpWorkspace.HasSourceStaticInterfaceContracts; @@ -1217,7 +1109,7 @@ await EmitProgressNotificationAsync( var csharpSymbolNameReady = !hasCSharpFiles || (persistedCSharpFiles && csharpSymbolNameContractMatchesCurrent); var csharpMetadataTargetReady = !hasCSharpFiles - || (persistedCSharpFiles && priorMetadataTargetCsharp == currentMetadataTargetVersion); + || (persistedCSharpFiles && indexSnapshot.MetadataTargetCSharp == currentMetadataTargetVersion); var structured = new JsonObject { ["path"] = projectPath, @@ -1253,8 +1145,8 @@ await EmitProgressNotificationAsync( ["sql_graph_contract_ready"] = sqlGraphContractReady, ["csharp_symbol_name_ready"] = csharpSymbolNameReady, ["csharp_metadata_target_ready"] = csharpMetadataTargetReady, - ["fold_ready"] = (priorReadiness & DbContext.FoldReadyFlag) != 0, - ["fold_ready_reason"] = (priorReadiness & DbContext.FoldReadyFlag) != 0 + ["fold_ready"] = (indexSnapshot.Readiness & DbContext.FoldReadyFlag) != 0, + ["fold_ready_reason"] = (indexSnapshot.Readiness & DbContext.FoldReadyFlag) != 0 ? null : DegradationReasonCodes.MissingFoldBackfill, }; @@ -1279,7 +1171,7 @@ await EmitProgressNotificationAsync( structured["failures_truncated"] = failures.Count - 50; AddMcpIndexDiagnostics(structured, failures, mcpIndexDiagnostics); var referenceExtractionCapHits = writer.GetReferenceExtractionCapHits( - issuesStateAvailable: (priorReadiness & DbContext.IssuesReadyFlag) != 0); + issuesStateAvailable: (indexSnapshot.Readiness & DbContext.IssuesReadyFlag) != 0); using var referenceSignalReader = new DbReader(writer.Connection, isReadOnly: true); AddReferenceGraphCompletenessSignal( structured, @@ -2052,8 +1944,8 @@ await EmitProgressNotificationAsync( RestampHotspotFamilyTrust( writer, reusedHotspotFamilyLanguages, - priorHotspotFamilyVersions, - priorHotspotFamilyMarkerFingerprints, + indexSnapshot.HotspotFamilyVersions, + indexSnapshot.HotspotFamilyMarkerFingerprints, currentHotspotFamilyMarkerFingerprints); // A successful refresh can stamp the languages it regenerated even when the // independent fold-key contract remains stale. @@ -2068,8 +1960,8 @@ await EmitProgressNotificationAsync( // MCP も incremental で skip される legacy 行が残るため、実検証を通してから stamp。 var currentFoldVersion = NameFold.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); var currentFoldFingerprint = NameFold.Fingerprint(); - var foldVersionMatchesCurrent = priorFoldVersion == currentFoldVersion; - var foldFingerprintMatchesCurrent = priorFoldFingerprint == currentFoldFingerprint; + var foldVersionMatchesCurrent = indexSnapshot.FoldVersion == currentFoldVersion; + var foldFingerprintMatchesCurrent = indexSnapshot.FoldFingerprint == currentFoldFingerprint; var canRestampExistingFoldTrust = foldVersionMatchesCurrent && foldFingerprintMatchesCurrent; if (skipped == 0 || canRestampExistingFoldTrust) { @@ -2223,95 +2115,38 @@ await EmitProgressNotificationAsync( if (memorySamples != null) memorySamples.Add(CaptureMcpIndexMemorySample("finalize", runStopwatch)); - var structured = new JsonObject - { - ["path"] = projectPath, - ["checked_root_identity"] = authorizedRoot.CheckedRootIdentity, - ["rebuild"] = rebuild, - ["dry_run"] = false, - ["max_file_bytes"] = maxFileBytes, - ["index_options"] = optionsPayload, - ["unsupported_modes"] = unsupportedModesJson, - ["summary"] = new JsonObject - { - ["files"] = totalFiles, - ["chunks"] = totalChunks, - ["symbols"] = totalSymbols, - ["references"] = totalReferences, - ["scanned"] = files.Count, - ["skipped"] = skipped, - ["purged"] = purged, - ["unknown_extension_file_count"] = scanResult.UnknownExtensionFiles.Count, - ["errors"] = errors, - ["failed_count"] = failures.Count, - ["symbols_dropped_by_kind_filter"] = symbolsDroppedByKindFilter - }, - ["symbol_kind_filter"] = new JsonObject - { - ["include"] = ToJsonStringArray(symbolKindFilter.Include), - ["exclude"] = ToJsonStringArray(symbolKindFilter.Exclude), - ["active"] = symbolKindFilter.IsActive, - }, - ["duration_ms"] = runStopwatch.ElapsedMilliseconds, - ["started_at"] = runStartedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), - ["completed_at"] = GetUtcNow().ToString("o", System.Globalization.CultureInfo.InvariantCulture), - ["sql_graph_contract_ready"] = sqlGraphContractReadyAfter, - ["csharp_symbol_name_ready"] = csharpSymbolNameReadyAfter, - ["csharp_metadata_target_ready"] = csharpMetadataTargetReadyAfter, - // #86 codex review: AI clients use this to tell whether --exact will use the - // Unicode fold path or silently fall back to ASCII NOCASE. If false after a clean - ["fold_ready"] = foldReadyAfter, - ["fold_ready_reason"] = foldReadyReason - }; - if (memorySamples != null) - structured["memory_trace"] = memorySamples; - if (failures.Count > 0) - { - var failureArray = new JsonArray(); - foreach (var failure in failures.Take(50)) - { - failureArray.Add(new JsonObject - { - ["path"] = failure.Path, - ["stage"] = failure.Stage, - ["exception_type"] = failure.ExceptionType, - ["message"] = failure.Message, - ["message_truncated"] = failure.MessageTruncated, - }); - } - structured["failed_count"] = failures.Count; - structured["failures"] = failureArray; - if (failures.Count > 50) - structured["failures_truncated"] = failures.Count - 50; - GlobalToolLog.Error( - $"mcp_index_file_failures count={failures.Count} first_path={QuoteMcpIndexFailureLogValue(failures[0].Path)} first_error={QuoteMcpIndexFailureLogValue($"{failures[0].ExceptionType}: {failures[0].Message}")}"); - } - AddMcpIndexDiagnostics(structured, failures, mcpIndexDiagnostics); - using var signalReader = new DbReader(writer.Connection); - AddReferenceGraphCompletenessSignal(structured, signalReader); - if (!sqlGraphContractReadyAfter) - { - var sqlGraphContractSignal = signalReader.GetSqlGraphContractSignal(); - AddSqlGraphContractSignal( - structured, - sqlGraphContractSignal.Relevant && !sqlGraphContractSignal.Ready - ? sqlGraphContractSignal - : new SqlGraphContractSignal( - Ready: false, - Relevant: true, - DegradedReason: DegradationReasonCodes.BuildSqlGraphContractDegradedReason())); - } - return CreateToolResult(id, - errors == 0 && !foldReadyAfter - ? foldReadyReason switch - { - "stale_fold_key_version" => "Indexing complete. Note: --exact Unicode fold path not active because unchanged rows still carry an older fold-key version. Rewrite or purge those stale rows and rerun index, run backfill_fold, or do a full rebuild to upgrade.", - "stale_fold_key_fingerprint" => "Indexing complete. Note: --exact Unicode fold path not active because unchanged rows still carry folded keys generated under an older runtime fingerprint. Rewrite or purge those stale rows and rerun index, run backfill_fold, or do a full rebuild to upgrade.", - "missing_fold_backfill" => "Indexing complete. Note: --exact Unicode fold path not active because legacy rows without name_folded remain. Run backfill_fold to upgrade without reparsing files, or do a full rebuild.", - _ => "Indexing complete. Note: --exact Unicode fold path not active." - } - : "Indexing complete.", - structured); + return BuildIndexCompletionResult( + id, + new IndexCompletionDetails( + projectPath, + authorizedRoot.CheckedRootIdentity, + rebuild, + maxFileBytes, + optionsPayload, + unsupportedModesJson, + totalFiles, + totalChunks, + totalSymbols, + totalReferences, + files.Count, + skipped, + purged, + scanResult.UnknownExtensionFiles.Count, + errors, + symbolsDroppedByKindFilter, + symbolKindFilter, + runStopwatch.ElapsedMilliseconds, + runStartedAtUtc, + GetUtcNow(), + sqlGraphContractReadyAfter, + csharpSymbolNameReadyAfter, + csharpMetadataTargetReadyAfter, + foldReadyAfter, + foldReadyReason, + memorySamples, + failures, + mcpIndexDiagnostics, + writer)); } } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Results.cs b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Results.cs new file mode 100644 index 000000000..d36345e96 --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Results.cs @@ -0,0 +1,200 @@ +using System.Diagnostics; +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + private sealed record IndexCompletionDetails( + string ProjectPath, + string CheckedRootIdentity, + bool Rebuild, + long? MaxFileBytes, + JsonObject IndexOptions, + JsonArray UnsupportedModes, + long TotalFiles, + long TotalChunks, + long TotalSymbols, + long TotalReferences, + int Scanned, + int Skipped, + int Purged, + int UnknownExtensionFileCount, + int Errors, + int SymbolsDroppedByKindFilter, + SymbolKindFilter SymbolKindFilter, + long DurationMilliseconds, + DateTime StartedAtUtc, + DateTime CompletedAtUtc, + bool SqlGraphContractReady, + bool CSharpSymbolNameReady, + bool CSharpMetadataTargetReady, + bool FoldReady, + string? FoldReadyReason, + JsonArray? MemoryTrace, + IReadOnlyList Failures, + IReadOnlyList Diagnostics, + DbWriter Writer); + + private JsonNode BuildIndexDryRunResult( + JsonNode? id, + McpIndexRequestOptions indexOptions, + string projectPath, + string cwd, + McpPathBoundary.IndexRootAuthorization authorizedRoot, + JsonArray unsupportedModes, + DateTime runStartedAtUtc, + Stopwatch runStopwatch, + JsonArray? memorySamples) + { + var requestToken = _currentRequestToken.Value; + var ignoreCase = GitHelper.ResolveIgnoreCase(projectPath, requestToken); + var repositoryRoot = GitHelper.TryGetRepositoryRoot(projectPath, requestToken); + var ignoreRuleRoot = repositoryRoot != null && IsIndexPathAuthorized(cwd, repositoryRoot) + ? repositoryRoot + : projectPath; + var indexer = new FileIndexer( + projectPath, + ignoreCase, + ignoreRuleRoot, + indexOptions.MaxFileBytes, + directoryIgnoreCaseProbe: null, + symlinkPolicy: indexOptions.SymlinkPolicy, + generatedCodePatterns: IndexCommandRunner.ReadGeneratedCodePatternsFromEnvironment(), + pathAccessValidator: authorizedRoot.EnsureAuthorizedEntry, + openReadForIndexContent: authorizedRoot.OpenAuthorizedRead, + enumerateFileSystemEntries: authorizedRoot.EnumerateAuthorizedFileSystemEntries, + bindConfigurationReadsToFileSystemIdentity: true, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(_dbPath)); + var scan = indexer.ScanFilesDetailed(cancellationToken: requestToken); + if (memorySamples != null) + memorySamples.Add(CaptureMcpIndexMemorySample("scan", runStopwatch)); + var fatalScanErrors = scan.Errors.Count(error => error.IsFatal); + var payload = new JsonObject + { + ["path"] = projectPath, + ["checked_root_identity"] = authorizedRoot.CheckedRootIdentity, + ["dry_run"] = true, + ["would_rebuild"] = indexOptions.Rebuild, + ["max_file_bytes"] = indexOptions.MaxFileBytes, + ["index_options"] = indexOptions.OptionsPayload, + ["unsupported_modes"] = unsupportedModes, + ["summary"] = new JsonObject + { + ["files_scanned"] = scan.Files.Count, + ["scan_errors"] = scan.Errors.Count, + ["fatal_scan_errors"] = fatalScanErrors, + ["unknown_extension_file_count"] = scan.UnknownExtensionFiles.Count, + ["would_mutate_database"] = false, + }, + ["duration_ms"] = runStopwatch.ElapsedMilliseconds, + ["started_at"] = runStartedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), + ["completed_at"] = GetUtcNow().ToString("o", System.Globalization.CultureInfo.InvariantCulture), + }; + if (memorySamples != null) + { + memorySamples.Add(CaptureMcpIndexMemorySample("finalize", runStopwatch)); + payload["memory_trace"] = memorySamples; + } + return CreateToolResult(id, "Index dry run complete.", payload); + } + + private JsonNode BuildIndexCompletionResult(JsonNode? id, IndexCompletionDetails details) + { + var structured = new JsonObject + { + ["path"] = details.ProjectPath, + ["checked_root_identity"] = details.CheckedRootIdentity, + ["rebuild"] = details.Rebuild, + ["dry_run"] = false, + ["max_file_bytes"] = details.MaxFileBytes, + ["index_options"] = details.IndexOptions, + ["unsupported_modes"] = details.UnsupportedModes, + ["summary"] = new JsonObject + { + ["files"] = details.TotalFiles, + ["chunks"] = details.TotalChunks, + ["symbols"] = details.TotalSymbols, + ["references"] = details.TotalReferences, + ["scanned"] = details.Scanned, + ["skipped"] = details.Skipped, + ["purged"] = details.Purged, + ["unknown_extension_file_count"] = details.UnknownExtensionFileCount, + ["errors"] = details.Errors, + ["failed_count"] = details.Failures.Count, + ["symbols_dropped_by_kind_filter"] = details.SymbolsDroppedByKindFilter + }, + ["symbol_kind_filter"] = new JsonObject + { + ["include"] = ToJsonStringArray(details.SymbolKindFilter.Include), + ["exclude"] = ToJsonStringArray(details.SymbolKindFilter.Exclude), + ["active"] = details.SymbolKindFilter.IsActive, + }, + ["duration_ms"] = details.DurationMilliseconds, + ["started_at"] = details.StartedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), + ["completed_at"] = details.CompletedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture), + ["sql_graph_contract_ready"] = details.SqlGraphContractReady, + ["csharp_symbol_name_ready"] = details.CSharpSymbolNameReady, + ["csharp_metadata_target_ready"] = details.CSharpMetadataTargetReady, + // #86 codex review: AI clients use this to tell whether --exact will use the + // Unicode fold path or silently fall back to ASCII NOCASE. If false after a clean + ["fold_ready"] = details.FoldReady, + ["fold_ready_reason"] = details.FoldReadyReason + }; + if (details.MemoryTrace != null) + structured["memory_trace"] = details.MemoryTrace; + if (details.Failures.Count > 0) + { + var failureArray = new JsonArray(); + foreach (var failure in details.Failures.Take(50)) + { + failureArray.Add(new JsonObject + { + ["path"] = failure.Path, + ["stage"] = failure.Stage, + ["exception_type"] = failure.ExceptionType, + ["message"] = failure.Message, + ["message_truncated"] = failure.MessageTruncated, + }); + } + structured["failed_count"] = details.Failures.Count; + structured["failures"] = failureArray; + if (details.Failures.Count > 50) + structured["failures_truncated"] = details.Failures.Count - 50; + GlobalToolLog.Error( + $"mcp_index_file_failures count={details.Failures.Count} first_path={QuoteMcpIndexFailureLogValue(details.Failures[0].Path)} first_error={QuoteMcpIndexFailureLogValue($"{details.Failures[0].ExceptionType}: {details.Failures[0].Message}")}"); + } + AddMcpIndexDiagnostics(structured, details.Failures, details.Diagnostics); + using var signalReader = new DbReader(details.Writer.Connection); + AddReferenceGraphCompletenessSignal(structured, signalReader); + if (!details.SqlGraphContractReady) + { + var sqlGraphContractSignal = signalReader.GetSqlGraphContractSignal(); + AddSqlGraphContractSignal( + structured, + sqlGraphContractSignal.Relevant && !sqlGraphContractSignal.Ready + ? sqlGraphContractSignal + : new SqlGraphContractSignal( + Ready: false, + Relevant: true, + DegradedReason: DegradationReasonCodes.BuildSqlGraphContractDegradedReason())); + } + + return CreateToolResult( + id, + details.Errors == 0 && !details.FoldReady + ? details.FoldReadyReason switch + { + "stale_fold_key_version" => "Indexing complete. Note: --exact Unicode fold path not active because unchanged rows still carry an older fold-key version. Rewrite or purge those stale rows and rerun index, run backfill_fold, or do a full rebuild to upgrade.", + "stale_fold_key_fingerprint" => "Indexing complete. Note: --exact Unicode fold path not active because unchanged rows still carry folded keys generated under an older runtime fingerprint. Rewrite or purge those stale rows and rerun index, run backfill_fold, or do a full rebuild to upgrade.", + "missing_fold_backfill" => "Indexing complete. Note: --exact Unicode fold path not active because legacy rows without name_folded remain. Run backfill_fold to upgrade without reparsing files, or do a full rebuild.", + _ => "Indexing complete. Note: --exact Unicode fold path not active." + } + : "Indexing complete.", + structured); + } +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Snapshots.cs b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Snapshots.cs new file mode 100644 index 000000000..5c877e83f --- /dev/null +++ b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Snapshots.cs @@ -0,0 +1,77 @@ +using CodeIndex.Cli; +using CodeIndex.Database; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + private sealed class IndexDatabaseSnapshot + { + public string? FoldVersion { get; init; } + public string? FoldFingerprint { get; init; } + public string? CSharpSymbolNameContractVersion { get; init; } + public bool? CSharpStaticInterfaceSourceEvidence { get; set; } + public string? MetadataTargetCSharp { get; init; } + public string? SqlGraphContractVersion { get; init; } + public string? HdlGraphContractVersion { get; init; } + public bool SymbolsOnlyGraphOmitted { get; init; } + public bool IndexComplete { get; init; } + public int Readiness { get; init; } + public required Dictionary HotspotFamilyVersions { get; init; } + public required Dictionary HotspotFamilyMarkerFingerprints { get; init; } + public string? IndexedProjectRoot { get; init; } + public string? SymbolKindFilterSignature { get; init; } + } + + private static IndexDatabaseSnapshot CaptureIndexDatabaseSnapshot(DbContext db) + { + var csharpMetadataTargetVersionMetaKey = DbContext.GetMetadataTargetVersionMetaKey("csharp"); + var meta = db.GetMetaStrings( + [ + "fold_key_version", + "fold_key_fingerprint", + DbContext.CSharpSymbolNameContractVersionMetaKey, + DbContext.CSharpStaticInterfaceSourceEvidenceMetaKey, + csharpMetadataTargetVersionMetaKey, + DbContext.SqlGraphContractVersionMetaKey, + DbContext.HdlGraphContractVersionMetaKey, + DbContext.SymbolsOnlyGraphOmittedMetaKey, + DbContext.IndexCompletenessMetaKey, + DbContext.IndexedProjectRootMetaKey, + IndexCommandRunner.SymbolKindFilterMetaKey, + ]); + + return new IndexDatabaseSnapshot + { + FoldVersion = meta["fold_key_version"], + FoldFingerprint = meta["fold_key_fingerprint"], + CSharpSymbolNameContractVersion = meta[DbContext.CSharpSymbolNameContractVersionMetaKey], + CSharpStaticInterfaceSourceEvidence = + bool.TryParse( + meta[DbContext.CSharpStaticInterfaceSourceEvidenceMetaKey], + out var parsedCSharpStaticInterfaceSourceEvidence) + ? parsedCSharpStaticInterfaceSourceEvidence + : null, + MetadataTargetCSharp = meta[csharpMetadataTargetVersionMetaKey], + SqlGraphContractVersion = meta[DbContext.SqlGraphContractVersionMetaKey], + HdlGraphContractVersion = meta[DbContext.HdlGraphContractVersionMetaKey], + SymbolsOnlyGraphOmitted = string.Equals( + meta[DbContext.SymbolsOnlyGraphOmittedMetaKey], + "true", + StringComparison.OrdinalIgnoreCase), + IndexComplete = string.Equals( + meta[DbContext.IndexCompletenessMetaKey], + "complete", + StringComparison.OrdinalIgnoreCase), + Readiness = db.GetUserVersion(), + HotspotFamilyVersions = GetHotspotFamilyMetaSnapshot( + db, + DbContext.GetHotspotFamilyVersionMetaKey), + HotspotFamilyMarkerFingerprints = GetHotspotFamilyMetaSnapshot( + db, + DbContext.GetHotspotFamilyMarkerFingerprintMetaKey), + IndexedProjectRoot = meta[DbContext.IndexedProjectRootMetaKey], + SymbolKindFilterSignature = meta[IndexCommandRunner.SymbolKindFilterMetaKey], + }; + } +} diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Validation.cs b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Validation.cs index 6eeb73c2d..3baf500d9 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Validation.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Validation.cs @@ -14,6 +14,11 @@ namespace CodeIndex.Mcp; public partial class McpServer { + private sealed record IndexAuthorizationResult( + string CurrentWorkingDirectory, + McpPathBoundary.IndexRootAuthorization? Authorization, + JsonNode? ErrorResponse); + internal static Action? McpIndexInputSnapshotBarrierForTesting { get; set; } private async Task ExecuteIndexAsync(JsonNode? id, JsonNode? args, JsonNode? progressToken = null) @@ -64,7 +69,70 @@ private static bool TryExtractIndexAuthorizationException( return false; } + private async Task CaptureIndexAuthorizationAsync( + JsonNode? id, + string requestedProjectPath) + { + // Prevent path traversal — only allow indexing within current working directory + // パストラバーサル防止 — カレントディレクトリ配下のみインデックスを許可 + var cwd = Path.GetFullPath("."); + if (!McpPathBoundary.IsPathWithinDirectory(cwd, requestedProjectPath)) + { + return new IndexAuthorizationResult( + cwd, + null, + CreateToolErrorResponse(id, "Path must be within the current working directory")); + } + await RefreshClientRootsIfNeededAsync().ConfigureAwait(false); + if (!IsPathWithinClientRoots(requestedProjectPath)) + { + return new IndexAuthorizationResult( + cwd, + null, + CreateToolErrorResponse(id, "Path must be within an MCP client root")); + } + if (!McpPathBoundary.TryCaptureIndexRoot( + requestedProjectPath, + path => IsIndexPathAuthorized(cwd, path), + McpIndexEntryOpenBoundaryForTesting, + McpIndexDirectoryEnumerationBoundaryForTesting, + McpIndexDirectoryEnumerationCompletedForTesting, + out var authorization, + out var authorizationError)) + { + return new IndexAuthorizationResult( + cwd, + null, + CreateToolErrorResponse(id, authorizationError!)); + } + return new IndexAuthorizationResult(cwd, authorization, null); + } + + private bool IsIndexPathAuthorized(string cwd, string path) + => McpPathBoundary.IsPathWithinDirectory(cwd, path) && IsPathWithinClientRoots(path); + + private JsonNode CreateUnsupportedIndexModeResponse( + JsonNode? id, + McpIndexRequestOptions indexOptions, + JsonArray unsupportedModes, + string checkedRootIdentity) + { + var unsupportedData = new JsonObject + { + ["unsupported_modes"] = unsupportedModes, + ["index_options"] = indexOptions.OptionsPayload, + ["index_started"] = false, + ["checked_root_identity"] = checkedRootIdentity, + }; + return CreateToolErrorResponse( + id, + "MCP index does not support the requested scoped or watch indexing mode; no indexing started.", + category: McpErrorEnvelope.CategoryInvalidArgument, + suggestion: "Use dryRun:true to inspect the plan, remove unsupported scope/watch arguments, or run the equivalent cdidx index command in the CLI.", + retrySafe: false, + extraData: unsupportedData); + } } From c807f13f1498f8f87c6b48cbb4274fe37949ac1e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 14:40:57 +0900 Subject: [PATCH 027/101] Split concurrent MCP transport frame lifecycle --- .../Mcp/McpServer.Transport.ConcurrentLoop.cs | 348 ++++++++++++++++++ src/CodeIndex/Mcp/McpServer.Transport.cs | 289 +-------------- 2 files changed, 356 insertions(+), 281 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.Transport.ConcurrentLoop.cs diff --git a/src/CodeIndex/Mcp/McpServer.Transport.ConcurrentLoop.cs b/src/CodeIndex/Mcp/McpServer.Transport.ConcurrentLoop.cs new file mode 100644 index 000000000..a2b5917af --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.Transport.ConcurrentLoop.cs @@ -0,0 +1,348 @@ +using System.Collections.Concurrent; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + private sealed class ConcurrentFrameLoopState + { + private readonly McpServer _server; + private readonly bool _hasRequestScopedWriters; + + public ConcurrentFrameLoopState( + McpServer server, + IMcpTransport transport, + CancellationToken loopToken, + CancellationToken externalCancellationToken) + { + _server = server; + Transport = transport; + LoopToken = loopToken; + ExternalCancellationToken = externalCancellationToken; + _hasRequestScopedWriters = transport is IConcurrentMcpTransport; + AdmissionGate = new SemaphoreSlim( + server.MaxAcceptedConcurrentFrames, + server.MaxAcceptedConcurrentFrames); + } + + public IMcpTransport Transport { get; } + public CancellationToken LoopToken { get; } + public CancellationToken ExternalCancellationToken { get; } + public SemaphoreSlim WriteGate { get; } = new(1, 1); + public SemaphoreSlim AdmissionGate { get; } + public List Tasks { get; } = []; + public Task ProtocolBarrier { get; private set; } = Task.CompletedTask; + public Task? TerminalTransportWriteTask { get; private set; } + + public bool TryAdmit() + { + if (!AdmissionGate.Wait(0)) + return false; + + Interlocked.Increment(ref _server._acceptedConcurrentFrameCount); + return true; + } + + public void ReleaseAdmission() + { + Interlocked.Decrement(ref _server._acceptedConcurrentFrameCount); + AdmissionGate.Release(); + } + + public Lazy CreateProtocolPredecessor(bool isProtocolBarrier) + { + var precedingBarrier = ProtocolBarrier; + var tasksAcceptedBeforeBarrier = isProtocolBarrier ? Tasks.ToArray() : []; + Func awaitPredecessorsAsync = isProtocolBarrier + ? token => AwaitProtocolPredecessorsAsync(tasksAcceptedBeforeBarrier, token) + : token => AwaitProtocolPredecessorsAsync([precedingBarrier], token); + return new Lazy( + () => awaitPredecessorsAsync(LoopToken), + LazyThreadSafetyMode.ExecutionAndPublication); + } + + public void TrackRequest(Task requestTask, bool isProtocolBarrier) + { + Tasks.Add(requestTask); + if (isProtocolBarrier) + ProtocolBarrier = requestTask; + } + + public void ScheduleTerminalProtocolError(string response) + { + _server.BeginDeferredFrameLogs(); + TerminalTransportWriteTask = _server.WriteTerminalProtocolErrorAsync( + WriteGate, + Transport, + response, + ExternalCancellationToken); + } + + public async Task WriteResponseAsync( + Func writeResponseAsync, + string? response) + { + // Concurrent transports provide one writer per request, so serializing those writers + // behind the base-transport gate lets an unrelated stuck response retain later HTTP + // request resources. Base transports (notably stdio) still require the shared gate. + // concurrent transport は request ごとの writer を持つため、base transport 用 gate + // に直列化すると無関係な stuck response が後続 HTTP resource を保持してしまう。 + // stdio 等の base transport だけ shared gate を維持する (#4546)。 + if (_hasRequestScopedWriters) + { + await WriteFrameSafelyAsync( + writeResponseAsync, + response, + ExternalCancellationToken).ConfigureAwait(false); + _server.FlushDeferredFrameLogs(); + return; + } + + await WriteGate.WaitAsync(ExternalCancellationToken).ConfigureAwait(false); + try + { + await WriteFrameSafelyAsync( + writeResponseAsync, + response, + ExternalCancellationToken).ConfigureAwait(false); + _server.FlushDeferredFrameLogs(); + } + finally + { + WriteGate.Release(); + } + } + + public Func? CreateOutOfBandFrameWriter() + { + if (Transport is IOutOfBandMcpTransport outOfBandTransport) + return (frame, token) => outOfBandTransport.WriteOutOfBandFrameAsync(frame, token); + if (!string.Equals(Transport.Name, "stdio", StringComparison.OrdinalIgnoreCase)) + return null; + + return async (frame, token) => + { + await WriteGate.WaitAsync(token).ConfigureAwait(false); + try + { + await Transport.WriteFrameAsync(frame, token).ConfigureAwait(false); + } + finally + { + WriteGate.Release(); + } + }; + } + + public async Task DrainAndScheduleGateDisposalAsync() + { + try + { + await _server.DrainInFlightTasksAsync( + Tasks, + _server.InFlightDrainGracePeriod, + _server.InFlightPostCancelGracePeriod, + ExternalCancellationToken, + TerminalTransportWriteTask).ConfigureAwait(false); + } + finally + { + // The bounded EOF drain can intentionally leave late request tasks running. Those + // tasks can still own the write gate or reach the stdio writer until their finally + // blocks run. Publish that aggregate even if draining itself exits unexpectedly, + // then clean up the gates only after every accepted task is done (#3999, #4543). + // bounded EOF drain は late request task を残すことがある。finally が走るまで gate や + // stdio writer を使い得るため、drain 自体が異常終了しても aggregate を公開し、全 + // accepted task 完了後に gate を dispose する (#3999, #4543)。 + var transportWork = BuildDrainOperationsTask(Tasks, TerminalTransportWriteTask); + if (Transport is StdioMcpTransport stdioTransport) + stdioTransport.DeferDisposalUntil(transportWork); + _ = DisposeConcurrentLoopGatesAfterAsync(transportWork, WriteGate, AdmissionGate); + } + } + } + + private static async Task ReadConcurrentTransportFrameAsync( + IMcpTransport transport, + CancellationToken cancellationToken) + { + if (transport is IConcurrentMcpTransport concurrentTransport) + return await concurrentTransport.ReadConcurrentFrameAsync(cancellationToken).ConfigureAwait(false); + + var frame = await transport.ReadFrameAsync(cancellationToken).ConfigureAwait(false); + return frame is null ? null : new McpTransportFrame(frame, transport.WriteFrameAsync); + } + + private async Task TryProcessInlineConcurrentFrameAsync( + ConcurrentFrameLoopState state, + McpTransportFrame transportFrame) + { + var frame = transportFrame.Frame; + if (IsCancellationFrame(frame) || IsServerResponseFrame(frame)) + { + try + { + BeginDeferredFrameLogs(); + var response = await ProcessFrameAsync(frame).ConfigureAwait(false); + await state.WriteResponseAsync(transportFrame.WriteResponseAsync, response).ConfigureAwait(false); + } + finally + { + transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); + } + return true; + } + + // Admission is deliberately non-blocking: waiting here would prevent a later + // cancellation/client-response frame from being read while execution is saturated. + // Excess ordinary work receives a retry-safe JSON-RPC overload response instead of + // retaining another frame/task/HTTP context without bound (#4536). + // admission は non-blocking にする。ここで待つと execution 飽和中に後続の + // cancellation/client-response frame を読めなくなるため。上限超過 work は task や + // HTTP context を保持し続けず、retry-safe overload response を返す (#4536)。 + if (state.TryAdmit()) + return false; + + try + { + // Keep every response-bearing id registered until its retry-safe overload + // response has reached the transport. A cancellation before or during that + // write then belongs to this rejected occurrence instead of poisoning a later + // same-id retry (#4536, #4545). + // retry-safe overload 応答が transport へ届くまで response-bearing id を登録する。 + // reject 前または write 中の cancel をこの occurrence に束縛し、同じ id の後続 + // retry へ持ち越さない (#4536, #4545)。 + using var capacityRejectedRegistrations = new CapacityRejectedFrameRegistrations(this); + BeginDeferredFrameLogs(); + var response = await ProcessFrameAsync( + frame, + beforeDispatchAsync: null, + rejectForCapacity: true, + capacityRejectedRegistrations: capacityRejectedRegistrations).ConfigureAwait(false); + await state.WriteResponseAsync(transportFrame.WriteResponseAsync, response).ConfigureAwait(false); + } + finally + { + transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); + } + return true; + } + + private async Task StartAcceptedConcurrentFrameAsync( + ConcurrentFrameLoopState state, + McpTransportFrame transportFrame) + { + var requestTaskStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var isProtocolBarrier = IsProtocolOrderingBarrierFrame(transportFrame.Frame); + var predecessorTask = state.CreateProtocolPredecessor(isProtocolBarrier); + Task requestTask; + try + { + requestTask = Task.Run( + () => ExecuteAcceptedConcurrentFrameAsync( + state, + transportFrame, + predecessorTask, + requestTaskStarted), + CancellationToken.None); + } + catch + { + transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); + state.ReleaseAdmission(); + throw; + } + + state.TrackRequest(requestTask, isProtocolBarrier); + await requestTaskStarted.Task.ConfigureAwait(false); + } + + private async Task ExecuteAcceptedConcurrentFrameAsync( + ConcurrentFrameLoopState state, + McpTransportFrame transportFrame, + Lazy predecessorTask, + TaskCompletionSource requestTaskStarted) + { + var detachedIsolatedActions = new ConcurrentQueue(); + var previousDetachedIsolatedActions = _currentDetachedIsolatedActions.Value; + try + { + requestTaskStarted.TrySetResult(); + using var frameCts = transportFrame.RequestCancellationToken.CanBeCanceled + ? CancellationTokenSource.CreateLinkedTokenSource( + state.LoopToken, + transportFrame.RequestCancellationToken) + : null; + var frameToken = frameCts?.Token ?? state.LoopToken; + string? response = null; + try + { + _currentDetachedIsolatedActions.Value = detachedIsolatedActions; + _currentRequestToken.Value = frameToken; + _currentOutOfBandFrameWriter.Value = state.CreateOutOfBandFrameWriter(); + _canAwaitClientResponses.Value = _currentOutOfBandFrameWriter.Value is not null + && (state.Transport is not HttpMcpTransport httpResponseTransport || httpResponseTransport.HasEventStreams); + BeginDeferredFrameLogs(); + response = await ProcessFrameAsync( + transportFrame.Frame, + token => predecessorTask.Value.WaitAsync(token), + rejectForCapacity: false).ConfigureAwait(false); + } + catch (OperationCanceledException) when (frameToken.IsCancellationRequested) + { + // Keep the transport's strict one-frame/one-writer contract. HTTP observes its + // own terminal reason and aborts/finalizes the response when the per-request + // lifetime expires (#4546). + // transport の frame/writer 対応を維持する。request lifetime 期限切れ時は HTTP 側が + // terminal reason を観測して response を abort/finalize する。 + response = null; + } + finally + { + _currentDetachedIsolatedActions.Value = previousDetachedIsolatedActions; + _currentRequestToken.Value = CancellationToken.None; + _canAwaitClientResponses.Value = false; + _currentOutOfBandFrameWriter.Value = null; + } + + // Malformed/unauthorized frames can return before normal dispatch. Start their + // predecessor wait here so such a frame cannot collapse a protocol barrier. + // malformed / unauthorized frame が dispatch 前に return しても protocol + // barrier を消してしまわないよう、未開始ならここで predecessor を待つ。 + if (!predecessorTask.IsValueCreated) + { + try + { + await predecessorTask.Value.WaitAsync(frameToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (frameToken.IsCancellationRequested) + { + // A canceled frame no longer needs protocol ordering, but its + // request-scoped writer still owns mandatory response cleanup. + // cancel 済み frame は protocol ordering を待たず、対応 writer + // による必須 cleanup だけを完了させる (#4546)。 + response = null; + } + } + + await state.WriteResponseAsync(transportFrame.WriteResponseAsync, response).ConfigureAwait(false); + } + finally + { + var retainedWork = detachedIsolatedActions.IsEmpty + ? Task.CompletedTask + : ObserveDetachedIsolatedActionsAsync(detachedIsolatedActions.ToArray()); + transportFrame.CompleteResourceRetentionWhen(retainedWork); + state.ReleaseAdmission(); + + // A canceled or timed-out isolated action may still be unwinding durable writer + // cleanup after its response has been sent. Release frame admission and the transport + // resource callback first, then keep the outer request task attached to that cleanup + // so EOF's bounded drain cannot return while the action is restoring database state. + // cancel / timeout 応答後も isolated action が永続 writer cleanup を unwind 中の場合がある。 + // frame admission と transport resource callback を先に解放し、その後 outer request + // task を cleanup に接続して、EOF の bounded drain が database 復元中に戻らないようにする。 + await retainedWork.ConfigureAwait(false); + } + } +} diff --git a/src/CodeIndex/Mcp/McpServer.Transport.cs b/src/CodeIndex/Mcp/McpServer.Transport.cs index b23d5f7ac..98c5ccc5a 100644 --- a/src/CodeIndex/Mcp/McpServer.Transport.cs +++ b/src/CodeIndex/Mcp/McpServer.Transport.cs @@ -292,67 +292,17 @@ private async Task RunConcurrentFrameLoopAsync( CancellationToken loopToken, CancellationToken externalCancellationToken) { - var writeGate = new SemaphoreSlim(1, 1); - var admissionGate = new SemaphoreSlim(MaxAcceptedConcurrentFrames, MaxAcceptedConcurrentFrames); - var tasks = new List(); - Task protocolBarrier = Task.CompletedTask; - Task? terminalTransportWriteTask = null; - var hasRequestScopedWriters = transport is IConcurrentMcpTransport; - - async Task WriteTransportFrameResponseAsync( - Func writeResponseAsync, - string? response) - { - // Concurrent transports provide one writer per request, so serializing those writers - // behind the base-transport gate lets an unrelated stuck response retain later HTTP - // request resources. Base transports (notably stdio) still require the shared gate. - // concurrent transport は request ごとの writer を持つため、base transport 用 gate - // に直列化すると無関係な stuck response が後続 HTTP resource を保持してしまう。 - // stdio 等の base transport だけ shared gate を維持する (#4546)。 - if (hasRequestScopedWriters) - { - await WriteFrameSafelyAsync( - writeResponseAsync, - response, - externalCancellationToken).ConfigureAwait(false); - FlushDeferredFrameLogs(); - return; - } - - await writeGate.WaitAsync(externalCancellationToken).ConfigureAwait(false); - try - { - await WriteFrameSafelyAsync( - writeResponseAsync, - response, - externalCancellationToken).ConfigureAwait(false); - FlushDeferredFrameLogs(); - } - finally - { - writeGate.Release(); - } - } + var state = new ConcurrentFrameLoopState(this, transport, loopToken, externalCancellationToken); try { while (_running) { - PruneCompletedRequestTasks(tasks); + PruneCompletedRequestTasks(state.Tasks); McpTransportFrame? transportFrame; try { - if (transport is IConcurrentMcpTransport concurrentTransport) - { - transportFrame = await concurrentTransport.ReadConcurrentFrameAsync(loopToken).ConfigureAwait(false); - } - else - { - var readFrame = await transport.ReadFrameAsync(loopToken).ConfigureAwait(false); - transportFrame = readFrame is null - ? null - : new McpTransportFrame(readFrame, transport.WriteFrameAsync); - } + transportFrame = await ReadConcurrentTransportFrameAsync(transport, loopToken).ConfigureAwait(false); } catch (OperationCanceledException) when (loopToken.IsCancellationRequested) { @@ -360,228 +310,27 @@ await WriteFrameSafelyAsync( } catch (DecoderFallbackException ex) { - BeginDeferredFrameLogs(); - terminalTransportWriteTask = WriteTerminalProtocolErrorAsync( - writeGate, - transport, - BuildInvalidUtf8ParseErrorResponse(ex), - externalCancellationToken); + state.ScheduleTerminalProtocolError(BuildInvalidUtf8ParseErrorResponse(ex)); break; } catch (BoundedLineLengthException ex) { - BeginDeferredFrameLogs(); - terminalTransportWriteTask = WriteTerminalProtocolErrorAsync( - writeGate, - transport, - BuildOversizedLineErrorResponse(ex), - externalCancellationToken); + state.ScheduleTerminalProtocolError(BuildOversizedLineErrorResponse(ex)); break; } if (transportFrame is null) break; - var frame = transportFrame.Frame; - var writeResponseAsync = transportFrame.WriteResponseAsync; - var transportRequestToken = transportFrame.RequestCancellationToken; - if (IsCancellationFrame(frame)) - { - try - { - BeginDeferredFrameLogs(); - var response = await ProcessFrameAsync(frame).ConfigureAwait(false); - await WriteTransportFrameResponseAsync(writeResponseAsync, response).ConfigureAwait(false); - } - finally - { - transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); - } - continue; - } - - if (IsServerResponseFrame(frame)) - { - try - { - BeginDeferredFrameLogs(); - var response = await ProcessFrameAsync(frame).ConfigureAwait(false); - await WriteTransportFrameResponseAsync(writeResponseAsync, response).ConfigureAwait(false); - } - finally - { - transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); - } + if (await TryProcessInlineConcurrentFrameAsync(state, transportFrame).ConfigureAwait(false)) continue; - } - // Admission is deliberately non-blocking: waiting here would prevent a later - // cancellation/client-response frame from being read while execution is saturated. - // Excess ordinary work receives a retry-safe JSON-RPC overload response instead of - // retaining another frame/task/HTTP context without bound (#4536). - // admission は non-blocking にする。ここで待つと execution 飽和中に後続の - // cancellation/client-response frame を読めなくなるため。上限超過 work は task や - // HTTP context を保持し続けず、retry-safe overload response を返す (#4536)。 - if (!admissionGate.Wait(0)) - { - try - { - // Keep every response-bearing id registered until its retry-safe overload - // response has reached the transport. A cancellation before or during that - // write then belongs to this rejected occurrence instead of poisoning a later - // same-id retry (#4536, #4545). - // retry-safe overload 応答が transport へ届くまで response-bearing id を登録する。 - // reject 前または write 中の cancel をこの occurrence に束縛し、同じ id の後続 - // retry へ持ち越さない (#4536, #4545)。 - using var capacityRejectedRegistrations = new CapacityRejectedFrameRegistrations(this); - BeginDeferredFrameLogs(); - var response = await ProcessFrameAsync( - frame, - beforeDispatchAsync: null, - rejectForCapacity: true, - capacityRejectedRegistrations: capacityRejectedRegistrations).ConfigureAwait(false); - await WriteTransportFrameResponseAsync(writeResponseAsync, response).ConfigureAwait(false); - } - finally - { - transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); - } - continue; - } - Interlocked.Increment(ref _acceptedConcurrentFrameCount); - - var requestTaskStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var isProtocolBarrier = IsProtocolOrderingBarrierFrame(frame); - var precedingBarrier = protocolBarrier; - var tasksAcceptedBeforeBarrier = isProtocolBarrier ? tasks.ToArray() : []; - Func awaitPredecessorsAsync = isProtocolBarrier - ? token => AwaitProtocolPredecessorsAsync(tasksAcceptedBeforeBarrier, token) - : token => AwaitProtocolPredecessorsAsync([precedingBarrier], token); - var predecessorTask = new Lazy( - () => awaitPredecessorsAsync(loopToken), - LazyThreadSafetyMode.ExecutionAndPublication); - Task BeforeDispatchAsync(CancellationToken token) - => predecessorTask.Value.WaitAsync(token); // Accepted frames are bounded independently from executing operations. The request // registers its id/cancellation state before awaiting protocol predecessors and the // execution gate, so a cancellation cannot expire while queued (#4536). // accepted frame と executing operation は別々に上限化する。request は protocol // predecessor / execution gate を待つ前に id と cancellation state を登録するため、 // queue 中に cancellation が失効しない (#4536)。 - Task requestTask; - try - { - requestTask = Task.Run(async () => - { - var detachedIsolatedActions = new ConcurrentQueue(); - var previousDetachedIsolatedActions = _currentDetachedIsolatedActions.Value; - try - { - requestTaskStarted.TrySetResult(); - using var frameCts = transportRequestToken.CanBeCanceled - ? CancellationTokenSource.CreateLinkedTokenSource(loopToken, transportRequestToken) - : null; - var frameToken = frameCts?.Token ?? loopToken; - string? response = null; - try - { - _currentDetachedIsolatedActions.Value = detachedIsolatedActions; - _currentRequestToken.Value = frameToken; - _currentOutOfBandFrameWriter.Value = transport is IOutOfBandMcpTransport outOfBandTransport - ? (frameToWrite, writeToken) => outOfBandTransport.WriteOutOfBandFrameAsync(frameToWrite, writeToken) - : string.Equals(transport.Name, "stdio", StringComparison.OrdinalIgnoreCase) - ? async (frameToWrite, writeToken) => - { - await writeGate.WaitAsync(writeToken).ConfigureAwait(false); - try - { - await transport.WriteFrameAsync(frameToWrite, writeToken).ConfigureAwait(false); - } - finally - { - writeGate.Release(); - } - } - : null; - _canAwaitClientResponses.Value = _currentOutOfBandFrameWriter.Value is not null - && (transport is not HttpMcpTransport httpResponseTransport || httpResponseTransport.HasEventStreams); - BeginDeferredFrameLogs(); - response = await ProcessFrameAsync( - frame, - BeforeDispatchAsync, - rejectForCapacity: false).ConfigureAwait(false); - } - catch (OperationCanceledException) when (frameToken.IsCancellationRequested) - { - // Keep the transport's strict one-frame/one-writer contract. HTTP - // observes its own terminal reason and aborts/finalizes the response - // when the per-request lifetime expires (#4546). - // transport の frame/writer 対応を維持する。request lifetime 期限切れ時は - // HTTP 側が terminal reason を観測して response を abort/finalize する。 - response = null; - } - finally - { - _currentDetachedIsolatedActions.Value = previousDetachedIsolatedActions; - _currentRequestToken.Value = CancellationToken.None; - _canAwaitClientResponses.Value = false; - _currentOutOfBandFrameWriter.Value = null; - } - - // Malformed/unauthorized frames can return before normal dispatch. Start their - // predecessor wait here so such a frame cannot collapse a protocol barrier. - // malformed / unauthorized frame が dispatch 前に return しても protocol - // barrier を消してしまわないよう、未開始ならここで predecessor を待つ。 - if (!predecessorTask.IsValueCreated) - { - try - { - await predecessorTask.Value.WaitAsync(frameToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (frameToken.IsCancellationRequested) - { - // A canceled frame no longer needs protocol ordering, but its - // request-scoped writer still owns mandatory response cleanup. - // cancel 済み frame は protocol ordering を待たず、対応 writer - // による必須 cleanup だけを完了させる (#4546)。 - response = null; - } - } - - await WriteTransportFrameResponseAsync(writeResponseAsync, response).ConfigureAwait(false); - } - finally - { - var retainedWork = detachedIsolatedActions.IsEmpty - ? Task.CompletedTask - : ObserveDetachedIsolatedActionsAsync(detachedIsolatedActions.ToArray()); - transportFrame.CompleteResourceRetentionWhen(retainedWork); - Interlocked.Decrement(ref _acceptedConcurrentFrameCount); - admissionGate.Release(); - - // A canceled or timed-out isolated action may still be unwinding - // durable writer cleanup after its response has been sent. Release - // frame admission and the transport resource callback first, then keep - // the outer request task attached to that cleanup so EOF's bounded - // drain cannot return while the action is restoring database state. - // cancel / timeout 応答後も isolated action が永続 writer cleanup を - // unwind 中の場合がある。frame admission と transport resource callback - // を先に解放し、その後 outer request task を cleanup に接続して、EOF の - // bounded drain が database 復元中に戻らないようにする。 - await retainedWork.ConfigureAwait(false); - } - }, CancellationToken.None); - } - catch - { - transportFrame.CompleteResourceRetentionWhen(Task.CompletedTask); - Interlocked.Decrement(ref _acceptedConcurrentFrameCount); - admissionGate.Release(); - throw; - } - tasks.Add(requestTask); - if (isProtocolBarrier) - protocolBarrier = requestTask; - await requestTaskStarted.Task.ConfigureAwait(false); + await StartAcceptedConcurrentFrameAsync(state, transportFrame).ConfigureAwait(false); } } catch (OperationCanceledException) when (loopToken.IsCancellationRequested) @@ -591,29 +340,7 @@ Task BeforeDispatchAsync(CancellationToken token) } finally { - try - { - await DrainInFlightTasksAsync( - tasks, - InFlightDrainGracePeriod, - InFlightPostCancelGracePeriod, - externalCancellationToken, - terminalTransportWriteTask).ConfigureAwait(false); - } - finally - { - // The bounded EOF drain can intentionally leave late request tasks running. Those - // tasks can still own the write gate or reach the stdio writer until their finally - // blocks run. Publish that aggregate even if draining itself exits unexpectedly, - // then clean up the gates only after every accepted task is done (#3999, #4543). - // bounded EOF drain は late request task を残すことがある。finally が走るまで gate や - // stdio writer を使い得るため、drain 自体が異常終了しても aggregate を公開し、全 - // accepted task 完了後に gate を dispose する (#3999, #4543)。 - var transportWork = BuildDrainOperationsTask(tasks, terminalTransportWriteTask); - if (transport is StdioMcpTransport stdioTransport) - stdioTransport.DeferDisposalUntil(transportWork); - _ = DisposeConcurrentLoopGatesAfterAsync(transportWork, writeGate, admissionGate); - } + await state.DrainAndScheduleGateDisposalAsync().ConfigureAwait(false); } CommandErrorWriter.WriteStderr("[cdidx-mcp] Server stopped. Restart `cdidx mcp` when your client reconnects."); } From 82f9aa1287f389ac899587311f4ec46bdf898170 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 14:46:23 +0900 Subject: [PATCH 028/101] Decompose MCP batch dispatch orchestration --- .../Mcp/McpServer.MessageDispatch.Batch.cs | 426 ++++++++++++++++++ .../Mcp/McpServer.MessageDispatch.cs | 320 ------------- 2 files changed, 426 insertions(+), 320 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.MessageDispatch.Batch.cs diff --git a/src/CodeIndex/Mcp/McpServer.MessageDispatch.Batch.cs b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Batch.cs new file mode 100644 index 000000000..0ae5d8bc7 --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Batch.cs @@ -0,0 +1,426 @@ +using System.Text.Json.Nodes; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + private sealed record BatchResponseBudgetPlan( + BatchResponseBudgetSlot?[]? Slots, + int?[]? ItemLimits, + JsonObject? PreflightError, + int ResponseLimit); + + private sealed class BatchDispatchState + { + public BatchDispatchState( + JsonArray batch, + bool isolateRequestDb, + bool[] completed, + BatchResponseBudgetPlan budget) + { + Batch = batch; + Completed = completed; + Budget = budget; + IsolateItems = isolateRequestDb || batch.Count > 1; + Responses = new JsonNode?[batch.Count]; + Logs = new DeferredFrameLogBuffer?[batch.Count]; + OrderingFences = new bool[batch.Count]; + CancellationItems = new bool[batch.Count]; + QueuedRegistrations = new QueuedBatchRequestRegistration?[batch.Count]; + } + + public JsonArray Batch { get; } + public bool[] Completed { get; } + public BatchResponseBudgetPlan Budget { get; } + public bool IsolateItems { get; } + public JsonNode?[] Responses { get; } + public DeferredFrameLogBuffer?[] Logs { get; } + public bool[] OrderingFences { get; } + public bool[] CancellationItems { get; } + public QueuedBatchRequestRegistration?[] QueuedRegistrations { get; } + + public void Complete(int index, (JsonNode? Response, DeferredFrameLogBuffer Logs) result) + { + Responses[index] = result.Response; + Logs[index] = result.Logs; + Completed[index] = true; + } + + public void DisposeQueuedRegistrations() + { + foreach (var registration in QueuedRegistrations) + registration?.DisposeIfUnclaimed(); + } + } + + private async Task HandleBatchMessageAsync( + JsonArray batch, + bool isolateRequestDb, + Func? beforeDispatchAsync, + bool rejectForCapacity, + DeferredInitializeCommits? deferredInitializeCommits) + { + if (TryValidateBatchRequest(batch) is { } validationError) + return validationError; + + var completed = ConsumeCompletedClientReplies(batch); + var state = new BatchDispatchState( + batch, + isolateRequestDb, + completed, + CreateBatchResponseBudgetPlan(batch, completed)); + + PrepareBatchItems(state, rejectForCapacity); + await ExecuteBatchCancellationItemsAsync(state, deferredInitializeCommits).ConfigureAwait(false); + + if (state.Budget.PreflightError is not null) + { + state.DisposeQueuedRegistrations(); + MergeBatchItemLogs(state.Logs); + return state.Budget.PreflightError; + } + + if (rejectForCapacity) + { + await ExecuteCapacityRejectedBatchAsync(state, deferredInitializeCommits).ConfigureAwait(false); + } + else + { + await ExecuteOrderedBatchItemsAsync( + state, + beforeDispatchAsync, + deferredInitializeCommits).ConfigureAwait(false); + } + + MergeBatchItemLogs(state.Logs); + return BuildBatchResponse( + state.Responses, + state.Budget.Slots, + state.Budget.ItemLimits, + state.Budget.ResponseLimit); + } + + private JsonObject? TryValidateBatchRequest(JsonArray batch) + { + if (batch.Count == 0) + { + return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: empty batch", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "JSON-RPC 2.0 batch requests must contain at least one request object.", + retrySafe: false); + } + + if (batch.Count <= MaxBatchRequestCount) + return null; + + return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: batch too large", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: $"JSON-RPC batch requests are limited to {MaxBatchRequestCount} items.", + retrySafe: false); + } + + private bool[] ConsumeCompletedClientReplies(JsonArray batch) + { + // Client replies complete server-initiated requests and never produce a response item. + // Consume matched replies before reserving response bytes; unmatched response-shaped + // objects remain ordinary invalid requests and retain their budget slot. + // client reply は server 起点 request を完了し response item を生成しないため、response + // budget 予約前に matched reply を consume する。unmatched object は invalid request として残す。 + var completed = new bool[batch.Count]; + for (var index = 0; index < batch.Count; index++) + { + if (batch[index] is JsonObject itemObject + && TryCompletePendingClientRequest(itemObject)) + { + completed[index] = true; + } + } + return completed; + } + + private BatchResponseBudgetPlan CreateBatchResponseBudgetPlan( + JsonArray batch, + IReadOnlyList completed) + { + if (!_usesDefaultResponseSerializer) + return new BatchResponseBudgetPlan(null, null, null, ResponseLimit: 0); + + // The complete JSON array owns one response budget. Reserve brackets, commas, and a + // bounded error for every response-bearing item, then divide the remaining bytes + // deterministically before concurrent dispatch. JSON 配列全体で 1 つの response + // budget を共有する。bracket、comma、各 response item の bounded error を予約し、 + // 残りを concurrent dispatch 前に決定的に分配する。 + var responseLimit = GetMaxResponseBytes(); + var activeTransportMaxResponseBytes = Volatile.Read(ref _activeTransportMaxResponseBytes); + if (activeTransportMaxResponseBytes > 0) + responseLimit = Math.Min(activeTransportMaxResponseBytes, responseLimit); + + var slots = new BatchResponseBudgetSlot?[batch.Count]; + var itemLimits = new int?[batch.Count]; + long reservedErrorBytes = 0; + var responseCount = 0; + for (var index = 0; index < batch.Count; index++) + { + if (completed[index] || !TryCreateBatchResponseBudgetSlot(batch[index], out var slot)) + continue; + + slots[index] = slot; + reservedErrorBytes += slot.ErrorResponseBytes; + responseCount++; + } + + if (responseCount == 0) + return new BatchResponseBudgetPlan(slots, itemLimits, null, responseLimit); + + var payloadBytes = responseLimit - 2L - (responseCount - 1L); + if (payloadBytes < reservedErrorBytes) + { + // Defer the terminal budget error until request IDs are durably registered + // and cancellation controls have run. No ordinary or state-changing work is + // dispatched on this path (#4544, #4545). + // terminal budget error は request ID の durable 登録と cancellation control + // 実行後まで保留し、通常処理や他の state mutation は開始しない。 + return new BatchResponseBudgetPlan( + slots, + itemLimits, + CreateBatchEnvelopeBudgetError(responseLimit, retrySafe: true), + responseLimit); + } + + var distributableBytes = payloadBytes - reservedErrorBytes; + var fairShareBytes = distributableBytes / responseCount; + var remainderBytes = distributableBytes % responseCount; + for (var index = 0; index < batch.Count; index++) + { + if (slots[index] is not { } slot) + continue; + + var itemExtraBytes = fairShareBytes; + if (remainderBytes > 0) + { + itemExtraBytes++; + remainderBytes--; + } + itemLimits[index] = checked((int)(slot.ErrorResponseBytes + itemExtraBytes)); + } + + RedistributeResourceListBudgetSlack(slots, itemLimits); + return new BatchResponseBudgetPlan(slots, itemLimits, null, responseLimit); + } + + private static void RedistributeResourceListBudgetSlack( + IReadOnlyList slots, + IList itemLimits) + { + // Equal caps can strand the same resource-serialization fragment in every slot. + // Move one minimum page quantum from the first resources/list slot to the last so + // one concurrent page can consume that deterministic slack without exceeding the + // aggregate cap. 等分時に各 slot へ同じ serialization 断片が残るのを避けるため、 + // 最初の resources/list から最後へ最小 page 予算 1 単位を移す。 + var firstResourceIndex = -1; + var lastResourceIndex = -1; + for (var index = 0; index < slots.Count; index++) + { + if (slots[index]?.CanShapeResourcesListResponse != true) + continue; + if (firstResourceIndex < 0) + firstResourceIndex = index; + lastResourceIndex = index; + } + if (firstResourceIndex < 0 || lastResourceIndex == firstResourceIndex) + return; + + var donorSlot = slots[firstResourceIndex]!.Value; + var donorLimit = itemLimits[firstResourceIndex]!.Value; + var transferableBytes = Math.Min( + MinResourceListMaxBytes, + donorLimit - donorSlot.ErrorResponseBytes); + itemLimits[firstResourceIndex] = donorLimit - transferableBytes; + itemLimits[lastResourceIndex] = checked( + itemLimits[lastResourceIndex]!.Value + transferableBytes); + } + + private void PrepareBatchItems(BatchDispatchState state, bool rejectForCapacity) + { + // A batch is one wire frame but each item is an independently bounded JSON-RPC + // operation (#4545). Invalid items are materialized immediately, cancellation controls + // run eagerly, and state-changing items split the remaining work into ordered segments. + // Response nodes are retained by input index so completion timing cannot reorder the wire + // response. バッチは 1 wire frame だが、各 item を独立した bounded operation として扱う。 + // 不正 item は即時確定し、cancel control は先行処理し、状態変更 item で順序 segment を区切る。 + var seenRequestIds = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < state.Batch.Count; index++) + { + if (state.Completed[index]) + continue; + + var item = state.Batch[index]; + if (item is null || item is not JsonObject and not JsonArray) + { + using (BeginBatchItemCorrelation(id: null, index)) + state.Responses[index] = CreateInvalidBatchItemResponse(nestedBatch: false); + state.Completed[index] = true; + continue; + } + if (item is JsonArray) + { + using (BeginBatchItemCorrelation(id: null, index)) + state.Responses[index] = CreateInvalidBatchItemResponse(nestedBatch: true); + state.Completed[index] = true; + continue; + } + + var itemObject = (JsonObject)item; + if (IsCancellationItem(itemObject)) + { + // Execute controls only after this pass has durably registered every unique + // request ID. This preserves eager cancellation even when the control precedes + // its target and the short tombstone cache is full (#4545). + // 全 unique request ID を durable 登録してから control を実行する。cancel が target + // より先でも、短命 tombstone cache が満杯でも eager cancellation を保つ。 + state.CancellationItems[index] = true; + continue; + } + + state.OrderingFences[index] = IsProtocolOrderingBarrierItem(itemObject); + if (!TryGetRequestId(itemObject, out var hasId, out var id) + || !hasId + || SerializeRequestId(id) is not { } requestKey) + { + continue; + } + + if (!seenRequestIds.Add(requestKey)) + { + // Preserve the pre-concurrency behavior for duplicate ids in one batch: the + // later occurrence starts only after the earlier occurrence has completed. + // 同一 batch 内の重複 id は、後続を fence にして従来の逐次 semantics を保つ。 + state.OrderingFences[index] = true; + } + else if (!rejectForCapacity) + { + state.QueuedRegistrations[index] = TryRegisterQueuedBatchRequest(requestKey); + } + } + } + + private async Task ExecuteBatchCancellationItemsAsync( + BatchDispatchState state, + DeferredInitializeCommits? deferredInitializeCommits) + { + for (var index = 0; index < state.Batch.Count; index++) + { + if (!state.CancellationItems[index]) + continue; + + var result = await ExecuteBatchItemAsync( + state.Batch[index]!, + index, + isolateRequestDb: true, + beforeDispatchAsync: null, + rejectForCapacity: false, + queuedBatchRegistration: null, + responseItemMaxBytes: state.Budget.ItemLimits?[index], + deferredInitializeCommits).ConfigureAwait(false); + state.Complete(index, result); + } + } + + private async Task ExecuteCapacityRejectedBatchAsync( + BatchDispatchState state, + DeferredInitializeCommits? deferredInitializeCommits) + { + for (var index = 0; index < state.Batch.Count; index++) + { + if (state.Completed[index]) + continue; + + var result = await ExecuteBatchItemAsync( + state.Batch[index]!, + index, + state.IsolateItems, + beforeDispatchAsync: null, + rejectForCapacity: true, + queuedBatchRegistration: null, + responseItemMaxBytes: state.Budget.ItemLimits?[index], + deferredInitializeCommits).ConfigureAwait(false); + state.Complete(index, result); + } + } + + private async Task ExecuteOrderedBatchItemsAsync( + BatchDispatchState state, + Func? beforeDispatchAsync, + DeferredInitializeCommits? deferredInitializeCommits) + { + var independentSegment = new List(); + for (var index = 0; index < state.Batch.Count; index++) + { + if (state.Completed[index]) + continue; + + if (!state.OrderingFences[index]) + { + independentSegment.Add(index); + continue; + } + + await ExecuteBatchSegmentAsync( + state.Batch, + independentSegment, + state.IsolateItems, + state.Responses, + state.Logs, + state.QueuedRegistrations, + state.Budget.ItemLimits, + deferredInitializeCommits, + beforeDispatchAsync).ConfigureAwait(false); + independentSegment.Clear(); + await ExecuteBatchItemAsync( + state.Batch[index]!, + index, + state.IsolateItems, + state.Responses, + state.Logs, + beforeDispatchAsync, + state.QueuedRegistrations[index], + state.Budget.ItemLimits?[index], + deferredInitializeCommits).ConfigureAwait(false); + ApplyBatchFenceState(state.Responses[index], deferredInitializeCommits); + } + + await ExecuteBatchSegmentAsync( + state.Batch, + independentSegment, + state.IsolateItems, + state.Responses, + state.Logs, + state.QueuedRegistrations, + state.Budget.ItemLimits, + deferredInitializeCommits, + beforeDispatchAsync).ConfigureAwait(false); + } + + private void ApplyBatchFenceState( + JsonNode? fenceResponse, + DeferredInitializeCommits? deferredInitializeCommits) + { + if (fenceResponse is not null + && deferredInitializeCommits?.TryGetRegisteredState(fenceResponse, out var initializeState) == true) + { + _frameInitializeState.Value = new FrameInitializeState( + BuildCommittedInitializeState(CurrentInitializeState, initializeState, logCallerSwap: false), + isProvisionalGeneration: true); + } + else if (_frameInitializeState.Value is { } currentFrameState + && currentFrameState.TryConsumeAcceptedRootsChange()) + { + var nextState = currentFrameState.IsProvisionalGeneration + ? currentFrameState.Current with { ClientRootsStale = true } + : PublishedInitializeState; + _frameInitializeState.Value = new FrameInitializeState( + nextState, + currentFrameState.IsProvisionalGeneration); + } + } +} diff --git a/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs b/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs index 893d979cf..d2f235398 100644 --- a/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs +++ b/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs @@ -464,326 +464,6 @@ private bool ProbeDbHealth(out string? error) return ok; } - private async Task HandleBatchMessageAsync( - JsonArray batch, - bool isolateRequestDb, - Func? beforeDispatchAsync, - bool rejectForCapacity, - DeferredInitializeCommits? deferredInitializeCommits) - { - if (batch.Count == 0) - return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: empty batch", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: "JSON-RPC 2.0 batch requests must contain at least one request object.", - retrySafe: false); - - if (batch.Count > MaxBatchRequestCount) - return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: batch too large", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: $"JSON-RPC batch requests are limited to {MaxBatchRequestCount} items.", - retrySafe: false); - - // Client replies complete server-initiated requests and never produce a response item. - // Consume matched replies before reserving response bytes; unmatched response-shaped - // objects remain ordinary invalid requests and retain their budget slot. - // client reply は server 起点 request を完了し response item を生成しないため、response - // budget 予約前に matched reply を consume する。unmatched object は invalid request として残す。 - var completed = new bool[batch.Count]; - for (var index = 0; index < batch.Count; index++) - { - if (batch[index] is JsonObject itemObject - && TryCompletePendingClientRequest(itemObject)) - { - completed[index] = true; - } - } - - BatchResponseBudgetSlot?[]? budgetSlots = null; - int?[]? batchResponseItemLimits = null; - JsonObject? batchBudgetPreflightError = null; - var batchResponseLimit = 0; - var activeTransportMaxResponseBytes = Volatile.Read(ref _activeTransportMaxResponseBytes); - if (_usesDefaultResponseSerializer) - { - // The complete JSON array owns one response budget. Reserve brackets, commas, and a - // bounded error for every response-bearing item, then divide the remaining bytes - // deterministically before concurrent dispatch. JSON 配列全体で 1 つの response - // budget を共有する。bracket、comma、各 response item の bounded error を予約し、 - // 残りを concurrent dispatch 前に決定的に分配する。 - batchResponseLimit = GetMaxResponseBytes(); - if (activeTransportMaxResponseBytes > 0) - batchResponseLimit = Math.Min(activeTransportMaxResponseBytes, batchResponseLimit); - budgetSlots = new BatchResponseBudgetSlot?[batch.Count]; - batchResponseItemLimits = new int?[batch.Count]; - long reservedErrorBytes = 0; - var responseCount = 0; - for (var index = 0; index < batch.Count; index++) - { - if (completed[index]) - continue; - if (!TryCreateBatchResponseBudgetSlot(batch[index], out var slot)) - continue; - - budgetSlots[index] = slot; - reservedErrorBytes += slot.ErrorResponseBytes; - responseCount++; - } - - if (responseCount > 0) - { - var payloadBytes = batchResponseLimit - 2L - (responseCount - 1L); - if (payloadBytes < reservedErrorBytes) - { - // Defer the terminal budget error until request IDs are durably registered - // and cancellation controls have run. No ordinary or state-changing work is - // dispatched on this path (#4544, #4545). - // terminal budget error は request ID の durable 登録と cancellation control - // 実行後まで保留し、通常処理や他の state mutation は開始しない。 - batchBudgetPreflightError = CreateBatchEnvelopeBudgetError( - batchResponseLimit, - retrySafe: true); - } - else - { - var distributableBytes = payloadBytes - reservedErrorBytes; - var fairShareBytes = distributableBytes / responseCount; - var remainderBytes = distributableBytes % responseCount; - for (var index = 0; index < batch.Count; index++) - { - if (budgetSlots[index] is not { } slot) - continue; - - var itemExtraBytes = fairShareBytes; - if (remainderBytes > 0) - { - itemExtraBytes++; - remainderBytes--; - } - batchResponseItemLimits[index] = checked((int)(slot.ErrorResponseBytes + itemExtraBytes)); - } - - // Equal caps can strand the same resource-serialization fragment in every slot. - // Move one minimum page quantum from the first resources/list slot to the last so - // one concurrent page can consume that deterministic slack without exceeding the - // aggregate cap. 等分時に各 slot へ同じ serialization 断片が残るのを避けるため、 - // 最初の resources/list から最後へ最小 page 予算 1 単位を移す。 - var firstResourceIndex = -1; - var lastResourceIndex = -1; - for (var index = 0; index < batch.Count; index++) - { - if (budgetSlots[index]?.CanShapeResourcesListResponse != true) - continue; - if (firstResourceIndex < 0) - firstResourceIndex = index; - lastResourceIndex = index; - } - if (firstResourceIndex >= 0 && lastResourceIndex != firstResourceIndex) - { - var donorSlot = budgetSlots[firstResourceIndex]!.Value; - var donorLimit = batchResponseItemLimits[firstResourceIndex]!.Value; - var transferableBytes = Math.Min( - MinResourceListMaxBytes, - donorLimit - donorSlot.ErrorResponseBytes); - batchResponseItemLimits[firstResourceIndex] = donorLimit - transferableBytes; - batchResponseItemLimits[lastResourceIndex] = checked( - batchResponseItemLimits[lastResourceIndex]!.Value + transferableBytes); - } - } - } - } - - // A batch is one wire frame but each item is an independently bounded JSON-RPC - // operation (#4545). Invalid items are materialized immediately, cancellation controls - // run eagerly, and state-changing items split the remaining work into ordered segments. - // Response nodes are retained by input index so completion timing cannot reorder the wire - // response. バッチは 1 wire frame だが、各 item を独立した bounded operation として扱う。 - // 不正 item は即時確定し、cancel control は先行処理し、状態変更 item で順序 segment を区切る。 - var responsesByIndex = new JsonNode?[batch.Count]; - var logsByIndex = new DeferredFrameLogBuffer?[batch.Count]; - var orderingFences = new bool[batch.Count]; - var cancellationItems = new bool[batch.Count]; - var queuedRegistrations = new QueuedBatchRequestRegistration?[batch.Count]; - var seenRequestIds = new HashSet(StringComparer.Ordinal); - var isolateBatchItems = isolateRequestDb || batch.Count > 1; - - for (var index = 0; index < batch.Count; index++) - { - if (completed[index]) - continue; - - var item = batch[index]; - if (item is null || item is not JsonObject and not JsonArray) - { - using (BeginBatchItemCorrelation(id: null, index)) - responsesByIndex[index] = CreateInvalidBatchItemResponse(nestedBatch: false); - completed[index] = true; - continue; - } - if (item is JsonArray) - { - using (BeginBatchItemCorrelation(id: null, index)) - responsesByIndex[index] = CreateInvalidBatchItemResponse(nestedBatch: true); - completed[index] = true; - continue; - } - var itemObject = (JsonObject)item; - if (IsCancellationItem(itemObject)) - { - // Execute controls only after this pass has durably registered every unique - // request ID. This preserves eager cancellation even when the control precedes - // its target and the short tombstone cache is full (#4545). - // 全 unique request ID を durable 登録してから control を実行する。cancel が target - // より先でも、短命 tombstone cache が満杯でも eager cancellation を保つ。 - cancellationItems[index] = true; - continue; - } - - orderingFences[index] = IsProtocolOrderingBarrierItem(itemObject); - if (TryGetRequestId(itemObject, out var hasId, out var id) - && hasId - && SerializeRequestId(id) is { } requestKey) - { - if (!seenRequestIds.Add(requestKey)) - { - // Preserve the pre-concurrency behavior for duplicate ids in one batch: the - // later occurrence starts only after the earlier occurrence has completed. - // 同一 batch 内の重複 id は、後続を fence にして従来の逐次 semantics を保つ。 - orderingFences[index] = true; - } - else if (!rejectForCapacity) - { - queuedRegistrations[index] = TryRegisterQueuedBatchRequest(requestKey); - } - } - } - - for (var index = 0; index < batch.Count; index++) - { - if (!cancellationItems[index]) - continue; - - var cancellationResult = await ExecuteBatchItemAsync( - batch[index]!, - index, - isolateRequestDb: true, - beforeDispatchAsync: null, - rejectForCapacity: false, - queuedBatchRegistration: null, - responseItemMaxBytes: batchResponseItemLimits?[index], - deferredInitializeCommits).ConfigureAwait(false); - responsesByIndex[index] = cancellationResult.Response; - logsByIndex[index] = cancellationResult.Logs; - completed[index] = true; - } - - if (batchBudgetPreflightError is not null) - { - foreach (var registration in queuedRegistrations) - registration?.DisposeIfUnclaimed(); - MergeBatchItemLogs(logsByIndex); - return batchBudgetPreflightError; - } - - if (rejectForCapacity) - { - for (var index = 0; index < batch.Count; index++) - { - if (completed[index]) - continue; - var result = await ExecuteBatchItemAsync( - batch[index]!, - index, - isolateBatchItems, - beforeDispatchAsync: null, - rejectForCapacity: true, - queuedBatchRegistration: null, - responseItemMaxBytes: batchResponseItemLimits?[index], - deferredInitializeCommits).ConfigureAwait(false); - responsesByIndex[index] = result.Response; - logsByIndex[index] = result.Logs; - completed[index] = true; - } - - MergeBatchItemLogs(logsByIndex); - return BuildBatchResponse( - responsesByIndex, - budgetSlots, - batchResponseItemLimits, - batchResponseLimit); - } - - var independentSegment = new List(); - for (var index = 0; index < batch.Count; index++) - { - if (completed[index]) - continue; - - if (!orderingFences[index]) - { - independentSegment.Add(index); - continue; - } - - await ExecuteBatchSegmentAsync( - batch, - independentSegment, - isolateBatchItems, - responsesByIndex, - logsByIndex, - queuedRegistrations, - batchResponseItemLimits, - deferredInitializeCommits, - beforeDispatchAsync).ConfigureAwait(false); - independentSegment.Clear(); - await ExecuteBatchItemAsync( - batch[index]!, - index, - isolateBatchItems, - responsesByIndex, - logsByIndex, - beforeDispatchAsync, - queuedRegistrations[index], - batchResponseItemLimits?[index], - deferredInitializeCommits).ConfigureAwait(false); - - var fenceResponse = responsesByIndex[index]; - if (fenceResponse is not null - && deferredInitializeCommits?.TryGetRegisteredState(fenceResponse, out var initializeState) == true) - { - _frameInitializeState.Value = new FrameInitializeState( - BuildCommittedInitializeState(CurrentInitializeState, initializeState, logCallerSwap: false), - isProvisionalGeneration: true); - } - else if (_frameInitializeState.Value is { } currentFrameState - && currentFrameState.TryConsumeAcceptedRootsChange()) - { - var nextState = currentFrameState.IsProvisionalGeneration - ? currentFrameState.Current with { ClientRootsStale = true } - : PublishedInitializeState; - _frameInitializeState.Value = new FrameInitializeState( - nextState, - currentFrameState.IsProvisionalGeneration); - } - } - - await ExecuteBatchSegmentAsync( - batch, - independentSegment, - isolateBatchItems, - responsesByIndex, - logsByIndex, - queuedRegistrations, - batchResponseItemLimits, - deferredInitializeCommits, - beforeDispatchAsync).ConfigureAwait(false); - MergeBatchItemLogs(logsByIndex); - - return BuildBatchResponse( - responsesByIndex, - budgetSlots, - batchResponseItemLimits, - batchResponseLimit); - } private QueuedBatchRequestRegistration? TryRegisterQueuedBatchRequest(string requestKey) { From 48b9678b0aebacc0b6b0a03869e85e02f803cb3a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 14:50:59 +0900 Subject: [PATCH 029/101] Separate MCP request validation and notification dispatch --- .../Mcp/McpServer.MessageDispatch.Single.cs | 321 ++++++++++++++++++ .../Mcp/McpServer.MessageDispatch.cs | 242 ------------- 2 files changed, 321 insertions(+), 242 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs diff --git a/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs new file mode 100644 index 000000000..433915d36 --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs @@ -0,0 +1,321 @@ +using System.Text.Json.Nodes; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + private sealed record McpRequestEnvelope( + JsonObject Object, + string? Method, + bool HasId, + JsonNode? Id); + + private async Task HandleMessageAsync( + JsonNode request, + bool isolateRequestDb, + Func? beforeDispatchAsync, + bool rejectForCapacity, + QueuedBatchRequestRegistration? queuedBatchRegistration, + DeferredInitializeCommits? deferredInitializeCommits) + { + if (request is JsonArray batch) + { + return await HandleBatchFrameAsync( + batch, + isolateRequestDb, + beforeDispatchAsync, + rejectForCapacity, + deferredInitializeCommits).ConfigureAwait(false); + } + + if (!TryCreateRequestEnvelope(request, out var envelope, out var validationError)) + return validationError; + + using var correlationScope = envelope!.HasId && CurrentCorrelationContext.Value is null + ? BeginRequestCorrelation(envelope.Id) + : null; + if (await TryHandleNotificationAsync( + envelope, + beforeDispatchAsync, + rejectForCapacity).ConfigureAwait(false)) + { + return null; + } + + if (TryAuthenticateRespondedRequest(envelope, out var authenticationError)) + return authenticationError; + if (rejectForCapacity) + return CreateServerBusyResponse(envelope.Id); + if (envelope.Method is null) + { + return CreateErrorResponse(hasId: true, id: envelope.Id, code: -32600, message: "Invalid request: missing method", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "JSON-RPC 2.0 requires a string `method` field.", + retrySafe: false); + } + + return await DispatchRespondedRequestAsync( + envelope, + isolateRequestDb, + beforeDispatchAsync, + queuedBatchRegistration, + deferredInitializeCommits).ConfigureAwait(false); + } + + private async Task HandleBatchFrameAsync( + JsonArray batch, + bool isolateRequestDb, + Func? beforeDispatchAsync, + bool rejectForCapacity, + DeferredInitializeCommits? deferredInitializeCommits) + { + if (deferredInitializeCommits is null) + { + return await HandleBatchMessageAsync( + batch, + isolateRequestDb, + beforeDispatchAsync, + rejectForCapacity, + deferredInitializeCommits).ConfigureAwait(false); + } + + var previousFrameInitializeState = _frameInitializeState.Value; + var initialFrameInitializeState = CurrentInitializeState; + var frameInitializeState = new FrameInitializeState( + initialFrameInitializeState, + isProvisionalGeneration: false); + _frameInitializeState.Value = frameInitializeState; + var batchBeforeDispatchAsync = beforeDispatchAsync; + if (beforeDispatchAsync is not null) + { + batchBeforeDispatchAsync = async cancellationToken => + { + await beforeDispatchAsync(cancellationToken).ConfigureAwait(false); + // The concurrent loop accepts and pre-registers a batch before its protocol + // predecessor finishes. Advance only this batch's original generation after + // that predecessor commits; timed-out older frames retain their own holders, + // and an in-batch initialize replaces this holder instead of being overwritten. + // concurrent loop は protocol predecessor 完了前に batch を受理・事前登録する。 + // predecessor の commit 後、この batch の元 generation だけを進める。timeout + // 後の旧 frame は別 holder を保持し、batch 内 initialize は holder 自体を置換する。 + frameInitializeState.TryAdvanceToPublishedGeneration( + initialFrameInitializeState, + PublishedInitializeState); + }; + } + try + { + return await HandleBatchMessageAsync( + batch, + isolateRequestDb, + batchBeforeDispatchAsync, + rejectForCapacity, + deferredInitializeCommits).ConfigureAwait(false); + } + finally + { + _frameInitializeState.Value = previousFrameInitializeState; + } + } + + private bool TryCreateRequestEnvelope( + JsonNode request, + out McpRequestEnvelope? envelope, + out JsonNode? validationError) + { + envelope = null; + if (request is not JsonObject obj) + { + validationError = CreateExpectedJsonObjectErrorResponse(); + return false; + } + + lock (_healthStateGate) + _lastRequestAt = _timeProvider.GetUtcNow(); + + // Extract `method` defensively: a non-string `method` (e.g. `"method":42`) must not + // throw before the auth gate runs, otherwise a token-protected server would surface + // an internal error to an unauthenticated caller and leak dispatch internals (#1559). + // `method` は防御的に取り出し、非文字列でも認証ゲート前に例外を投げない (#1559)。 + var method = TryGetStringMember(obj, "method"); + if (!TryGetRequestId(obj, out var hasId, out var id, out var idError)) + { + validationError = CreateErrorResponse(hasId: true, id: null, code: -32600, message: BuildInvalidRequestIdMessage(idError), + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: BuildInvalidRequestIdSuggestion(idError), + retrySafe: false, + extraData: BuildInvalidRequestIdData(idError)); + return false; + } + + if (TryGetStringMember(obj, "jsonrpc") != "2.0") + { + validationError = CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: jsonrpc must be exactly \"2.0\"", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "Set the top-level `jsonrpc` member to the string `2.0`.", + retrySafe: false); + return false; + } + + envelope = new McpRequestEnvelope(obj, method, hasId, id); + validationError = null; + return true; + } + + private async Task TryHandleNotificationAsync( + McpRequestEnvelope request, + Func? beforeDispatchAsync, + bool rejectForCapacity) + { + var method = request.Method; + // A JSON-RPC notification cannot carry an error response, but state-changing + // notifications must still authenticate before mutating cancellation, roots, or lifecycle + // state. On denial, emit only the bounded local diagnostic (#4537). + // JSON-RPC notification はエラー応答を持てないが、state-changing notification は + // server state を変更する前に認証し、拒否時は bounded なローカル診断だけを残す。 + if (IsStateChangingNotification(method)) + { + var notificationAuth = _authenticator.Authenticate(request.Object); + if (!notificationAuth.IsAuthenticated) + { + WriteMcpLogLine(BuildAuthFailureLog(method, notificationAuth.FailureReason)); + return true; + } + } + + if (method == "$/cancelRequest" || method == "notifications/cancelled") + { + TryCancelRequest(request.Object["params"]); + return true; + } + + if (rejectForCapacity && IsStateChangingNotification(method)) + { + // Eager cancellation is handled above. Other state notifications are dropped on + // admission overflow without mutating roots or lifecycle state (#4536, #4545). + return true; + } + + var protocolPredecessorAwaited = false; + if (IsStateChangingNotification(method) && beforeDispatchAsync is not null) + { + // Cancellation controls intentionally bypass protocol barriers, but roots/lifecycle + // notifications must not mutate state before an earlier initialize commits. + await beforeDispatchAsync(_currentRequestToken.Value).ConfigureAwait(false); + protocolPredecessorAwaited = true; + } + + if (!request.HasId) + { + if (rejectForCapacity) + return true; + if (!protocolPredecessorAwaited && beforeDispatchAsync is not null) + await beforeDispatchAsync(_currentRequestToken.Value).ConfigureAwait(false); + } + + if (method == "notifications/initialized") + return true; + if (method == "notifications/roots/list_changed") + { + MarkClientRootsStale(); + _frameInitializeState.Value?.MarkRootsChangeAccepted(); + return true; + } + + if (string.Equals(method, "notifications/shutdown", StringComparison.Ordinal) + || string.Equals(method, "notifications/exit", StringComparison.Ordinal)) + { + WriteMcpLogLine($"[cdidx-mcp] Received {method}; draining in-flight work and shutting down."); + _running = false; + _ = RequestShutdownCancellation(); + return true; + } + + if (request.HasId) + return false; + if (method != null && method.StartsWith("notifications/", StringComparison.OrdinalIgnoreCase)) + WriteMcpLogLine(BuildUnknownNotificationLog(method)); + return true; + } + + private bool TryAuthenticateRespondedRequest( + McpRequestEnvelope request, + out JsonNode? authenticationError) + { + // Authenticate every responded request before dispatch, even when `method` is missing or + // malformed, so token-protected servers do not leak method-shape errors (#1559). + var authResult = _authenticator.Authenticate(request.Object); + if (authResult.IsAuthenticated) + { + authenticationError = null; + return false; + } + + DeferFrameLog(BuildAuthFailureLog(request.Method, authResult.FailureReason)); + authenticationError = CreateErrorResponse( + hasId: true, + id: request.Id, + code: McpErrorEnvelope.CodeUnauthorized, + message: "Unauthorized", + category: McpErrorEnvelope.CategoryPermissionDenied, + suggestion: "Set CDIDX_MCP_AUTH_TOKEN on the server and include a matching params.auth.token (or an `Authorization: Bearer ` header for HTTP) on each request.", + retrySafe: false); + return true; + } + + private Task DispatchRespondedRequestAsync( + McpRequestEnvelope request, + bool isolateRequestDb, + Func? beforeDispatchAsync, + QueuedBatchRequestRegistration? queuedBatchRegistration, + DeferredInitializeCommits? deferredInitializeCommits) + => DispatchWithRequestCancellationAsync( + request.Id, + isolateRequestDb, + beforeDispatchAsync, + queuedBatchRegistration, + () => DispatchRequestMethodAsync(request, deferredInitializeCommits)); + + private Task DispatchRequestMethodAsync( + McpRequestEnvelope request, + DeferredInitializeCommits? deferredInitializeCommits) + { + var method = request.Method!; + if (_enforceInitializationLifecycle && !CurrentInitializeState.Initialized && method != "initialize") + { + return Task.FromResult(CreateErrorResponse( + hasId: true, + id: request.Id, + code: -32002, + message: "Server not initialized", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "Send a successful `initialize` request before calling other MCP methods.", + retrySafe: true)); + } + + return method switch + { + "initialize" => Task.FromResult(HandleInitialize( + request.Id, + request.Object["params"], + deferredInitializeCommits)), + "tools/list" => Task.FromResult(HandleToolsList(request.Id, request.Object["params"])), + "tools/call" => HandleToolsCallAsync(request.HasId, request.Id, request.Object["params"]), + "resources/list" => Task.FromResult(HandleResourcesList(request.Id, request.Object["params"])), + "resources/templates/list" => Task.FromResult(HandleResourceTemplatesList(request.Id, request.Object["params"])), + "resources/read" => Task.FromResult(HandleResourcesRead(request.Id, request.Object["params"])), + "prompts/list" => Task.FromResult(HandlePromptsList(request.Id)), + "prompts/get" => Task.FromResult(HandlePromptsGet(request.Id, request.Object["params"])), + "logging/setLevel" => HandleLoggingSetLevelAsync(request.Id, request.Object["params"]), + "ping" => Task.FromResult(CreateSuccessResponse(request.HasId, request.Id, BuildHealthResult())), + _ => Task.FromResult(CreateErrorResponse( + hasId: true, + id: request.Id, + code: -32601, + message: $"Method not found: {method}", + category: McpErrorEnvelope.CategoryMethodNotFound, + suggestion: "Supported methods: initialize, tools/list, tools/call, resources/list, resources/templates/list, resources/read, prompts/list, prompts/get, logging/setLevel, ping, notifications/initialized, notifications/roots/list_changed, notifications/shutdown.", + retrySafe: false)), + }; + } +} diff --git a/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs b/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs index d2f235398..5e02126ba 100644 --- a/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs +++ b/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs @@ -47,248 +47,6 @@ public partial class McpServer : IDisposable queuedBatchRegistration: null, deferredInitializeCommits: null); - private async Task HandleMessageAsync( - JsonNode request, - bool isolateRequestDb, - Func? beforeDispatchAsync, - bool rejectForCapacity, - QueuedBatchRequestRegistration? queuedBatchRegistration, - DeferredInitializeCommits? deferredInitializeCommits) - { - if (request is JsonArray batch) - { - if (deferredInitializeCommits is null) - { - return await HandleBatchMessageAsync( - batch, - isolateRequestDb, - beforeDispatchAsync, - rejectForCapacity, - deferredInitializeCommits).ConfigureAwait(false); - } - - var previousFrameInitializeState = _frameInitializeState.Value; - var initialFrameInitializeState = CurrentInitializeState; - var frameInitializeState = new FrameInitializeState( - initialFrameInitializeState, - isProvisionalGeneration: false); - _frameInitializeState.Value = frameInitializeState; - var batchBeforeDispatchAsync = beforeDispatchAsync; - if (beforeDispatchAsync is not null) - { - batchBeforeDispatchAsync = async cancellationToken => - { - await beforeDispatchAsync(cancellationToken).ConfigureAwait(false); - // The concurrent loop accepts and pre-registers a batch before its protocol - // predecessor finishes. Advance only this batch's original generation after - // that predecessor commits; timed-out older frames retain their own holders, - // and an in-batch initialize replaces this holder instead of being overwritten. - // concurrent loop は protocol predecessor 完了前に batch を受理・事前登録する。 - // predecessor の commit 後、この batch の元 generation だけを進める。timeout - // 後の旧 frame は別 holder を保持し、batch 内 initialize は holder 自体を置換する。 - frameInitializeState.TryAdvanceToPublishedGeneration( - initialFrameInitializeState, - PublishedInitializeState); - }; - } - try - { - return await HandleBatchMessageAsync( - batch, - isolateRequestDb, - batchBeforeDispatchAsync, - rejectForCapacity, - deferredInitializeCommits).ConfigureAwait(false); - } - finally - { - _frameInitializeState.Value = previousFrameInitializeState; - } - } - - if (request is not JsonObject obj) - return CreateExpectedJsonObjectErrorResponse(); - - lock (_healthStateGate) - _lastRequestAt = _timeProvider.GetUtcNow(); - - // Extract `method` defensively: a non-string `method` (e.g. `"method":42`) must not - // throw before the auth gate runs, otherwise a token-protected server would surface - // `-32603 "Internal error"` to an unauthenticated caller instead of `-32001 - // "Unauthorized"`, leaking that the request reached dispatch internals (#1559). - // `method` は防御的に取り出す。`"method":42` のような非文字列が GetValue() - // で例外を投げると、認証ゲート前に -32603 が返ってしまい、未認証呼び出し元に dispatch - // 内部まで届いた事実が漏れる (#1559)。 - var method = TryGetStringMember(obj, "method"); - if (!TryGetRequestId(obj, out var hasId, out var id, out var idError)) - return CreateErrorResponse(hasId: true, id: null, code: -32600, message: BuildInvalidRequestIdMessage(idError), - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: BuildInvalidRequestIdSuggestion(idError), - retrySafe: false, - extraData: BuildInvalidRequestIdData(idError)); - - if (TryGetStringMember(obj, "jsonrpc") != "2.0") - return CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: jsonrpc must be exactly \"2.0\"", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: "Set the top-level `jsonrpc` member to the string `2.0`.", - retrySafe: false); - - using var correlationScope = hasId && CurrentCorrelationContext.Value is null ? BeginRequestCorrelation(id) : null; - - // A JSON-RPC notification cannot carry an error response, but that does not make it - // safe to bypass authentication when handling it mutates server state. Authenticate - // every state-changing notification before cancellation, roots, or lifecycle state is - // touched; on denial, emit only the bounded local diagnostic and preserve the required - // no-response wire contract (#4537). - // JSON-RPC notification はエラー応答を持てないが、server state を変更する通知まで認証を - // 省略してよいことにはならない。cancellation / roots / lifecycle state に触れる前に認証し、 - // 拒否時は bounded なローカル診断だけを残して no-response 契約を維持する (#4537)。 - if (IsStateChangingNotification(method)) - { - var notificationAuth = _authenticator.Authenticate(request); - if (!notificationAuth.IsAuthenticated) - { - WriteMcpLogLine(BuildAuthFailureLog(method, notificationAuth.FailureReason)); - return null; - } - } - - if (method == "$/cancelRequest" || method == "notifications/cancelled") - { - TryCancelRequest(request["params"]); - return null; - } - - if (rejectForCapacity && IsStateChangingNotification(method)) - { - // Eager cancellation is handled above. Other state notifications are dropped on - // admission overflow regardless of a malformed id, matching the normal no-id - // overload contract without mutating roots or lifecycle state (#4536, #4545). - // eager cancellation は上で処理済み。それ以外の state notification は malformed - // id の有無に関係なく admission overflow 時に drop し、roots/lifecycle を変更しない。 - return null; - } - - var protocolPredecessorAwaited = false; - if (IsStateChangingNotification(method) && beforeDispatchAsync is not null) - { - // Cancellation controls intentionally bypass protocol barriers, but roots/lifecycle - // notifications must not mutate state before an earlier initialize commits. Apply the - // method semantic even when a malformed client attaches an id to the notification. - // cancellation control は protocol barrier を bypass する一方、roots/lifecycle - // notification は先行 initialize の commit 前に state を変更してはならない。 - // malformed client が id を付けた場合も method semantics に基づいて待機する。 - await beforeDispatchAsync(_currentRequestToken.Value).ConfigureAwait(false); - protocolPredecessorAwaited = true; - } - - if (!hasId) - { - if (rejectForCapacity) - return null; - if (!protocolPredecessorAwaited && beforeDispatchAsync is not null) - await beforeDispatchAsync(_currentRequestToken.Value).ConfigureAwait(false); - } - - // Notifications (no id) don't get a response / 通知(idなし)にはレスポンスなし - if (method == "notifications/initialized") - return null; - - if (method == "notifications/roots/list_changed") - { - MarkClientRootsStale(); - _frameInitializeState.Value?.MarkRootsChangeAccepted(); - return null; - } - - // Graceful shutdown via JSON-RPC notification (#1567). Without this, the only way to - // stop a long-lived `cdidx mcp` server was to close the transport (stdin EOF / HTTP - // listener stop), which races with in-flight work and forces clients to send SIGINT. - // Treating both `notifications/shutdown` (the MCP spec-aligned name) and the legacy - // LSP-style `notifications/exit` alias as graceful-stop signals lets clients drain the - // current request and exit cleanly. Asynchronous cancellation unblocks any pending - // `ReadFrameAsync` without letting a slow user callback hold the dispatch thread (#4543). - // JSON-RPC 通知による graceful shutdown (#1567)。非同期 cancellation で slow callback に - // dispatch thread を塞がせず `ReadFrameAsync` を unblock する (#4543)。 - if (string.Equals(method, "notifications/shutdown", StringComparison.Ordinal) - || string.Equals(method, "notifications/exit", StringComparison.Ordinal)) - { - WriteMcpLogLine($"[cdidx-mcp] Received {method}; draining in-flight work and shutting down."); - _running = false; - _ = RequestShutdownCancellation(); - return null; - } - - if (!hasId) - { - if (method != null && method.StartsWith("notifications/", StringComparison.OrdinalIgnoreCase)) - WriteMcpLogLine(BuildUnknownNotificationLog(method)); - return null; - } - - // Authenticate every responded request before dispatch so the auth contract is - // uniform across `initialize`, `tools/list`, `tools/call`, and `ping`. Run auth even - // when `method` is missing or malformed so a token-protected server cannot be probed - // for method-shape errors without credentials (#1559). State-changing notifications - // pass through their own auth gate above; side-effect-free notifications short-circuit - // without authentication because they produce no response. - // すべての応答対象リクエストを dispatch 前に認証する。`method` が欠落・不正でも - // 認証は走らせ、トークン保護下のサーバーで未認証呼び出し元に method 形式エラーを - // 漏らさない (#1559)。state-changing notification は上の専用ゲートで認証し、 - // 副作用のない notification だけを応答なしで short-circuit する。 - var authResult = _authenticator.Authenticate(request); - if (!authResult.IsAuthenticated) - { - DeferFrameLog(BuildAuthFailureLog(method, authResult.FailureReason)); - return CreateErrorResponse(hasId: true, id: id, code: McpErrorEnvelope.CodeUnauthorized, message: "Unauthorized", - category: McpErrorEnvelope.CategoryPermissionDenied, - suggestion: "Set CDIDX_MCP_AUTH_TOKEN on the server and include a matching params.auth.token (or an `Authorization: Bearer ` header for HTTP) on each request.", - retrySafe: false); - } - - if (rejectForCapacity) - return CreateServerBusyResponse(id); - - if (method == null) - { - return CreateErrorResponse(hasId: true, id: id, code: -32600, message: "Invalid request: missing method", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: "JSON-RPC 2.0 requires a string `method` field.", - retrySafe: false); - } - - return await DispatchWithRequestCancellationAsync(id, isolateRequestDb, beforeDispatchAsync, queuedBatchRegistration, () => - { - if (_enforceInitializationLifecycle && !CurrentInitializeState.Initialized && method != "initialize") - { - return Task.FromResult(CreateErrorResponse(hasId: true, id: id, code: -32002, message: "Server not initialized", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: "Send a successful `initialize` request before calling other MCP methods.", - retrySafe: true)); - } - - return method switch - { - "initialize" => Task.FromResult(HandleInitialize( - id, - request["params"], - deferredInitializeCommits)), - "tools/list" => Task.FromResult(HandleToolsList(id, request["params"])), - "tools/call" => HandleToolsCallAsync(hasId, id, request["params"]), - "resources/list" => Task.FromResult(HandleResourcesList(id, request["params"])), - "resources/templates/list" => Task.FromResult(HandleResourceTemplatesList(id, request["params"])), - "resources/read" => Task.FromResult(HandleResourcesRead(id, request["params"])), - "prompts/list" => Task.FromResult(HandlePromptsList(id)), - "prompts/get" => Task.FromResult(HandlePromptsGet(id, request["params"])), - "logging/setLevel" => HandleLoggingSetLevelAsync(id, request["params"]), - "ping" => Task.FromResult(CreateSuccessResponse(hasId, id, BuildHealthResult())), - _ => Task.FromResult(CreateErrorResponse(hasId: true, id: id, code: -32601, message: $"Method not found: {method}", - category: McpErrorEnvelope.CategoryMethodNotFound, - suggestion: "Supported methods: initialize, tools/list, tools/call, resources/list, resources/templates/list, resources/read, prompts/list, prompts/get, logging/setLevel, ping, notifications/initialized, notifications/cancelled, notifications/shutdown.", - retrySafe: false)), - }; - }).ConfigureAwait(false); - } private static JsonObject CreateExpectedJsonObjectErrorResponse() => CreateErrorResponse(hasId: true, id: null, code: -32600, message: "Invalid request: expected JSON object", From dd6f2c2cc5ce3ab63ca09dace75774379056a6d8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 14:55:48 +0900 Subject: [PATCH 030/101] Encapsulate MCP request cancellation lifecycle --- .../McpServer.MessageDispatch.Cancellation.cs | 244 ++++++++++++++++++ .../Mcp/McpServer.MessageDispatch.cs | 166 ------------ 2 files changed, 244 insertions(+), 166 deletions(-) create mode 100644 src/CodeIndex/Mcp/McpServer.MessageDispatch.Cancellation.cs diff --git a/src/CodeIndex/Mcp/McpServer.MessageDispatch.Cancellation.cs b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Cancellation.cs new file mode 100644 index 000000000..335ecc62f --- /dev/null +++ b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Cancellation.cs @@ -0,0 +1,244 @@ +using System.Diagnostics; +using System.Text.Json.Nodes; + +namespace CodeIndex.Mcp; + +public partial class McpServer +{ + private sealed class RequestDispatchState + { + public RequestDispatchState( + string? requestKey, + McpRequestIdTelemetryData telemetryRequestId, + CancellationTokenSource cancellation, + bool registeredRequest) + { + RequestKey = requestKey; + TelemetryRequestId = telemetryRequestId; + Cancellation = cancellation; + RegisteredRequest = registeredRequest; + } + + public string? RequestKey { get; } + public McpRequestIdTelemetryData TelemetryRequestId { get; } + public CancellationTokenSource Cancellation { get; } + public bool RegisteredRequest { get; } + public Stopwatch? Stopwatch { get; set; } + public bool ExecutionSlotAcquired { get; set; } + public bool CleanupNow { get; private set; } = true; + public bool ReleaseExecutionSlotNow { get; private set; } = true; + + public void DeferCleanup() + { + CleanupNow = false; + ReleaseExecutionSlotNow = false; + } + } + + private async Task DispatchWithRequestCancellationAsync( + JsonNode? id, + bool isolateRequestDb, + Func? beforeDispatchAsync, + QueuedBatchRequestRegistration? queuedBatchRegistration, + Func> action) + { + if (!TryCreateRequestDispatchState(id, queuedBatchRegistration, out var state, out var duplicateError)) + return duplicateError!; + + var previousToken = _currentRequestToken.Value; + try + { + _currentRequestToken.Value = state!.Cancellation.Token; + state.Cancellation.Token.ThrowIfCancellationRequested(); + if (beforeDispatchAsync is not null) + await beforeDispatchAsync(state.Cancellation.Token).ConfigureAwait(false); + await _concurrencyGate.WaitAsync(state.Cancellation.Token).ConfigureAwait(false); + state.ExecutionSlotAcquired = true; + state.Cancellation.Token.ThrowIfCancellationRequested(); + state.Stopwatch = Stopwatch.StartNew(); + + return isolateRequestDb + ? await ExecuteIsolatedRequestActionAsync(id, state, action).ConfigureAwait(false) + : await ExecuteInlineRequestActionAsync(id, state, action).ConfigureAwait(false); + } + catch (OperationCanceledException) when (state!.Cancellation.IsCancellationRequested) + { + if (state.Stopwatch is not null + && !previousToken.IsCancellationRequested + && !_shutdownCts.IsCancellationRequested + && state.Stopwatch.Elapsed >= _requestTimeout) + { + return CreateRequestTimeoutResponse(id, state.Stopwatch.Elapsed); + } + return CreateCancelledResponse(id); + } + finally + { + _currentRequestToken.Value = previousToken; + CleanupRequestDispatchState(state!); + } + } + + private bool TryCreateRequestDispatchState( + JsonNode? id, + QueuedBatchRequestRegistration? queuedBatchRegistration, + out RequestDispatchState? state, + out JsonNode? duplicateError) + { + var requestKey = SerializeRequestId(id); + var cancellation = queuedBatchRegistration is null + ? CancellationTokenSource.CreateLinkedTokenSource(_currentRequestToken.Value, _shutdownCts.Token) + : CancellationTokenSource.CreateLinkedTokenSource( + _currentRequestToken.Value, + _shutdownCts.Token, + queuedBatchRegistration.Token); + var registeredRequest = false; + if (requestKey is not null) + { + if (!_activeRequests.TryAdd(requestKey, cancellation)) + { + cancellation.Dispose(); + state = null; + duplicateError = CreateErrorResponse( + hasId: true, + id, + code: -32600, + message: "Duplicate in-flight request id", + category: McpErrorEnvelope.CategoryInvalidRequest, + suggestion: "JSON-RPC request ids must be unique while a previous request with the same id is still running.", + retrySafe: true); + return false; + } + + registeredRequest = true; + if (queuedBatchRegistration is not null && !queuedBatchRegistration.TryClaim()) + CancelRequestCts(cancellation); + if (TryConsumePendingRequestCancellation(requestKey)) + CancelRequestCts(cancellation); + RequestRegisteredForTests?.Invoke(id); + } + + state = new RequestDispatchState( + requestKey, + McpRequestIdTelemetry.Create(id), + cancellation, + registeredRequest); + duplicateError = null; + return true; + } + + private async Task ExecuteInlineRequestActionAsync( + JsonNode? id, + RequestDispatchState state, + Func> action) + { + state.Cancellation.CancelAfter(_requestTimeout); + var previousIsolation = _isolateDbForCurrentRequest.Value; + _isolateDbForCurrentRequest.Value = false; + try + { + await DelayRequestForTestsAsync(id, state.Cancellation.Token).ConfigureAwait(false); + return await action().ConfigureAwait(false); + } + finally + { + _isolateDbForCurrentRequest.Value = previousIsolation; + } + } + + private async Task ExecuteIsolatedRequestActionAsync( + JsonNode? id, + RequestDispatchState state, + Func> action) + { + var actionTask = Task.Run(async () => + { + var previousIsolation = _isolateDbForCurrentRequest.Value; + _isolateDbForCurrentRequest.Value = true; + try + { + await DelayRequestForTestsAsync(id, state.Cancellation.Token).ConfigureAwait(false); + return await action().ConfigureAwait(false); + } + finally + { + _isolateDbForCurrentRequest.Value = previousIsolation; + } + }, state.Cancellation.Token); + + using var timeoutDelayCts = new CancellationTokenSource(); + var remainingTimeout = _requestTimeout - state.Stopwatch!.Elapsed; + var timeoutTask = remainingTimeout <= TimeSpan.Zero + ? Task.CompletedTask + : Task.Delay(remainingTimeout, timeoutDelayCts.Token); + var cancellationSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellationRegistration = state.Cancellation.Token.Register( + static signal => ((TaskCompletionSource)signal!).TrySetResult(true), + cancellationSignal); + var cancellationTask = cancellationSignal.Task; + var completed = await Task.WhenAny(actionTask, timeoutTask, cancellationTask).ConfigureAwait(false); + try { timeoutDelayCts.Cancel(); } + catch (ObjectDisposedException) { /* the timeout signal has already completed. */ } + + if (completed == cancellationTask && _shutdownCts.IsCancellationRequested) + { + // EOF/server shutdown owns the bounded outer request-task drain. Keep this dispatch + // attached to non-cooperative work so teardown cannot race a terminal write (#4543). + return await actionTask.ConfigureAwait(false); + } + if (completed == actionTask) + return await actionTask.ConfigureAwait(false); + + var timedOut = completed == timeoutTask; + if (timedOut) + CancelRequestCts(state.Cancellation); + var elapsed = state.Stopwatch.Elapsed; + if (timedOut) + RecordTimedOutIsolatedActionDraining(state.TelemetryRequestId, elapsed); + + state.DeferCleanup(); + _currentDetachedIsolatedActions.Value?.Enqueue(actionTask); + RegisterDetachedRequestCleanup(state, actionTask, timedOut); + return timedOut + ? CreateRequestTimeoutResponse(id, elapsed, isolatedActionDraining: true) + : CreateCancelledResponse(id); + } + + private void RegisterDetachedRequestCleanup( + RequestDispatchState state, + Task actionTask, + bool timedOut) + { + // Cleanup must run after request timeout/shutdown cancellation. The execution lease remains + // held until the underlying action ends so live handlers cannot exceed MaxConcurrency. + // timeout / cancel 応答後も underlying action 終了まで cleanup と execution lease を保持する。 + _ = actionTask.ContinueWith(task => + { + try + { + _ = task.Exception; + if (state.RegisteredRequest) + _activeRequests.TryRemove(state.RequestKey!, out _); + if (timedOut) + RecordTimedOutIsolatedActionDrained(state.TelemetryRequestId, task); + } + finally + { + state.Cancellation.Dispose(); + _concurrencyGate.Release(); + } + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + + private void CleanupRequestDispatchState(RequestDispatchState state) + { + if (state.ExecutionSlotAcquired && state.ReleaseExecutionSlotNow) + _concurrencyGate.Release(); + if (!state.CleanupNow) + return; + + if (state.RegisteredRequest) + _activeRequests.TryRemove(state.RequestKey!, out _); + state.Cancellation.Dispose(); + } +} diff --git a/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs b/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs index 5e02126ba..5ee517840 100644 --- a/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs +++ b/src/CodeIndex/Mcp/McpServer.MessageDispatch.cs @@ -557,172 +557,6 @@ private readonly record struct BatchResponseBudgetSlot( bool CanShapeResourcesListResponse, bool CanShapeResourcesReadResponse); - private async Task DispatchWithRequestCancellationAsync( - JsonNode? id, - bool isolateRequestDb, - Func? beforeDispatchAsync, - QueuedBatchRequestRegistration? queuedBatchRegistration, - Func> action) - { - var requestKey = SerializeRequestId(id); - var telemetryRequestId = McpRequestIdTelemetry.Create(id); - var requestCts = queuedBatchRegistration is null - ? CancellationTokenSource.CreateLinkedTokenSource(_currentRequestToken.Value, _shutdownCts.Token) - : CancellationTokenSource.CreateLinkedTokenSource( - _currentRequestToken.Value, - _shutdownCts.Token, - queuedBatchRegistration.Token); - var registeredRequest = false; - if (requestKey is not null) - { - if (!_activeRequests.TryAdd(requestKey, requestCts)) - { - requestCts.Dispose(); - return CreateErrorResponse(hasId: true, id: id, code: -32600, message: "Duplicate in-flight request id", - category: McpErrorEnvelope.CategoryInvalidRequest, - suggestion: "JSON-RPC request ids must be unique while a previous request with the same id is still running.", - retrySafe: true); - } - registeredRequest = true; - if (queuedBatchRegistration is not null && !queuedBatchRegistration.TryClaim()) - CancelRequestCts(requestCts); - if (TryConsumePendingRequestCancellation(requestKey)) - CancelRequestCts(requestCts); - RequestRegisteredForTests?.Invoke(id); - } - - var previousToken = _currentRequestToken.Value; - Stopwatch? stopwatch = null; - var cleanupNow = true; - var executionSlotAcquired = false; - var releaseExecutionSlotNow = true; - try - { - _currentRequestToken.Value = requestCts.Token; - requestCts.Token.ThrowIfCancellationRequested(); - if (beforeDispatchAsync is not null) - await beforeDispatchAsync(requestCts.Token).ConfigureAwait(false); - await _concurrencyGate.WaitAsync(requestCts.Token).ConfigureAwait(false); - executionSlotAcquired = true; - requestCts.Token.ThrowIfCancellationRequested(); - stopwatch = Stopwatch.StartNew(); - - if (!isolateRequestDb) - { - requestCts.CancelAfter(_requestTimeout); - var previousIsolation = _isolateDbForCurrentRequest.Value; - _isolateDbForCurrentRequest.Value = false; - try - { - await DelayRequestForTestsAsync(id, requestCts.Token).ConfigureAwait(false); - return await action().ConfigureAwait(false); - } - finally - { - _isolateDbForCurrentRequest.Value = previousIsolation; - } - } - - var actionTask = Task.Run(async () => - { - var previousIsolation = _isolateDbForCurrentRequest.Value; - _isolateDbForCurrentRequest.Value = isolateRequestDb; - try - { - await DelayRequestForTestsAsync(id, requestCts.Token).ConfigureAwait(false); - return await action().ConfigureAwait(false); - } - finally - { - _isolateDbForCurrentRequest.Value = previousIsolation; - } - }, requestCts.Token); - using var timeoutDelayCts = new CancellationTokenSource(); - var remainingTimeout = _requestTimeout - stopwatch.Elapsed; - var timeoutTask = remainingTimeout <= TimeSpan.Zero - ? Task.CompletedTask - : Task.Delay(remainingTimeout, timeoutDelayCts.Token); - var cancellationSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var cancellationRegistration = requestCts.Token.Register( - static state => ((TaskCompletionSource)state!).TrySetResult(true), - cancellationSignal); - var cancellationTask = cancellationSignal.Task; - var completed = await Task.WhenAny(actionTask, timeoutTask, cancellationTask).ConfigureAwait(false); - try { timeoutDelayCts.Cancel(); } - catch (ObjectDisposedException) { /* the timeout signal has already completed. */ } - if (completed == cancellationTask && _shutdownCts.IsCancellationRequested) - { - // EOF/server shutdown owns the bounded outer request-task drain. Keep this - // dispatch attached to non-cooperative work so teardown does not manufacture a - // late cancellation response or race a terminal protocol-error write (#4543). - // EOF/server shutdown は外側の bounded request-task drain が所有する。非協調 work を - // detach せず、遅延 cancel response や terminal protocol-error write との race を防ぐ。 - return await actionTask.ConfigureAwait(false); - } - if (completed != actionTask) - { - var timedOut = completed == timeoutTask; - if (timedOut) - CancelRequestCts(requestCts); - var elapsed = stopwatch.Elapsed; - if (timedOut) - RecordTimedOutIsolatedActionDraining(telemetryRequestId, elapsed); - cleanupNow = false; - releaseExecutionSlotNow = false; - _currentDetachedIsolatedActions.Value?.Enqueue(actionTask); - // This cleanup must run even after request timeout/shutdown cancellation; - // otherwise `_activeRequests`, the linked CTS, and the execution lease would leak - // when an isolated action eventually observes cancellation and exits. The lease - // intentionally remains held until the underlying action actually ends so timeout - // responses cannot let live handlers exceed MaxConcurrency (#3722, #4536, #4545). - // request timeout / shutdown cancellation 後でも cleanup は必ず実行する。 - // underlying action が実際に終了するまで execution lease も保持し、timeout response - // の後に live handler が MaxConcurrency を超えないようにする (#3722, #4536, #4545)。 - _ = actionTask.ContinueWith(task => - { - try - { - _ = task.Exception; - if (registeredRequest) - _activeRequests.TryRemove(requestKey!, out _); - if (timedOut) - RecordTimedOutIsolatedActionDrained(telemetryRequestId, task); - } - finally - { - requestCts.Dispose(); - _concurrencyGate.Release(); - } - }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - return timedOut - ? CreateRequestTimeoutResponse(id, elapsed, isolatedActionDraining: true) - : CreateCancelledResponse(id); - } - - return await actionTask.ConfigureAwait(false); - } - catch (OperationCanceledException) when (requestCts.IsCancellationRequested) - { - if (stopwatch is not null - && !previousToken.IsCancellationRequested - && !_shutdownCts.IsCancellationRequested - && stopwatch.Elapsed >= _requestTimeout) - return CreateRequestTimeoutResponse(id, stopwatch.Elapsed); - return CreateCancelledResponse(id); - } - finally - { - _currentRequestToken.Value = previousToken; - if (executionSlotAcquired && releaseExecutionSlotNow) - _concurrencyGate.Release(); - if (cleanupNow) - { - if (registeredRequest) - _activeRequests.TryRemove(requestKey!, out _); - requestCts.Dispose(); - } - } - } private Task DelayRequestForTestsAsync(JsonNode? id, CancellationToken cancellationToken) { From fcf2e82c83930b7d98cb41efe68459b10ca90dce Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 15:03:09 +0900 Subject: [PATCH 031/101] Extract pattern signature construction phases --- .../Symbols/SymbolExtractor.ExtractCore.cs | 380 +------------- .../SymbolExtractor.PatternSignatures.cs | 487 ++++++++++++++++++ 2 files changed, 511 insertions(+), 356 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs index 776bdff77..499f778cc 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs @@ -682,362 +682,30 @@ private static List ExtractCore( break; } - var csharpSingleLineCollapsedMatch = lang == "csharp" - && csharpMatchLines != null - && ReferenceEquals(patternMatchLine, csharpMatchLines[i]); - var csharpSignatureRawStartColumn = csharpGateRawStartColumn; - var csharpSameLineBraceStartColumn = csharpSingleLineCollapsedMatch - ? absoluteStartColumn - : csharpSignatureRawStartColumn; - var sameLineEndColumn = pattern.BodyStyle == BodyStyle.Brace - && bodyEndLine == startLine - ? (lang == "csharp" && csharpSingleLineCollapsedMatch - ? FindCSharpSameLineBraceEndColumnFromSanitized(patternMatchLine, csharpSameLineBraceStartColumn) - : FindSameLineBraceEndColumn(line, csharpSameLineBraceStartColumn, lang, kind)) - : -1; - var sameLineEndUsesRawColumns = pattern.BodyStyle == BodyStyle.Brace - && bodyEndLine == startLine - && !(lang == "csharp" && csharpSingleLineCollapsedMatch); - if (lang == "csharp" - && csharpSingleLineCollapsedMatch - && CanUseCSharpSameLineSemicolonEndColumn(kind)) - { - var semicolonEndColumn = FindCSharpSameLineSemicolonEndColumn(patternMatchLine, absoluteStartColumn); - if (semicolonEndColumn >= absoluteStartColumn - && (sameLineEndColumn < absoluteStartColumn || semicolonEndColumn < sameLineEndColumn)) - { - sameLineEndColumn = semicolonEndColumn; - sameLineEndUsesRawColumns = false; - } - } - if (lang == "csharp" - && kind == "event" - && pattern.BodyStyle == BodyStyle.None - && HasCSharpEventAccessorStart(patternMatchLine[absoluteStartColumn..])) - { - // Same-line accessor events (`event E { add {} remove {} }`) share the - // sibling-stream requirement with semicolon-bodied members: their - // signature must stop at the accessor block so later same-line siblings - // can restart the full pattern scan. Without this brace clamp, the - // stored event signature swallows the following declaration and the - // later sibling never reaches earlier patterns such as property. - // Closes #520. - // 同一行 accessor event (`event E { add {} remove {} }`) も semicolon 系 - // member と同様に sibling stream として扱う必要がある。そのため - // accessor block の閉じ `}` で signature を切り、後続の same-line - // sibling が property など先頭側 pattern へ再到達できるようにする。 - // これが無いと event signature が後続宣言を飲み込み、後続 sibling が - // earlier pattern に届かない。Closes #520. - var braceEndColumn = csharpSingleLineCollapsedMatch - ? FindCSharpSameLineBraceEndColumnFromSanitized(patternMatchLine, csharpSameLineBraceStartColumn) - : FindSameLineBraceEndColumn(line, csharpSameLineBraceStartColumn, lang, kind); - if (braceEndColumn >= absoluteStartColumn - && (sameLineEndColumn < absoluteStartColumn || braceEndColumn < sameLineEndColumn)) - { - sameLineEndColumn = braceEndColumn; - sameLineEndUsesRawColumns = !(lang == "csharp" && csharpSingleLineCollapsedMatch); - } - } - if (sameLineEndColumn < absoluteStartColumn - && lang == "csharp" - && kind == "enum" - && pattern.BodyStyle == BodyStyle.None) - { - sameLineEndColumn = FindCSharpSameLineEnumMemberEndColumn(patternMatchLine, absoluteStartColumn); - sameLineEndUsesRawColumns = false; - } - string signature; - if (csharpWrappedModifierPrefix != null) - { - // Wrapped ctor signature: prepend the modifier prefix recovered from - // preceding modifier-only lines so the stored signature reflects the - // full declaration (`static Foo() { ... }`) rather than only the name - // line. Honor the same-line brace body truncation when present so the - // signature does not absorb the entire ctor body. Closes #348. - // ラップされたコンストラクタのシグネチャ: 直前のモディファイアのみ行から - // 復元した prefix を付与し、識別子行だけでなく宣言全体 - // (`static Foo() { ... }`) を保存する。同一行に brace 本体が閉じる - // ケースではその末尾で切り詰め、シグネチャが本体全体を飲み込まない - // ようにする。Closes #348. - var nameLineStartColumn = csharpSingleLineCollapsedMatch - ? (sameLineEndUsesRawColumns - ? csharpSignatureRawStartColumn - : csharpSignatureRawStartColumn) - : absoluteStartColumn; - var nameLineEndExclusive = sameLineEndColumn >= absoluteStartColumn - ? (sameLineEndUsesRawColumns - ? Math.Min(sameLineEndColumn + 1, line.Length) - : Math.Min( - TranslateCSharpCollapsedColumnToRaw( - csharpMatchColumnToRaw, - i, - sameLineEndColumn, - line.Length) + 1, - line.Length)) - : line.Length; - var nameLineContent = sameLineEndColumn >= absoluteStartColumn - ? line.AsSpan(nameLineStartColumn, nameLineEndExclusive - nameLineStartColumn) - : line.AsSpan(nameLineStartColumn); - var signatureBuilder = new StringBuilder(csharpWrappedModifierPrefix.Length + 1 + nameLineContent.Length); - signatureBuilder.Append(csharpWrappedModifierPrefix); - signatureBuilder.Append(' '); - signatureBuilder.Append(nameLineContent.TrimStart()); - signature = signatureBuilder.ToString().Trim(); - } - else if (lang == "csharp" - && pattern.Kind == "function" - && pattern.BodyStyle == BodyStyle.Brace - && bodyStartLine.HasValue - && bodyEndLine != startLine - && !IsCSharpMultilineExpressionBodiedMember( - lines, - i, - csharpSignatureRawStartColumn) - && TryFindCSharpBraceBodyHeaderExtent( - lines, - i, - Math.Min(csharpSignatureRawStartColumn, line.Length), - out var csharpBraceHeaderLastLineIndex, - out var csharpBraceHeaderLastLineExclusiveEndColumn)) - { - signature = BuildCSharpMultilineSignature( - lines, - i, - Math.Min(csharpSignatureRawStartColumn, line.Length), - csharpBraceHeaderLastLineIndex, - csharpBraceHeaderLastLineExclusiveEndColumn); - } - else if (sameLineEndColumn >= absoluteStartColumn) - { - if (lang == "csharp" - && csharpSingleLineCollapsedMatch) - { - var rawStart = csharpSignatureRawStartColumn; - var rawEndInclusive = sameLineEndUsesRawColumns - ? sameLineEndColumn - : TranslateCSharpCollapsedColumnToRaw( - csharpMatchColumnToRaw, - i, - sameLineEndColumn, - line.Length); - var rawEndExclusive = Math.Min(rawEndInclusive + 1, line.Length); - if (rawStart > line.Length) - rawStart = line.Length; - if (rawEndExclusive <= rawStart) - rawEndExclusive = Math.Min(rawStart + Math.Max(1, match.Length), line.Length); - signature = line[rawStart..rawEndExclusive].Trim(); - } - else - { - var signatureStartColumn = csharpSingleLineCollapsedMatch && sameLineEndUsesRawColumns - ? csharpSignatureRawStartColumn - : absoluteStartColumn; - var signatureEndExclusive = Math.Min(sameLineEndColumn + 1, line.Length); - if (signatureEndExclusive <= signatureStartColumn) - signatureEndExclusive = Math.Min(signatureStartColumn + Math.Max(1, match.Length), line.Length); - signature = line[signatureStartColumn..signatureEndExclusive].Trim(); - } - } - else if (lang == "csharp" - && pattern.BodyStyle == BodyStyle.None - && TryFindCSharpSemicolonTerminatedSignatureExtent( - lines, - i, - csharpGateRawStartColumn, - out var csharpFieldSignatureLastLineIndex, - out var csharpFieldSignatureLastLineExclusiveEndColumn) - && csharpFieldSignatureLastLineIndex > i) - { - signature = BuildCSharpMultilineSignature( - lines, - i, - csharpGateRawStartColumn, - csharpFieldSignatureLastLineIndex, - csharpFieldSignatureLastLineExclusiveEndColumn); - } - else if (lang == "csharp" - && pattern.BodyStyle == BodyStyle.Brace - && IsCSharpMultilineExpressionBodiedMember( - lines, - i, - csharpSignatureRawStartColumn) - && TryFindCSharpSemicolonTerminatedSignatureExtent( - lines, - i, - csharpSignatureRawStartColumn, - out var csharpSemicolonSignatureLastLineIndex, - out var csharpSemicolonSignatureLastLineExclusiveEndColumn) - && csharpSemicolonSignatureLastLineIndex > i) - { - signature = BuildCSharpMultilineSignature( - lines, - i, - csharpSignatureRawStartColumn, - csharpSemicolonSignatureLastLineIndex, - csharpSemicolonSignatureLastLineExclusiveEndColumn); - } - else if (lang == "csharp" && csharpPropertyCandidate.LastConsumedLineIndex > i) - { - signature = BuildCSharpMultilineSignature( - lines, - i, - csharpSignatureRawStartColumn, - csharpPropertyCandidate.SignatureLastLineIndex, - csharpPropertyCandidate.SignatureLastLineExclusiveEndColumn); - } - else if (lang == "csharp" - && pattern.Kind is "class" or "struct" or "interface" or "enum" - && TryFindCSharpTypeHeaderExtent( - lines, - i, - csharpSignatureRawStartColumn, - out var csharpTypeHeaderLastLineIndex, - out var csharpTypeHeaderLastLineExclusiveEndColumn) - && csharpTypeHeaderLastLineIndex > i) - { - // Wrapped C# type header: base list and `where` clauses often continue - // onto following lines before the body-opening `{` or primary-ctor `;`. - // Join them so consumers like ReferenceExtractor can resolve the base - // type from the stored signature instead of silently treating the class - // as having no base. Uses the comment-stripping variant so trailing or - // interleaved `//` / `/* */` comments do not leak into the signature. - // Closes #382. - // 折り返された C# 型ヘッダ: base リストや `where` 句は本体開きの `{` - // または primary-ctor 終端の `;` までに複数行へまたがることが多い。 - // 継続行を連結して保存し、ReferenceExtractor などが保存済み - // シグネチャから base 型を解決できるようにする。末尾や途中に混じる - // `//` / `/* */` コメントを signature から除去する variant を使う。 - // Closes #382. - signature = BuildCSharpTypeHeaderSignature( - lines, - i, - csharpSignatureRawStartColumn, - csharpTypeHeaderLastLineIndex, - csharpTypeHeaderLastLineExclusiveEndColumn); - } - else if (lang == "csharp" - && pattern.Kind is "event" or "delegate" - && pattern.BodyStyle == BodyStyle.None) - { - // Same-line C# semicolon-style declarations such as - // `event EventHandler E; }` or `delegate void D(); }` must stop at the - // declaration terminator instead of absorbing the enclosing type's - // closing brace into the stored signature. Reuse the same statement-end - // scanner as plain fields so nested `{}` inside accessor-style events - // still stay balanced while the outer `}` remains excluded. - // Closes #473 follow-up. - // `event EventHandler E; }` や `delegate void D(); }` のような - // 同一行 C# のセミコロン終端宣言は、囲む型本体の `}` を signature に - // 含めてはならない。plain field と同じ statement-end scanner を再利用し、 - // アクセサ式 event 内部の `{}` は釣り合いを保ったまま、外側 `}` だけを - // 除外する。Closes #473 follow-up. - var statementEnd = FindCSharpSameLineStatementEnd(patternMatchLine, absoluteStartColumn); - if (statementEnd > line.Length) - statementEnd = line.Length; - if (statementEnd <= absoluteStartColumn) - statementEnd = Math.Min(absoluteStartColumn + Math.Max(1, match.Length), line.Length); - signature = line[absoluteStartColumn..statementEnd].Trim(); - } - else if (lang == "java" - && pattern.BodyStyle == BodyStyle.Brace - && bodyStartLine == null) - { - var statementEnd = FindJavaSameLineStatementEnd(line, absoluteStartColumn); - if (statementEnd > line.Length) - statementEnd = line.Length; - if (statementEnd <= absoluteStartColumn) - statementEnd = Math.Min(absoluteStartColumn + Math.Max(1, match.Length), line.Length); - signature = line[absoluteStartColumn..statementEnd].Trim(); - } - else if (lang == "csharp" - && pattern.Kind == "property" - && pattern.BodyStyle == BodyStyle.None) - { - // For a plain C# field (kind `property`, BodyStyle.None), clamp the - // signature to the end of the field's declaration statement (the - // terminating `;`, or — if an unbalanced `}` from a same-line - // enclosing type body is hit first — the position of that `}`). - // This keeps initializer-backed fields such as - // `private int _x = 42;` carrying a full `private int _x = 42;` - // signature instead of being truncated at `=`, and still prevents - // `public int X; } }` inside a same-line nested type from leaking - // the trailing `} }` into X's signature (which would break the - // same-line `ContainsSymbol` check in `AssignContainers` and make - // X attach to `Outer` instead of `Inner`). Closes #400. - // C# の通常フィールド(kind `property`、BodyStyle.None)では、signature を - // 宣言文の終端(`;` まで、または同一行の囲む型本体の閉じ `}` が先に - // 来ればその位置)までで clamp する。`private int _x = 42;` のような - // 初期化子付きフィールドでも signature が `=` で切れず完全に残り、かつ - // `public int X; } }` のような同一行ネスト型内のフィールドでも - // trailing `} }` が signature に混入せず、AssignContainers の - // ContainsSymbol 判定が正しく動いて X が Inner ではなく Outer に - // ぶら下がる事故が起きない。Closes #400. - var statementEnd = FindCSharpSameLineStatementEnd(patternMatchLine, absoluteStartColumn); - if (csharpMatchLines != null - && ReferenceEquals(patternMatchLine, csharpMatchLines[i])) - { - // Single-line candidate: translate both endpoints through the - // per-line collapsed→raw column map so the raw slice keeps the - // `;` terminator and does not absorb a phantom leading `;` from - // the next declarator on the same line. Without this, a line like - // `public Dictionary Map = new(); public int B;` - // returned `Map` without `;` and `B` with a leading `;` because - // the collapsed-space endpoints no longer lined up with raw - // character positions. Closes #400. - // 単一行候補では、per-line collapsed→raw map で両端点を raw 列に - // 戻してから slice する。こうしないと、 - // `public Dictionary Map = new(); public int B;` のような行で - // `Map` の終端 `;` が欠け、後続の `B` の先頭に `;` が混入する。Closes #400. - var rawStart = TranslateCSharpCollapsedColumnToRaw( - csharpMatchColumnToRaw, - i, - absoluteStartColumn, - line.Length); - var rawEnd = TranslateCSharpCollapsedColumnToRaw( - csharpMatchColumnToRaw, - i, - statementEnd, - line.Length); - if (rawEnd > line.Length) - rawEnd = line.Length; - if (rawStart > line.Length) - rawStart = line.Length; - if (rawEnd <= rawStart) - rawEnd = Math.Min(rawStart + Math.Max(1, match.Length), line.Length); - signature = line[rawStart..rawEnd].Trim(); - } - else - { - if (statementEnd > line.Length) - statementEnd = line.Length; - if (statementEnd <= absoluteStartColumn) - statementEnd = Math.Min(absoluteStartColumn + Math.Max(1, match.Length), line.Length); - signature = line[absoluteStartColumn..statementEnd].Trim(); - } - } - else - { - signature = lang == "fortran" - ? patternMatchLine[absoluteStartColumn..].Trim() - : line[absoluteStartColumn..].Trim(); - } - if (lang == "python" && pattern.Kind is "function" or "class") - signature = BuildPythonLogicalHeaderSignature(lines, i, absoluteStartColumn); - - if (kind == "function" - && lang == "csharp" - && pattern.BodyStyle == BodyStyle.None - && IsCSharpConstOrStaticReadonlyField(signature)) - { - kind = "field"; - } - - if (lang == "csharp" - && pattern.BodyStyle == BodyStyle.None - && (pattern.Kind == "property" || kind == "field")) - { - signature = BoundCSharpFieldInitializerSignature(signature); - } + var signatureResult = BuildPatternSignature( + lang, + pattern, + lines, + i, + line, + patternMatchLine, + absoluteStartColumn, + match, + csharpPropertyCandidate, + csharpWrappedModifierPrefix, + csharpMatchColumnToRaw, + csharpMatchLines, + csharpGateRawStartColumn, + startLine, + bodyStartLine, + bodyEndLine, + kind); + var signature = signatureResult.Signature; + kind = signatureResult.Kind; + var csharpSingleLineCollapsedMatch = signatureResult.Bounds.CSharpSingleLineCollapsedMatch; + var csharpSignatureRawStartColumn = signatureResult.Bounds.CSharpSignatureRawStartColumn; + var sameLineEndColumn = signatureResult.Bounds.SameLineEndColumn; + var sameLineEndUsesRawColumns = signatureResult.Bounds.SameLineEndUsesRawColumns; List? fortranProcedureNames = null; if (lang == "fortran" diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs new file mode 100644 index 000000000..c7b5bc81b --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs @@ -0,0 +1,487 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private readonly record struct PatternSignatureBounds( + bool CSharpSingleLineCollapsedMatch, + int CSharpSignatureRawStartColumn, + int SameLineEndColumn, + bool SameLineEndUsesRawColumns); + + private readonly record struct PatternSignatureResult( + string Signature, + string Kind, + PatternSignatureBounds Bounds); + + private static PatternSignatureBounds ResolvePatternSignatureBounds( + string lang, + SymbolPattern pattern, + string kind, + string line, + string patternMatchLine, + int[]?[] csharpMatchColumnToRaw, + string[]? csharpMatchLines, + int lineIndex, + int absoluteStartColumn, + int csharpGateRawStartColumn, + int startLine, + int? bodyEndLine) + { + var csharpSingleLineCollapsedMatch = lang == "csharp" + && csharpMatchLines != null + && ReferenceEquals(patternMatchLine, csharpMatchLines[lineIndex]); + var csharpSignatureRawStartColumn = csharpGateRawStartColumn; + var csharpSameLineBraceStartColumn = csharpSingleLineCollapsedMatch + ? absoluteStartColumn + : csharpSignatureRawStartColumn; + var sameLineEndColumn = pattern.BodyStyle == BodyStyle.Brace + && bodyEndLine == startLine + ? (lang == "csharp" && csharpSingleLineCollapsedMatch + ? FindCSharpSameLineBraceEndColumnFromSanitized(patternMatchLine, csharpSameLineBraceStartColumn) + : FindSameLineBraceEndColumn(line, csharpSameLineBraceStartColumn, lang, kind)) + : -1; + var sameLineEndUsesRawColumns = pattern.BodyStyle == BodyStyle.Brace + && bodyEndLine == startLine + && !(lang == "csharp" && csharpSingleLineCollapsedMatch); + + if (lang == "csharp" + && csharpSingleLineCollapsedMatch + && CanUseCSharpSameLineSemicolonEndColumn(kind)) + { + var semicolonEndColumn = FindCSharpSameLineSemicolonEndColumn(patternMatchLine, absoluteStartColumn); + if (semicolonEndColumn >= absoluteStartColumn + && (sameLineEndColumn < absoluteStartColumn || semicolonEndColumn < sameLineEndColumn)) + { + sameLineEndColumn = semicolonEndColumn; + sameLineEndUsesRawColumns = false; + } + } + + if (lang == "csharp" + && kind == "event" + && pattern.BodyStyle == BodyStyle.None + && HasCSharpEventAccessorStart(patternMatchLine[absoluteStartColumn..])) + { + var braceEndColumn = csharpSingleLineCollapsedMatch + ? FindCSharpSameLineBraceEndColumnFromSanitized(patternMatchLine, csharpSameLineBraceStartColumn) + : FindSameLineBraceEndColumn(line, csharpSameLineBraceStartColumn, lang, kind); + if (braceEndColumn >= absoluteStartColumn + && (sameLineEndColumn < absoluteStartColumn || braceEndColumn < sameLineEndColumn)) + { + sameLineEndColumn = braceEndColumn; + sameLineEndUsesRawColumns = !csharpSingleLineCollapsedMatch; + } + } + + if (sameLineEndColumn < absoluteStartColumn + && lang == "csharp" + && kind == "enum" + && pattern.BodyStyle == BodyStyle.None) + { + sameLineEndColumn = FindCSharpSameLineEnumMemberEndColumn(patternMatchLine, absoluteStartColumn); + sameLineEndUsesRawColumns = false; + } + + return new PatternSignatureBounds( + csharpSingleLineCollapsedMatch, + csharpSignatureRawStartColumn, + sameLineEndColumn, + sameLineEndUsesRawColumns); + } + + private static PatternSignatureResult BuildPatternSignature( + string lang, + SymbolPattern pattern, + string[] lines, + int lineIndex, + string line, + string patternMatchLine, + int absoluteStartColumn, + Match match, + CSharpPropertyMatchCandidate csharpPropertyCandidate, + string? csharpWrappedModifierPrefix, + int[]?[] csharpMatchColumnToRaw, + string[]? csharpMatchLines, + int csharpGateRawStartColumn, + int startLine, + int? bodyStartLine, + int? bodyEndLine, + string kind) + { + var bounds = ResolvePatternSignatureBounds( + lang, + pattern, + kind, + line, + patternMatchLine, + csharpMatchColumnToRaw, + csharpMatchLines, + lineIndex, + absoluteStartColumn, + csharpGateRawStartColumn, + startLine, + bodyEndLine); + + string signature; + if (csharpWrappedModifierPrefix is not null) + { + signature = BuildWrappedCSharpPatternSignature( + line, + lineIndex, + match, + csharpMatchColumnToRaw, + csharpWrappedModifierPrefix, + absoluteStartColumn, + bounds); + } + else if (TryBuildCSharpBraceFunctionHeaderSignature( + lang, + pattern, + lines, + lineIndex, + line, + bodyStartLine, + bodyEndLine, + bounds.CSharpSignatureRawStartColumn, + startLine, + out signature)) + { + } + else if (bounds.SameLineEndColumn >= absoluteStartColumn) + { + signature = BuildBoundedSameLinePatternSignature( + lang, + line, + patternMatchLine, + lineIndex, + match, + csharpMatchColumnToRaw, + absoluteStartColumn, + bounds); + } + else if (TryBuildCSharpMultilinePatternSignature( + lang, + pattern, + lines, + lineIndex, + bounds.CSharpSignatureRawStartColumn, + csharpGateRawStartColumn, + csharpPropertyCandidate, + out signature)) + { + } + else if (lang == "csharp" + && pattern.Kind is "event" or "delegate" + && pattern.BodyStyle == BodyStyle.None) + { + signature = BuildCSharpSameLineStatementSignature( + line, + patternMatchLine, + match, + absoluteStartColumn); + } + else if (lang == "java" + && pattern.BodyStyle == BodyStyle.Brace + && bodyStartLine is null) + { + signature = BuildJavaSameLineStatementSignature(line, match, absoluteStartColumn); + } + else if (lang == "csharp" + && pattern.Kind == "property" + && pattern.BodyStyle == BodyStyle.None) + { + signature = BuildCSharpFieldPatternSignature( + line, + patternMatchLine, + lineIndex, + match, + csharpMatchColumnToRaw, + csharpMatchLines, + absoluteStartColumn); + } + else + { + signature = lang == "fortran" + ? patternMatchLine[absoluteStartColumn..].Trim() + : line[absoluteStartColumn..].Trim(); + } + + if (lang == "python" && pattern.Kind is "function" or "class") + signature = BuildPythonLogicalHeaderSignature(lines, lineIndex, absoluteStartColumn); + + if (kind == "function" + && lang == "csharp" + && pattern.BodyStyle == BodyStyle.None + && IsCSharpConstOrStaticReadonlyField(signature)) + { + kind = "field"; + } + + if (lang == "csharp" + && pattern.BodyStyle == BodyStyle.None + && (pattern.Kind == "property" || kind == "field")) + { + signature = BoundCSharpFieldInitializerSignature(signature); + } + + return new PatternSignatureResult(signature, kind, bounds); + } + + private static string BuildWrappedCSharpPatternSignature( + string line, + int lineIndex, + Match match, + int[]?[] csharpMatchColumnToRaw, + string modifierPrefix, + int absoluteStartColumn, + PatternSignatureBounds bounds) + { + var nameLineStartColumn = bounds.CSharpSignatureRawStartColumn; + var nameLineEndExclusive = bounds.SameLineEndColumn >= absoluteStartColumn + ? (bounds.SameLineEndUsesRawColumns + ? Math.Min(bounds.SameLineEndColumn + 1, line.Length) + : Math.Min( + TranslateCSharpCollapsedColumnToRaw( + csharpMatchColumnToRaw, + lineIndex, + bounds.SameLineEndColumn, + line.Length) + 1, + line.Length)) + : line.Length; + var nameLineContent = bounds.SameLineEndColumn >= absoluteStartColumn + ? line.AsSpan(nameLineStartColumn, nameLineEndExclusive - nameLineStartColumn) + : line.AsSpan(nameLineStartColumn); + var signatureBuilder = new StringBuilder(modifierPrefix.Length + 1 + nameLineContent.Length); + signatureBuilder.Append(modifierPrefix); + signatureBuilder.Append(' '); + signatureBuilder.Append(nameLineContent.TrimStart()); + return signatureBuilder.ToString().Trim(); + } + + private static bool TryBuildCSharpBraceFunctionHeaderSignature( + string lang, + SymbolPattern pattern, + string[] lines, + int lineIndex, + string line, + int? bodyStartLine, + int? bodyEndLine, + int signatureRawStartColumn, + int startLine, + out string signature) + { + if (lang == "csharp" + && pattern.Kind == "function" + && pattern.BodyStyle == BodyStyle.Brace + && bodyStartLine.HasValue + && bodyEndLine != startLine + && !IsCSharpMultilineExpressionBodiedMember(lines, lineIndex, signatureRawStartColumn) + && TryFindCSharpBraceBodyHeaderExtent( + lines, + lineIndex, + Math.Min(signatureRawStartColumn, line.Length), + out var lastLineIndex, + out var lastLineExclusiveEndColumn)) + { + signature = BuildCSharpMultilineSignature( + lines, + lineIndex, + Math.Min(signatureRawStartColumn, line.Length), + lastLineIndex, + lastLineExclusiveEndColumn); + return true; + } + + signature = string.Empty; + return false; + } + + private static string BuildBoundedSameLinePatternSignature( + string lang, + string line, + string patternMatchLine, + int lineIndex, + Match match, + int[]?[] csharpMatchColumnToRaw, + int absoluteStartColumn, + PatternSignatureBounds bounds) + { + if (lang == "csharp" && bounds.CSharpSingleLineCollapsedMatch) + { + var rawStart = bounds.CSharpSignatureRawStartColumn; + var rawEndInclusive = bounds.SameLineEndUsesRawColumns + ? bounds.SameLineEndColumn + : TranslateCSharpCollapsedColumnToRaw( + csharpMatchColumnToRaw, + lineIndex, + bounds.SameLineEndColumn, + line.Length); + var rawEndExclusive = Math.Min(rawEndInclusive + 1, line.Length); + if (rawStart > line.Length) + rawStart = line.Length; + if (rawEndExclusive <= rawStart) + rawEndExclusive = Math.Min(rawStart + Math.Max(1, match.Length), line.Length); + return line[rawStart..rawEndExclusive].Trim(); + } + + var signatureStartColumn = bounds.CSharpSingleLineCollapsedMatch && bounds.SameLineEndUsesRawColumns + ? bounds.CSharpSignatureRawStartColumn + : absoluteStartColumn; + var signatureEndExclusive = Math.Min(bounds.SameLineEndColumn + 1, line.Length); + if (signatureEndExclusive <= signatureStartColumn) + signatureEndExclusive = Math.Min(signatureStartColumn + Math.Max(1, match.Length), line.Length); + return line[signatureStartColumn..signatureEndExclusive].Trim(); + } + + private static bool TryBuildCSharpMultilinePatternSignature( + string lang, + SymbolPattern pattern, + string[] lines, + int lineIndex, + int signatureRawStartColumn, + int gateRawStartColumn, + CSharpPropertyMatchCandidate propertyCandidate, + out string signature) + { + if (lang == "csharp" + && pattern.BodyStyle == BodyStyle.None + && TryFindCSharpSemicolonTerminatedSignatureExtent( + lines, + lineIndex, + gateRawStartColumn, + out var fieldLastLineIndex, + out var fieldLastLineExclusiveEndColumn) + && fieldLastLineIndex > lineIndex) + { + signature = BuildCSharpMultilineSignature( + lines, + lineIndex, + gateRawStartColumn, + fieldLastLineIndex, + fieldLastLineExclusiveEndColumn); + return true; + } + + if (lang == "csharp" + && pattern.BodyStyle == BodyStyle.Brace + && IsCSharpMultilineExpressionBodiedMember(lines, lineIndex, signatureRawStartColumn) + && TryFindCSharpSemicolonTerminatedSignatureExtent( + lines, + lineIndex, + signatureRawStartColumn, + out var semicolonLastLineIndex, + out var semicolonLastLineExclusiveEndColumn) + && semicolonLastLineIndex > lineIndex) + { + signature = BuildCSharpMultilineSignature( + lines, + lineIndex, + signatureRawStartColumn, + semicolonLastLineIndex, + semicolonLastLineExclusiveEndColumn); + return true; + } + + if (lang == "csharp" && propertyCandidate.LastConsumedLineIndex > lineIndex) + { + signature = BuildCSharpMultilineSignature( + lines, + lineIndex, + signatureRawStartColumn, + propertyCandidate.SignatureLastLineIndex, + propertyCandidate.SignatureLastLineExclusiveEndColumn); + return true; + } + + if (lang == "csharp" + && pattern.Kind is "class" or "struct" or "interface" or "enum" + && TryFindCSharpTypeHeaderExtent( + lines, + lineIndex, + signatureRawStartColumn, + out var typeHeaderLastLineIndex, + out var typeHeaderLastLineExclusiveEndColumn) + && typeHeaderLastLineIndex > lineIndex) + { + signature = BuildCSharpTypeHeaderSignature( + lines, + lineIndex, + signatureRawStartColumn, + typeHeaderLastLineIndex, + typeHeaderLastLineExclusiveEndColumn); + return true; + } + + signature = string.Empty; + return false; + } + + private static string BuildCSharpSameLineStatementSignature( + string line, + string patternMatchLine, + Match match, + int absoluteStartColumn) + { + var statementEnd = FindCSharpSameLineStatementEnd(patternMatchLine, absoluteStartColumn); + if (statementEnd > line.Length) + statementEnd = line.Length; + if (statementEnd <= absoluteStartColumn) + statementEnd = Math.Min(absoluteStartColumn + Math.Max(1, match.Length), line.Length); + return line[absoluteStartColumn..statementEnd].Trim(); + } + + private static string BuildJavaSameLineStatementSignature( + string line, + Match match, + int absoluteStartColumn) + { + var statementEnd = FindJavaSameLineStatementEnd(line, absoluteStartColumn); + if (statementEnd > line.Length) + statementEnd = line.Length; + if (statementEnd <= absoluteStartColumn) + statementEnd = Math.Min(absoluteStartColumn + Math.Max(1, match.Length), line.Length); + return line[absoluteStartColumn..statementEnd].Trim(); + } + + private static string BuildCSharpFieldPatternSignature( + string line, + string patternMatchLine, + int lineIndex, + Match match, + int[]?[] csharpMatchColumnToRaw, + string[]? csharpMatchLines, + int absoluteStartColumn) + { + var statementEnd = FindCSharpSameLineStatementEnd(patternMatchLine, absoluteStartColumn); + if (csharpMatchLines != null + && ReferenceEquals(patternMatchLine, csharpMatchLines[lineIndex])) + { + var rawStart = TranslateCSharpCollapsedColumnToRaw( + csharpMatchColumnToRaw, + lineIndex, + absoluteStartColumn, + line.Length); + var rawEnd = TranslateCSharpCollapsedColumnToRaw( + csharpMatchColumnToRaw, + lineIndex, + statementEnd, + line.Length); + if (rawEnd > line.Length) + rawEnd = line.Length; + if (rawStart > line.Length) + rawStart = line.Length; + if (rawEnd <= rawStart) + rawEnd = Math.Min(rawStart + Math.Max(1, match.Length), line.Length); + return line[rawStart..rawEnd].Trim(); + } + + if (statementEnd > line.Length) + statementEnd = line.Length; + if (statementEnd <= absoluteStartColumn) + statementEnd = Math.Min(absoluteStartColumn + Math.Max(1, match.Length), line.Length); + return line[absoluteStartColumn..statementEnd].Trim(); + } +} From 341bbe93ac3c35df97c789603216511eff164b09 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 15:08:08 +0900 Subject: [PATCH 032/101] Separate symbol extraction state management --- .../Indexer/Symbols/SymbolExtractor.State.cs | 243 ++++++++++++++++++ .../Indexer/Symbols/SymbolExtractor.cs | 236 ----------------- 2 files changed, 243 insertions(+), 236 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.State.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.State.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.State.cs new file mode 100644 index 000000000..850ca961c --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.State.cs @@ -0,0 +1,243 @@ +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private sealed class SymbolExtractionState + { + private readonly int _initialCapacity; + private SymbolAddState? _symbolAddState; + private SymbolLineIdentityState? _symbolLineIdentityState; + + public SymbolExtractionState(int initialCapacity = 0) + { + _initialCapacity = initialCapacity; + } + + public static SymbolExtractionState FromSymbols(List symbols) + { + var state = new SymbolExtractionState(symbols.Count); + foreach (var symbol in symbols) + state.Record(symbol); + return state; + } + + public int GetExactDuplicateCount(SymbolRecord symbol) => + _symbolAddState?.GetExactDuplicateCount(symbol) ?? 0; + + public int? GetSameLineSignatureOccurrenceIndex(SymbolRecord symbol) => + _symbolAddState?.GetSameLineSignatureOccurrenceIndex(symbol) + ?? (TryGetSameLineSignatureKey(symbol, out _) ? 0 : null); + + public bool HasSymbolLineIdentity(List symbols, SymbolLineIdentity identity) + { + if (symbols.Count == 0) + return false; + + return (_symbolLineIdentityState ??= new(_initialCapacity)).Contains(symbols, identity); + } + + public void Record(SymbolRecord symbol) => + (_symbolAddState ??= new(_initialCapacity)).Record(symbol); + + public void Remove(SymbolRecord symbol) => + _symbolAddState?.Remove(symbol); + } + + private sealed class SymbolExtractionList : List + { + public SymbolExtractionList(int initialCapacity) + : base(initialCapacity) + { + ExtractionState = new SymbolExtractionState(initialCapacity); + } + + public SymbolExtractionState ExtractionState { get; } + } + + private sealed class SymbolAddState + { + private readonly int _initialCapacity; + private Dictionary? _exactCounts; + private Dictionary? _sameLineSignatureCounts; + + public SymbolAddState(int initialCapacity) + { + _initialCapacity = initialCapacity; + } + + public int GetExactDuplicateCount(SymbolRecord symbol) + { + if (_exactCounts is null) + return 0; + + var key = new SymbolRecordIdentity(symbol); + return _exactCounts.TryGetValue(key, out var count) ? count : 0; + } + + public int? GetSameLineSignatureOccurrenceIndex(SymbolRecord symbol) + { + if (!TryGetSameLineSignatureKey(symbol, out var key)) + return null; + + if (_sameLineSignatureCounts is null) + return 0; + + return _sameLineSignatureCounts.TryGetValue(key, out var count) ? count : 0; + } + + public void Record(SymbolRecord symbol) + { + var exactKey = new SymbolRecordIdentity(symbol); + var exactCounts = _exactCounts ??= CreateSymbolRecordIdentityDictionary(_initialCapacity); + exactCounts[exactKey] = exactCounts.TryGetValue(exactKey, out var exactCount) + ? exactCount + 1 + : 1; + + if (TryGetSameLineSignatureKey(symbol, out var sameLineKey)) + { + var sameLineSignatureCounts = _sameLineSignatureCounts ??= CreateSameLineSignatureDictionary(_initialCapacity); + sameLineSignatureCounts[sameLineKey] = sameLineSignatureCounts.TryGetValue(sameLineKey, out var sameLineCount) + ? sameLineCount + 1 + : 1; + } + } + + public void Remove(SymbolRecord symbol) + { + if (_exactCounts is not null) + { + var exactKey = new SymbolRecordIdentity(symbol); + if (_exactCounts.TryGetValue(exactKey, out var exactCount)) + { + if (exactCount <= 1) + _exactCounts.Remove(exactKey); + else + _exactCounts[exactKey] = exactCount - 1; + } + } + + if (!TryGetSameLineSignatureKey(symbol, out var sameLineKey)) + return; + + if (_sameLineSignatureCounts is null) + return; + + if (!_sameLineSignatureCounts.TryGetValue(sameLineKey, out var sameLineCount)) + return; + + if (sameLineCount <= 1) + _sameLineSignatureCounts.Remove(sameLineKey); + else + _sameLineSignatureCounts[sameLineKey] = sameLineCount - 1; + } + } + + private static Dictionary CreateSymbolRecordIdentityDictionary(int initialCapacity) => + initialCapacity == 0 + ? new Dictionary() + : new Dictionary(initialCapacity); + + private static Dictionary CreateSameLineSignatureDictionary(int initialCapacity) => + initialCapacity == 0 + ? new Dictionary() + : new Dictionary(initialCapacity); + + private readonly record struct SymbolRecordIdentity( + string Kind, + string Name, + int Line, + int StartLine, + int? StartColumn, + int EndLine, + int? BodyStartLine, + int? BodyEndLine, + string? Signature, + string? Visibility, + string? ReturnType) + { + public SymbolRecordIdentity(SymbolRecord symbol) + : this( + symbol.Kind, + symbol.Name, + symbol.Line, + symbol.StartLine, + symbol.StartColumn, + symbol.EndLine, + symbol.BodyStartLine, + symbol.BodyEndLine, + symbol.Signature, + symbol.Visibility, + symbol.ReturnType) + { + } + } + + private readonly record struct SymbolLineIdentity(long FileId, int Line, string Kind, string Name); + private readonly record struct SymbolKindNameIdentity(string Kind, string Name); + + private sealed class SymbolLineIdentityState + { + private readonly HashSet _identities; + private int _knownCount; + + public SymbolLineIdentityState(int initialCapacity) + { + _identities = initialCapacity == 0 ? [] : new HashSet(initialCapacity); + } + + public bool Contains(List symbols, SymbolLineIdentity identity) + { + Sync(symbols); + return _identities.Contains(identity); + } + + private void Sync(List symbols) + { + if (_knownCount > symbols.Count) + { + _identities.Clear(); + _knownCount = 0; + } + + for (; _knownCount < symbols.Count; _knownCount++) + _identities.Add(GetSymbolLineIdentity(symbols[_knownCount])); + } + } + + private static HashSet BuildSymbolLineIdentities(IEnumerable symbols, int expectedAdditionalLines = 0) + { + var identities = symbols is ICollection collection + ? new HashSet(collection.Count + EstimateSymbolListInitialCapacity(expectedAdditionalLines)) + : new HashSet(); + foreach (var symbol in symbols) + identities.Add(GetSymbolLineIdentity(symbol)); + return identities; + } + + private static SymbolLineIdentity GetSymbolLineIdentity(SymbolRecord symbol) + => new(symbol.FileId, symbol.Line, symbol.Kind, symbol.Name); + + private static bool HasSymbolLineIdentity( + HashSet identities, + long fileId, + int lineNumber, + string kind, + string name) + => identities.Contains(new SymbolLineIdentity(fileId, lineNumber, kind, name)); + + private static bool HasSymbolLineIdentity( + SymbolExtractionState extractionState, + List symbols, + long fileId, + int lineNumber, + string kind, + string name) + => extractionState.HasSymbolLineIdentity(symbols, new SymbolLineIdentity(fileId, lineNumber, kind, name)); + + private static void RecordSymbolLineIdentity(HashSet identities, SymbolRecord symbol) + => identities.Add(GetSymbolLineIdentity(symbol)); + + private readonly record struct SameLineSignatureKey(int Line, int StartLine, string Signature); +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 7a2e1c810..95c873672 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -2983,242 +2983,6 @@ public static void ApplyFamilyScope(IEnumerable symbols, string sc } } - private sealed class SymbolExtractionState - { - private readonly int _initialCapacity; - private SymbolAddState? _symbolAddState; - private SymbolLineIdentityState? _symbolLineIdentityState; - - public SymbolExtractionState(int initialCapacity = 0) - { - _initialCapacity = initialCapacity; - } - - public static SymbolExtractionState FromSymbols(List symbols) - { - var state = new SymbolExtractionState(symbols.Count); - foreach (var symbol in symbols) - state.Record(symbol); - return state; - } - - public int GetExactDuplicateCount(SymbolRecord symbol) => - _symbolAddState?.GetExactDuplicateCount(symbol) ?? 0; - - public int? GetSameLineSignatureOccurrenceIndex(SymbolRecord symbol) => - _symbolAddState?.GetSameLineSignatureOccurrenceIndex(symbol) - ?? (TryGetSameLineSignatureKey(symbol, out _) ? 0 : null); - - public bool HasSymbolLineIdentity(List symbols, SymbolLineIdentity identity) - { - if (symbols.Count == 0) - return false; - - return (_symbolLineIdentityState ??= new(_initialCapacity)).Contains(symbols, identity); - } - - public void Record(SymbolRecord symbol) => - (_symbolAddState ??= new(_initialCapacity)).Record(symbol); - - public void Remove(SymbolRecord symbol) => - _symbolAddState?.Remove(symbol); - } - - private sealed class SymbolExtractionList : List - { - public SymbolExtractionList(int initialCapacity) - : base(initialCapacity) - { - ExtractionState = new SymbolExtractionState(initialCapacity); - } - - public SymbolExtractionState ExtractionState { get; } - } - - private sealed class SymbolAddState - { - private readonly int _initialCapacity; - private Dictionary? _exactCounts; - private Dictionary? _sameLineSignatureCounts; - - public SymbolAddState(int initialCapacity) - { - _initialCapacity = initialCapacity; - } - - public int GetExactDuplicateCount(SymbolRecord symbol) - { - if (_exactCounts is null) - return 0; - - var key = new SymbolRecordIdentity(symbol); - return _exactCounts.TryGetValue(key, out var count) ? count : 0; - } - - public int? GetSameLineSignatureOccurrenceIndex(SymbolRecord symbol) - { - if (!TryGetSameLineSignatureKey(symbol, out var key)) - return null; - - if (_sameLineSignatureCounts is null) - return 0; - - return _sameLineSignatureCounts.TryGetValue(key, out var count) ? count : 0; - } - - public void Record(SymbolRecord symbol) - { - var exactKey = new SymbolRecordIdentity(symbol); - var exactCounts = _exactCounts ??= CreateSymbolRecordIdentityDictionary(_initialCapacity); - exactCounts[exactKey] = exactCounts.TryGetValue(exactKey, out var exactCount) - ? exactCount + 1 - : 1; - - if (TryGetSameLineSignatureKey(symbol, out var sameLineKey)) - { - var sameLineSignatureCounts = _sameLineSignatureCounts ??= CreateSameLineSignatureDictionary(_initialCapacity); - sameLineSignatureCounts[sameLineKey] = sameLineSignatureCounts.TryGetValue(sameLineKey, out var sameLineCount) - ? sameLineCount + 1 - : 1; - } - } - - public void Remove(SymbolRecord symbol) - { - if (_exactCounts is not null) - { - var exactKey = new SymbolRecordIdentity(symbol); - if (_exactCounts.TryGetValue(exactKey, out var exactCount)) - { - if (exactCount <= 1) - _exactCounts.Remove(exactKey); - else - _exactCounts[exactKey] = exactCount - 1; - } - } - - if (!TryGetSameLineSignatureKey(symbol, out var sameLineKey)) - return; - - if (_sameLineSignatureCounts is null) - return; - - if (!_sameLineSignatureCounts.TryGetValue(sameLineKey, out var sameLineCount)) - return; - - if (sameLineCount <= 1) - _sameLineSignatureCounts.Remove(sameLineKey); - else - _sameLineSignatureCounts[sameLineKey] = sameLineCount - 1; - } - } - - private static Dictionary CreateSymbolRecordIdentityDictionary(int initialCapacity) => - initialCapacity == 0 - ? new Dictionary() - : new Dictionary(initialCapacity); - - private static Dictionary CreateSameLineSignatureDictionary(int initialCapacity) => - initialCapacity == 0 - ? new Dictionary() - : new Dictionary(initialCapacity); - - private readonly record struct SymbolRecordIdentity( - string Kind, - string Name, - int Line, - int StartLine, - int? StartColumn, - int EndLine, - int? BodyStartLine, - int? BodyEndLine, - string? Signature, - string? Visibility, - string? ReturnType) - { - public SymbolRecordIdentity(SymbolRecord symbol) - : this( - symbol.Kind, - symbol.Name, - symbol.Line, - symbol.StartLine, - symbol.StartColumn, - symbol.EndLine, - symbol.BodyStartLine, - symbol.BodyEndLine, - symbol.Signature, - symbol.Visibility, - symbol.ReturnType) - { - } - } - - private readonly record struct SymbolLineIdentity(long FileId, int Line, string Kind, string Name); - private readonly record struct SymbolKindNameIdentity(string Kind, string Name); - - private sealed class SymbolLineIdentityState - { - private readonly HashSet _identities; - private int _knownCount; - - public SymbolLineIdentityState(int initialCapacity) - { - _identities = initialCapacity == 0 ? [] : new HashSet(initialCapacity); - } - - public bool Contains(List symbols, SymbolLineIdentity identity) - { - Sync(symbols); - return _identities.Contains(identity); - } - - private void Sync(List symbols) - { - if (_knownCount > symbols.Count) - { - _identities.Clear(); - _knownCount = 0; - } - - for (; _knownCount < symbols.Count; _knownCount++) - _identities.Add(GetSymbolLineIdentity(symbols[_knownCount])); - } - } - - private static HashSet BuildSymbolLineIdentities(IEnumerable symbols, int expectedAdditionalLines = 0) - { - var identities = symbols is ICollection collection - ? new HashSet(collection.Count + EstimateSymbolListInitialCapacity(expectedAdditionalLines)) - : new HashSet(); - foreach (var symbol in symbols) - identities.Add(GetSymbolLineIdentity(symbol)); - return identities; - } - - private static SymbolLineIdentity GetSymbolLineIdentity(SymbolRecord symbol) - => new(symbol.FileId, symbol.Line, symbol.Kind, symbol.Name); - - private static bool HasSymbolLineIdentity( - HashSet identities, - long fileId, - int lineNumber, - string kind, - string name) - => identities.Contains(new SymbolLineIdentity(fileId, lineNumber, kind, name)); - - private static bool HasSymbolLineIdentity( - SymbolExtractionState extractionState, - List symbols, - long fileId, - int lineNumber, - string kind, - string name) - => extractionState.HasSymbolLineIdentity(symbols, new SymbolLineIdentity(fileId, lineNumber, kind, name)); - - private static void RecordSymbolLineIdentity(HashSet identities, SymbolRecord symbol) - => identities.Add(GetSymbolLineIdentity(symbol)); - - private readonly record struct SameLineSignatureKey(int Line, int StartLine, string Signature); private static bool TryAddRPacmanPackageLoaderSymbols( long fileId, string line, From 1f2d18ec9e1c65b5e2e6daa7b66cf3562bc6386c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 15:10:24 +0900 Subject: [PATCH 033/101] Separate symbol extraction contracts --- .../Symbols/SymbolExtractor.Contracts.cs | 85 +++++++++++++++++++ .../Indexer/Symbols/SymbolExtractor.cs | 79 ----------------- 2 files changed, 85 insertions(+), 79 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs new file mode 100644 index 000000000..a92c6b43a --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs @@ -0,0 +1,85 @@ +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + public const int DefaultContractVersion = 1; + public const int ExpandedLanguageContractVersion = 2; + public const int PythonContractVersion = 2; + public const int CSharpContractVersion = 5; + public const int DockerfileContractVersion = 2; + public const int MakefileContractVersion = 2; + public const int StyleAndXamlContractVersion = 2; + public const int XmlContractVersion = 3; + public const int FunctionalLanguageContractVersion = 3; + public const int DynamicLanguageContractVersion = 2; + public const int DynamicReferenceGraphContractVersion = 8; + public const int PrologReferenceGraphContractVersion = 7; + public const int SystemsLanguageContractVersion = 2; + public const int ScientificNativeGraphContractVersion = 4; + public const int RepositoryMetadataContractVersion = 2; + public const int ApplicationManifestContractVersion = 3; + + private static readonly string[] ExplicitReferenceGraphContractLanguages = + ["crystal", "groovy", "tcl", "prolog", "ambiguous_pl"]; + + private static readonly string[] AdditionalSymbolLanguages = + [ + "app_manifest", + "commonlisp", + "racket", + "vue", + "svelte", + "markdown", + "json", + "yaml", + "xml", + "razor", + "blazor", + "cshtml", + "solidity", + "solution", + "cuda", + "ambiguous_m", + "dependency_manifest", + "dependency_lock", + "jsonl", + "toml", + "gitignore", + "gitattributes", + "editorconfig", + "dockerignore", + "config", + ]; + + public static int GetContractVersion(string? lang) + { + return lang switch + { + null or "" => DefaultContractVersion, + "python" => PythonContractVersion, + "csharp" => CSharpContractVersion, + "dockerfile" => DockerfileContractVersion, + "makefile" => MakefileContractVersion, + "sass" or "stylus" => StyleAndXamlContractVersion, + "xml" => XmlContractVersion, + "clojure" or "erlang" or "ocaml" or "raku" => FunctionalLanguageContractVersion, + "crystal" or "groovy" or "tcl" => DynamicReferenceGraphContractVersion, + "prolog" or "ambiguous_pl" => PrologReferenceGraphContractVersion, + "ada" or "ambiguous_m" or "cython" or "d" or "julia" or "matlab" or "nim" or "objc" => ScientificNativeGraphContractVersion, + "config" or "dockerignore" or "editorconfig" or "gitattributes" or "gitignore" or "jsonl" or "toml" => RepositoryMetadataContractVersion, + "app_manifest" => ApplicationManifestContractVersion, + "cmake" or "dependency_lock" or "dependency_manifest" or "graphql" or "html" or "json" or "justfile" or "markdown" or "msbuild" or "solution" or "yaml" => ExpandedLanguageContractVersion, + _ => DefaultContractVersion, + }; + } + + internal static IReadOnlyList GetExplicitReferenceGraphContractLanguages() => + ExplicitReferenceGraphContractLanguages; + + internal static bool RequiresExplicitReferenceGraphContractStamp(string? lang) => + lang != null + && ExplicitReferenceGraphContractLanguages.Contains(lang, StringComparer.Ordinal); + + internal static int GetReferenceGraphContractVersion(string lang) => + GetContractVersion(lang); +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 95c873672..40c5cdf07 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -13,53 +13,6 @@ namespace CodeIndex.Indexer; /// public static partial class SymbolExtractor { - public const int DefaultContractVersion = 1; - public const int ExpandedLanguageContractVersion = 2; - public const int PythonContractVersion = 2; - public const int CSharpContractVersion = 5; - public const int DockerfileContractVersion = 2; - public const int MakefileContractVersion = 2; - public const int StyleAndXamlContractVersion = 2; - public const int XmlContractVersion = 3; - public const int FunctionalLanguageContractVersion = 3; - public const int DynamicLanguageContractVersion = 2; - public const int DynamicReferenceGraphContractVersion = 8; - public const int PrologReferenceGraphContractVersion = 7; - public const int SystemsLanguageContractVersion = 2; - public const int ScientificNativeGraphContractVersion = 4; - public const int RepositoryMetadataContractVersion = 2; - public const int ApplicationManifestContractVersion = 3; - private static readonly string[] ExplicitReferenceGraphContractLanguages = - ["crystal", "groovy", "tcl", "prolog", "ambiguous_pl"]; - private static readonly string[] AdditionalSymbolLanguages = - [ - "app_manifest", - "commonlisp", - "racket", - "vue", - "svelte", - "markdown", - "json", - "yaml", - "xml", - "razor", - "blazor", - "cshtml", - "solidity", - "solution", - "cuda", - "ambiguous_m", - "dependency_manifest", - "dependency_lock", - "jsonl", - "toml", - "gitignore", - "gitattributes", - "editorconfig", - "dockerignore", - "config", - ]; - private const int SymbolListInitialCapacityLineThreshold = 128; private const int SymbolListInitialCapacityMax = 1024; private const string JuliaIdentifierPattern = @"[\p{L}_]\w*"; @@ -85,38 +38,6 @@ private static int EstimateSymbolListInitialCapacity(int lineCount) return Math.Min(SymbolListInitialCapacityMax, Math.Max(16, lineCount / 8)); } - public static int GetContractVersion(string? lang) - { - return lang switch - { - null or "" => DefaultContractVersion, - "python" => PythonContractVersion, - "csharp" => CSharpContractVersion, - "dockerfile" => DockerfileContractVersion, - "makefile" => MakefileContractVersion, - "sass" or "stylus" => StyleAndXamlContractVersion, - "xml" => XmlContractVersion, - "clojure" or "erlang" or "ocaml" or "raku" => FunctionalLanguageContractVersion, - "crystal" or "groovy" or "tcl" => DynamicReferenceGraphContractVersion, - "prolog" or "ambiguous_pl" => PrologReferenceGraphContractVersion, - "ada" or "ambiguous_m" or "cython" or "d" or "julia" or "matlab" or "nim" or "objc" => ScientificNativeGraphContractVersion, - "config" or "dockerignore" or "editorconfig" or "gitattributes" or "gitignore" or "jsonl" or "toml" => RepositoryMetadataContractVersion, - "app_manifest" => ApplicationManifestContractVersion, - "cmake" or "dependency_lock" or "dependency_manifest" or "graphql" or "html" or "json" or "justfile" or "markdown" or "msbuild" or "solution" or "yaml" => ExpandedLanguageContractVersion, - _ => DefaultContractVersion, - }; - } - - internal static IReadOnlyList GetExplicitReferenceGraphContractLanguages() => - ExplicitReferenceGraphContractLanguages; - - internal static bool RequiresExplicitReferenceGraphContractStamp(string? lang) => - lang != null - && ExplicitReferenceGraphContractLanguages.Contains(lang, StringComparer.Ordinal); - - internal static int GetReferenceGraphContractVersion(string lang) => - GetContractVersion(lang); - private static IReadOnlyList BuildEnumDeclarationSnapshot(IReadOnlyList symbols, long? fileId = null) { List<(SymbolRecord Symbol, int OriginalIndex)>? candidates = null; From 42a23b96a1184f00933d2c91576ebc1a342a29b2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 15:14:56 +0900 Subject: [PATCH 034/101] Separate symbol extraction pattern catalog --- .../Symbols/SymbolExtractor.Patterns.cs | 2257 +++++++++++++++++ .../Indexer/Symbols/SymbolExtractor.cs | 2248 ---------------- 2 files changed, 2257 insertions(+), 2248 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs new file mode 100644 index 000000000..55ecd9e83 --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs @@ -0,0 +1,2257 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private const string JuliaIdentifierPattern = @"[\p{L}_]\w*"; + private const string JuliaQualifiedCallableIdentifierPattern = + JuliaIdentifierPattern + @"(?:\." + JuliaIdentifierPattern + @")*!?"; + + // THREAD-SAFETY: Symbol extraction is intentionally stateless per call. Shared Regex + // instances and lookup tables are initialized once by the CLR and must be treated as + // immutable after type initialization; per-file extraction state belongs in local + // variables or per-call collections, never in static mutable caches. + private const string SqlQualifiedIdentifierSegmentPattern = @"(?:\[(?:[^\]\r\n]|\]\])+\]|""[^""]+""|[\w$#]+)"; + private const string SqlQualifiedIdentifierPattern = + @"(?:" + SqlQualifiedIdentifierSegmentPattern + @")(?:\s*\.\s*(?:" + SqlQualifiedIdentifierSegmentPattern + @"))*"; + // Swift declarations commonly carry attributes on the same line as the declaration keyword. + // Allow those prefixes so annotated declarations still index by their actual names. + // Swift の宣言では、宣言キーワードと同じ行に属性が付くことが多い。 + // その前置きを許容し、注釈付き宣言でも実際の名前でインデックスできるようにする。 + private const string SwiftAttributeNamePattern = @"\w+(?:\.\w+)*"; + private const string SwiftAttributePattern = @"(?:@" + SwiftAttributeNamePattern + @"(?:\([^)]*\))?\s+)*"; + private static readonly Regex SwiftPropertyDeclarationRegex = new( + @"^\s*(?(?:@" + SwiftAttributeNamePattern + @"(?:\([^)]*\))?\s+)*)?(?:(?:public|private|internal|open|fileprivate|package)(?:\s*\(\s*set\s*\))?\s+)?(?:(?:lazy|weak|unowned|final|static|class|nonisolated)\s+)*(?:let|var)\s+(?`[^`]+`|\w+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex SwiftPropertyWrapperAttributeRegex = new( + @"@(?[A-Z]\w*(?:\.[A-Z]\w*)?)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex SwiftAccessorDeclarationRegex = new( + @"^\s*" + SwiftAttributePattern + @"(?:(?:mutating|nonmutating)\s+)?(?:@(?=willSet\b|didSet\b))?(?get|set|willSet|didSet)\b(?:\s*\([^)]*\))?(?:\s+(?:async|throws|rethrows))*\s*(?:\{|$)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly HashSet SwiftNonWrapperPropertyAttributes = new(StringComparer.Ordinal) + { + "IBOutlet", + "IBOutletCollection", + "IBInspectable", + "NSManaged", + "GKInspectable", + }; + // C++ return-type atoms need to accept both ordinary word tokens and `decltype(...)`. + // The decltype branch allows nested parentheses so modern forms such as + // `decltype(auto)`, `decltype((value))`, and `decltype(foo(x))` stay searchable. + // C++ の戻り値型トークンは通常の単語トークンに加え `decltype(...)` も受け入れる必要がある。 + // ここで括弧の入れ子を許容し、`decltype(auto)` / `decltype((value))` / + // `decltype(foo(x))` のような現代的な形も検索可能なままにする。 + private const string CppDecltypePattern = + @"decltype\s*\((?:(?>[^()]+)|\((?)|\)(?<-CppDecltypeDepth>))*(?(CppDecltypeDepth)(?!))\)"; + private const string CppFunctionReturnTypeAtomPattern = @"(?:" + CppDecltypePattern + @"|[\w:<>~]+)"; + // GCC/Clang/MSVC attribute specifiers can appear before the return type or between return + // type tokens. Keep them inside the function return-type matcher so common annotated C + // functions still surface in `symbols` / `search`. + // GCC/Clang/MSVC の attribute specifier は戻り値型の前や、戻り値型トークンの途中に現れる。 + // それらを戻り値型マッチャーに含めて、よくある注釈付き C 関数も `symbols` / `search` に出るようにする。 + private const string CAttributeSpecifierTokenPattern = + @"(?:\[\[[^\r\n]*?\]\]\s*|__attribute__\s*\(\((?:(?>[^()]+)|\((?)|\)(?<-CAttributeDepth>))*(?(CAttributeDepth)(?!))\)\)\s*|__declspec\s*\((?:(?>[^()]+)|\((?)|\)(?<-CAttributeDepth>))*(?(CAttributeDepth)(?!))\)\s*|_Noreturn\s+)"; + private const string CFunctionReturnTypePattern = + @"(?(?:(?:\w+[\s*]+)|" + CAttributeSpecifierTokenPattern + @")+)"; + private const string JavaUnicodeEscapePattern = @"\\u+[0-9A-Fa-f]{4}"; + private const string JavaIdentifierPattern = + @"(?:[\p{L}_$]|" + JavaUnicodeEscapePattern + @")(?:[\p{L}\p{Nd}_$]|" + JavaUnicodeEscapePattern + @")*"; + private const string JavaQualifiedIdentifierPattern = JavaIdentifierPattern + @"(?:\s*\.\s*" + JavaIdentifierPattern + @")*"; + private const string JavaMethodTypeParameterPattern = + @"(?:<(?:(?>[^<>]+)|<(?)|>(?<-JavaMethodTypeParameterDepth>))*(?(JavaMethodTypeParameterDepth)(?!))>\s+)?"; + private const string JavaReturnTypePattern = + @"(?:" + JavaQualifiedIdentifierPattern + @"(?:\s*<[^;=(){}]+>)?(?:\s*\[\s*\])*)"; + private const string KotlinIdentifierPattern = @"(?:\w+|`[^`\r\n]+`)"; + private const string CythonIdentifierPattern = @"[A-Za-z_]\w*"; + private const string CythonDottedIdentifierPattern = CythonIdentifierPattern + @"(?:\." + CythonIdentifierPattern + @")*"; + private const string CythonDeclarationPrefixPattern = @"(?:(?:public|readonly|api|inline|extern|nogil|const|volatile)\s+)*"; + private const string CythonNativeReturnTypePattern = + @"(?(?:(?:const|volatile|unsigned|signed|long|short|int|double|float|char|void|bint|object|Py_ssize_t|size_t|" + CythonDottedIdentifierPattern + @")(?:\s*[*&])?\s+)+)"; + private const string HdlIdentifierPattern = @"[A-Za-z_$][A-Za-z0-9_$]*"; + private const string HdlDeclaratorPrefixPattern = + @"(?:(?:signed|unsigned|automatic|static|wire|reg|logic|bit|byte|shortint|int|longint|integer|time|real|realtime|string|chandle|event)\s+|\[[^\]\r\n]+\]\s+)*"; + private const string VhdlIdentifierPattern = @"[A-Za-z][A-Za-z0-9_]*"; + private static readonly Regex HdlInlineParameterRegex = new( + @"\b(?:parameter|localparam)\s+(?:type\s+)?" + HdlDeclaratorPrefixPattern + @"(?" + HdlIdentifierPattern + @")\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private const string ShaderIdentifierPattern = @"[A-Za-z_]\w*"; + private const string ShaderTypePattern = @"[\w:<>,]+(?:\s*[*&])?(?:\s*\[[^\]\r\n]+\])?"; + private const string ShaderAttributePrefixPattern = @"(?:(?:layout\s*\([^)]*\)|\[[^\]\r\n]+\]|@\w+(?:\([^)]*\))?)\s*)*"; + private const string ShaderFunctionStartBlacklistPattern = @"^(?!\s*(?:if|for|while|switch|return|discard)\b)"; + private static readonly Regex RPacmanPackageLoaderStartRegex = new( + @"^\s*(?:(?:[\w.]+)::)?p_load\s*\(", + RegexOptions.Compiled); + private static readonly Regex RPacmanPackageLoaderArgumentRegex = new( + @"(?:^|,)\s*(?!(?:[A-Za-z.][\w.]*\s*=))(?:['""](?[^'""]+)['""]|(?[A-Za-z.][\w.]*))", + RegexOptions.Compiled); + private static readonly Regex CobolProgramIdLineRegex = new( + @"^\s*(?:IDENTIFICATION\s+DIVISION\.\s*)?(?:PROGRAM|CLASS)-ID\.\s*(?[A-Z0-9][A-Z0-9-]*)\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex CobolProcedureDivisionRegex = new( + @"^\s*PROCEDURE\s+DIVISION\.\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex CobolEntryRegex = new( + @"^\s*ENTRY\s+(?:""(?[^""]+)""|'(?[^']+)'|(?[A-Z0-9][A-Z0-9-]*))", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex CobolSectionHeaderRegex = new( + @"^\s{0,6}(?[A-Z0-9][A-Z0-9-]*)\s+SECTION\.\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex CobolParagraphHeaderRegex = new( + @"^\s{0,6}(?[A-Z0-9][A-Z0-9-]*)\.\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex CobolEndProgramRegex = new( + @"^\s*END\s+(?:PROGRAM|CLASS)(?:\s+(?[A-Z0-9][A-Z0-9-]*))?\.\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex PhpGroupUseRegex = new( + @"^\s*use\s+(?:(?function|const)\s+)?(?[\w\\]+\\)\{\s*(?[^{}]+?)\s*\}\s*;", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex PhpUseRegex = new( + @"^\s*use\s+(?:(?function|const)\s+)?(?[\w\\]+)(?:\s+as\s+(?\w+))?\s*;", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex PhpRequireIncludeRegex = new( + @"^\s*(?:require|include)(?:_once)?\s*\(?\s*(?:'(?[^']+)'|""(?[^""]+)"")\s*\)?\s*;", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex PhpPrefixedRequireIncludeRegex = new( + @"^\s*(?:require|include)(?:_once)?\s*\(?\s*(?(?:(?:__DIR__|__FILE__|dirname\s*\(\s*__FILE__\s*\))\s*\.\s*)+)\s*(?:'(?[^']+)'|""(?[^""]+)"")\s*\)?\s*;", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private const string CFunctionStartBlacklistPattern = @"^(?!\s*typedef\b)(?!\s*(?:if|else|for|while|switch|return|sizeof)\s*[\(\{;])"; + private const string CFunctionNameBlacklistPattern = @"(?!(?:int|void|char|short|long|float|double|signed|unsigned|bool|_Bool|size_t|ssize_t|intptr_t|uintptr_t|int8_t|int16_t|int32_t|int64_t|uint8_t|uint16_t|uint32_t|uint64_t)\b)"; + private const string CppFunctionStartBlacklistPattern = @"^(?!\s*typedef\b)(?!\s*(?:if|else|for|while|switch|return|sizeof|using|namespace)\s*[\(\{;<])"; + private const string CppTemplatePrefixPattern = @"(?:template\s*<[^>]*>\s*)*"; + private const string CppAttributePrefixPattern = @"(?:\[\[[^\r\n]*?\]\]\s*)*"; + private static readonly Regex CppFriendTypeDeclarationRegex = new( + @"\bfriend\s+(?class|struct|union|enum(?:\s+class)?)\s+(?(?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)\s*;", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppFriendFunctionDeclarationRegex = new( + @"\bfriend\s+(?!(?:class|struct|union|typename|enum)\b)(?[^;()]*?)\b(?(?:[A-Za-z_]\w*::)*(?:[A-Za-z_]\w*|operator\s*(?:new\[\]|delete\[\]|new|delete|\[\]|[^\s(]+)))(?:\s*<[^>]+>)?\s*\(", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex PartialModifierRegex = new(@"\bpartial\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex GoImportSpecRegex = new( + @"^(?(?:(?:[._]|[\p{L}_][\p{L}\p{Nd}_]*)\s+)?""(?:\\.|[^""\\])*"")(?:\s*;)?(?:\s*(?://.*|/\*.*\*/))?\s*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex GoTypeBlockSpecRegex = new( + @"^(?\w+)(?:\[[^\]]+\])?\s+(?:(?struct|interface)\b|.+)$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex GoInterfaceHeaderRegex = new( + @"^\s*(?:type\s+)?\w+(?:\[[^\]]+\])?\s+interface\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex GoInterfaceMethodRegex = new( + @"^\s*(?[A-Za-z_]\w*)\s*(?:\[[^\]\r\n]+\])?\s*\(", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex GoInterfaceEmbeddedTypeRegex = new( + @"^\s*(?:~\s*)?(?[A-Za-z_]\w*(?:\s*\.\s*[A-Za-z_]\w*)*)(?:\[[^\]\r\n]+\])?\s*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex GoStructEmbeddedTypeRegex = new( + @"^\s*\*?\s*(?[A-Za-z_]\w*(?:\s*\.\s*[A-Za-z_]\w*)*)(?:\[[^\]\r\n]+\])?\s*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly HashSet GoInterfaceEmbeddedTypeBlacklist = new(StringComparer.Ordinal) + { + "bool", + "byte", + "complex64", + "complex128", + "float32", + "float64", + "int", + "int8", + "int16", + "int32", + "int64", + "rune", + "string", + "uint", + "uint8", + "uint16", + "uint32", + "uint64", + "uintptr", + }; + private static readonly Regex GoValueBlockSpecRegex = new( + @"^(?[A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex GoLabelRegex = new( + @"^(?[A-Za-z_]\w*)\s*:\s*(?!=)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex RustUseStartRegex = new( + @"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?use\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private readonly record struct RustUseSymbolOccurrence(string Name, int Line, int Column); + private const string RustIdentifierPattern = @"(?:r#)?\w+"; + private static readonly Regex RustMultilineImplForRegex = new( + @"^\s*(?:unsafe\s+)?impl(?:<[^>]+>)?\s+.+?\s+for\s+(?" + RustIdentifierPattern + @")\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); + private static readonly Regex RustMultilineImplTypeRegex = new( + @"^\s*(?:unsafe\s+)?impl(?:<[^>]+>)?\s+(?" + RustIdentifierPattern + @")(?!\s+for\b)\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); + private static readonly Regex DockerfileNamedFromImageRegex = new( + @"^\s*FROM\s+(?:--platform=\S+\s+)?(?\S+)\s+(?:AS|as)\s+[A-Za-z0-9_.-]+", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex XamlClassRegex = new( + @"\bx:Class\s*=\s*[""'](?[^""']+)[""']", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex XamlDataTypeRegex = new( + @"\bx:DataType\s*=\s*[""'](?[^""']+)[""']", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex XamlTypeArgumentsRegex = new( + @"\bx:TypeArguments\s*=\s*[""'](?[^""']+)[""']", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex XamlTargetTypeRegex = new( + @"\bTargetType\s*=\s*[""'](?[^""']+)[""']", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex XamlTypeObjectElementRegex = new( + @"<\s*x:Type(?:Extension)?\b[^>]*\bTypeName\s*=\s*[""'](?[^""']+)[""']", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); + private static readonly Regex XamlTypePropertyElementRegex = new( + @"<\s*(?x:Type(?:Extension)?)\.TypeName\b[^>]*>(?.*?)\.TypeName\s*>", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); + private static readonly Regex XamlNameRegex = new( + @"\bx:Name\s*=\s*[""'](?[^""']+)[""']", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex XamlKeyRegex = new( + @"\bx:Key\s*=\s*[""'](?[^""']+)[""']", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly string[] XamlEventAttributeNames = + [ + "Clicked", + "Tapped", + "Loaded", + "Unloaded", + "SelectionChanged", + "TextChanged", + "CheckedChanged", + "Unchecked", + "SelectedIndexChanged", + "PointerPressed", + "PointerReleased", + "PointerEntered", + "PointerExited", + "Drop", + "DragOver", + "Completed", + "Appearing", + "Disappearing", + "NavigatedTo", + "NavigatedFrom", + "SizeChanged", + ]; + private static readonly Regex XamlEventHandlerRegex = new( + @"\b(?:" + string.Join("|", XamlEventAttributeNames) + @")\s*=\s*[""'](?[^""']+)[""']", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex XamlBindingRegex = new( + @"\{(?Binding|x:Bind|TemplateBinding|CompiledBinding|ReflectionBinding)\b(?(?:[^{}]|{[^{}]*})*)\}", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex XamlBindingPathPropertyElementRegex = new( + @"<\s*Binding\.Path\b[^>]*>(?.*?)", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); + private static readonly Regex XamlBindingElementNamePropertyElementRegex = new( + @"<\s*Binding\.ElementName\b[^>]*>(?.*?)", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); + private static readonly Regex XamlReferenceNamePropertyElementRegex = new( + @"<\s*(?x:Reference(?:Extension)?)\.Name\b[^>]*>(?.*?)\.Name\s*>", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); + private static readonly string[] XamlResourceReferenceMarkupPrefixes = + [ + "{StaticResource", + "{StaticResourceExtension", + "{DynamicResource", + "{DynamicResourceExtension", + ]; + private static readonly string[] XamlReferenceMarkupPrefixes = + [ + "{x:ReferenceExtension", + "{x:Reference", + ]; + private static readonly string[] XamlReferenceObjectElementPrefixes = + [ + "\w+)\s*\(\s*(?[^)]+?)\s*\)(?:\s*<[^>]+>)?", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex SqlDefinerRegex = new( + @"\bDEFINER\s*=\s*(?:'(?[^'\r\n]+)'|`(?[^`\r\n]+)`|(?[^\s@'`]+))\s*@\s*(?:'(?[^'\r\n]+)'|`(?[^`\r\n]+)`|(?[^\s'`]+))", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex SqlDefinerMarkerRegex = new( + @"\bDEFINER\s*=", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex SqlCteDefinitionRegex = new( + $@"(?{SqlQualifiedIdentifierSegmentPattern})(?:\s*\([^)]*\))?\s+AS\s*\(", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex SqlAlterTableAddGeneratedColumnRegex = new( + $@"(?{SqlQualifiedIdentifierPattern})\s+ADD(?:\s+COLUMN)?\s+(?!CONSTRAINT\b)(?{SqlQualifiedIdentifierSegmentPattern})\b(?=[^;]*?\b(?:GENERATED\s+(?:ALWAYS\s+)?AS|AS\s*\(|DEFAULT\s+NEXT\s+VALUE\s+FOR)\b)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex SqlCreateTableBodyRegex = new( + $@"(?{SqlQualifiedIdentifierPattern})\s*\((?[\s\S]*?)\)\s*;", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex SqlGeneratedColumnDefinitionMarkerRegex = new( + @"\b(?:GENERATED\s+(?:ALWAYS\s+)?AS|AS\s*\(|DEFAULT\s+NEXT\s+VALUE\s+FOR)\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex SqlColumnDefinitionNameRegex = new( + $@"^\s*(?{SqlQualifiedIdentifierSegmentPattern})\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex SqlReturnsTableMarkerRegex = new( + @"\bRETURNS\s+TABLE\s*\(", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex SqlOutParameterRegex = new( + @"(?:^|,)\s*(?:OUT|INOUT)\s+(?(?:\[(?:[^\]\r\n]|\]\])+\]|`[^`\r\n]+`|""(?:""""|[^""\r\n])+""|[_\p{L}][\p{L}\p{Mn}\p{Mc}\p{Nd}\p{Pc}$]*))\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex SqlCreateRoutineHeaderRegex = new( + @"^\s*CREATE\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?(?:DEFINER\s*=\s*(?:'[^'\r\n]+'|`[^`\r\n]+`|[^\s@'`]+)\s*@\s*(?:'[^'\r\n]+'|`[^`\r\n]+`|[^\s'`]+)\s+)?(?:PROCEDURE|PROC|FUNCTION)\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + // Optional TypeScript generic type-argument token that may sit between an HOC call + // name and its `(`. Consumed only by the TypeScript HOC-binding row — the JavaScript + // row intentionally does NOT accept this token, because JavaScript has no generic + // syntax and a bare `memo < Props > (Component)` is a chained comparison / call + // expression that must NOT produce a phantom HOC binding. The expression balances up + // to three levels of nested angle brackets (`>>`) + // and allows parenthesised segments (`<(props: Props) => JSX.Element>`) inside a + // generic argument, which covers the function-type / conditional-type shapes real TS + // HOC call sites use. Each parenthesised segment itself balances one level of nested + // parens — `\((?:[^()]|\([^()]*\))*\)` — so callback-prop shapes such as + // `<(props: { onClick: (x: number) => void }) => JSX.Element>` still match; the + // inner `\([^()]*\)` branch is disjoint from `[^()]` (first char `(` vs not `(`), so + // the paren balancer stays ReDoS-safe. The outer alternation treats `=>` as a single + // two-character token via `=>?` (greedy `?` so the `>` is consumed when present) + // instead of letting the `>` leak out and close the outer `<...>` early, which would + // otherwise drop function-type generic arguments. Each alternation branch starts + // with a distinct character class — `[^<>()=]` (plain), `=>?` (=-rooted), `\(` + // (paren), `<` (nested angle) — so the engine never has overlapping choices at a + // single input position, which rules out catastrophic backtracking on long or + // malformed inputs. Four or more levels of angle-bracket nesting, or two or more + // levels of paren nesting inside a single generic argument, are vanishingly rare in + // real HOC signatures and would require a full bracket walker to stay ReDoS-safe. + // Closes #240. + // HOC 呼び出し名と `(` の間に入りうる、TypeScript の generic 型引数トークン(オプション)。 + // TypeScript 行の HOC 束縛だけがこのトークンを受け付け、JavaScript 行は意図的に + // 受け付けない。JavaScript には generic 構文が無く、`memo < Props > (Component)` は + // 比較・呼び出しの連鎖式であって、ここから phantom な HOC 束縛を生やしてはいけないため。 + // 式は 3 段までのネストした山括弧(`>>`)と、 + // generic 引数内の丸括弧付きセグメント(`<(props: Props) => JSX.Element>`)を許容する + // ので、実在する TS HOC 呼び出しで使われる関数型・条件型形状までカバーできる。各 + // 丸括弧セグメント自身も 1 段のネスト丸括弧を許容する(`\((?:[^()]|\([^()]*\))*\)`) + // ため、callback-prop 形 + // (`<(props: { onClick: (x: number) => void }) => JSX.Element>`)もマッチする。 + // 内側の `\([^()]*\)` 分岐は `[^()]` と先頭文字が互いに素(`(` vs それ以外)なので、 + // 丸括弧バランサーも ReDoS 安全に保たれる。外側 alternation は `=>` を `=>?` の 2 + // 文字トークンとして 1 度に消費する(greedy の `?` によって後続の `>` があれば必ず + // 消費)。こうしないと `=>` の `>` が外側の山括弧閉じとして早期マッチしてしまい、 + // 関数型 generic 引数全体が落ちる。各 alternation 分岐は先頭文字クラスが互いに素 + // (`[^<>()=]`(平文字)、`=>?`(=-root)、`\(`(丸括弧)、`<`(ネスト山括弧))で、 + // 同一入力位置で選択が重ならないため、長い入力や不正な入力に対しても catastrophic + // backtracking が発生しない。4 段以上の山括弧ネストや、単一 generic 引数内での 2 段 + // 以上の丸括弧ネストは実 HOC シグネチャでは極めて稀で、ReDoS 安全に受理するには完全 + // な bracket walker が必要になるため、それぞれ 3 段・1 段で打ち切る。#240 解消。 + private const string TypeScriptOptionalHocTypeArgsPattern = @"(?:<(?:[^<>()=]|=>?|\((?:[^()]|\([^()]*\))*\)|<(?:[^<>()=]|=>?|\((?:[^()]|\([^()]*\))*\)|<(?:[^<>()=]|=>?|\((?:[^()]|\([^()]*\))*\))*>)*>)*>\s*)?"; + // Optional TypeScript generic parameter list that may follow a `type` alias name. + // Allow defaulted parameters (`T = string`) in addition to constraints and nested + // type expressions so generic aliases stay searchable. + // `type` エイリアス名の後に続く TypeScript の generic parameter list(オプション)。 + // `T = string` のような default 付き parameter に加え、constraint や入れ子の + // type expression も許容して generic alias を検索対象に残す。 + private const string TypeScriptOptionalTypeParameterListPattern = @"(?:<(?:[^<>()=]|=(?!>)|=>?|\((?:[^()]|\([^()]*\))*\)|<(?:[^<>()=]|=(?!>)|=>?|\((?:[^()]|\([^()]*\))*\)|<(?:[^<>()=]|=(?!>)|=>?|\((?:[^()]|\([^()]*\))*\))*>)*>)*>\s*)?"; + + private enum BodyStyle + { + None, + Brace, + Indent, + RubyEnd, + FortranEnd, + ElixirEnd, + ScientificEnd, + JuliaShortFunction, + VisualBasicEnd, + PascalEnd, + AdaEnd, + SmalltalkMethod, + SqlProcBody, + } + + private sealed record SymbolPattern( + string Kind, + Regex Regex, + BodyStyle BodyStyle, + string? VisibilityGroup = null, + string? ReturnTypeGroup = null); + + private enum CssContextKind + { + GroupingAtRule, + QualifiedRule, + } + + private enum JavaScriptLexMode + { + Code, + SingleQuote, + DoubleQuote, + TemplateString, + BlockComment, + } + + private enum JavaScriptPrevTokenKind + { + None, + Identifier, + Number, + CloseParen, + CloseBracket, + CloseBrace, + Other, + } + + private enum CSharpLexMode + { + Code, + String, + Char, + VerbatimString, + RawString, + BlockComment, + } + + private enum JavaScriptScopeKind + { + Other, + Block, + Function, + StaticBlock, + Class, + Namespace, + Object, + } + + [Flags] + private enum JavaScriptScopePrivacyFlags + { + None = 0, + FunctionLike = 1, + Block = 2, + Namespace = 4, + } + + private readonly record struct JavaScriptLexState( + JavaScriptLexMode Mode = JavaScriptLexMode.Code, + bool EscapeNext = false, + JavaScriptPrevTokenKind PreviousTokenKind = JavaScriptPrevTokenKind.None, + bool PreviousIdentifierAllowsRegex = false, + bool ExpectingControlFlowOpenParen = false, + int ControlFlowParenDepth = 0, + bool RegexAllowedAfterControlFlowParen = false); + + private readonly record struct JavaScriptLexedLine( + string SanitizedLine, + JavaScriptLexState EndState); + + private readonly record struct CSharpLexState( + CSharpLexMode Mode = CSharpLexMode.Code, + bool EscapeNext = false, + int RawDelimiterLength = 0, + // Interpolation tracking for $@"..." / @$"..." / $"""...""" / $$"""...""" etc. + // IsInterpolated / InterpolationDollarCount describe the CURRENT string mode + // (only meaningful while Mode is a string mode). Return* fields preserve the + // outer interpolated string's info while we are inside an interpolation hole + // (Mode = Code with InterpolationBraceDepth > 0). InterpolationParent keeps + // an immutable stack when another interpolated string starts inside that hole. + // 補間 verbatim / raw 文字列のホール追跡。IsInterpolated / InterpolationDollarCount は + // 現在のモード(string 系モードのときだけ意味を持つ)を表し、Return* は + // ホール内(Mode = Code かつ InterpolationBraceDepth > 0)の間、外側の + // 補間文字列情報を退避する。ホール内で別の補間文字列が始まった場合は + // InterpolationParent の immutable stack に外側の状態を退避する。 + bool IsInterpolated = false, + int InterpolationDollarCount = 0, + int InterpolationBraceDepth = 0, + CSharpLexMode InterpolationReturnMode = CSharpLexMode.Code, + int InterpolationReturnRawDelimiterLength = 0, + int InterpolationReturnDollarCount = 0, + CSharpInterpolationFrame? InterpolationParent = null); + + private sealed record CSharpInterpolationFrame(CSharpLexState State); + + private readonly record struct CSharpLexedLine( + string SanitizedLine, + CSharpLexState EndState); + + private readonly record struct CSharpPropertyMatchCandidate( + string MatchLine, + int LastConsumedLineIndex, + int SignatureLastLineIndex, + int? SignatureLastLineExclusiveEndColumn = null, + int? ExpressionBodyEndLineIndex = null, + int? ExpressionBodyEndLineExclusiveEndColumn = null); + + private readonly record struct FortranContinuationMatchCandidate( + string MatchLine, + int LastConsumedLineIndex); + + private enum CSharpAccessorProbeStatus + { + Pending, + Found, + Rejected + } + + + private readonly record struct JavaScriptClassScanTarget( + int StartIndex, + int StartColumn, + int ScanStartIndex, + int ScanEndExclusive, + int FirstLineScanOffset, + string ContainerKind, + string ContainerName, + bool IsExported = false); + + private static readonly HashSet TypeScriptBareMethodModifiers = + [ + "public", "private", "protected", "static", "readonly", "abstract", "override", "async", "get", "set" + ]; + + // Enum declaration — visibility optional; modifier order is free. Accepts `file` (file-scoped + // enum) and `new` (member-hiding nested enum in a derived type) as non-visibility modifiers. + // Closes #353. + // enum 宣言 — visibility は任意で、修飾子の順序は自由。非 visibility 修飾子として `file` + // (ファイルスコープ enum)と `new`(派生型でのネスト enum 隠蔽)を受け付ける。Closes #353. + private static readonly Regex CSharpEnumDeclarationRegex = new($@"^\s*(?:(?public|private|protected\s+internal|private\s+protected|protected|internal)\s+|(?:file|new)\s+)*enum\s+(?{CSharpIdentifierPattern})", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CSharpEnumMemberRegex = new($@"^\s*(?{CSharpIdentifierPattern})\s*(?:=\s*(?:-?\d|0x|{CSharpIdentifierPattern}(?:\s*\|\s*{CSharpIdentifierPattern})*)[^""']*)?,?\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CSharpEnumMemberNameRegex = new($@"^\s*(?{CSharpIdentifierPattern})\b", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex JavaCompactConstructorRegex = new( + @"^\s*(?:(?public|private|protected)\s+)?(?\w+)\s*(?=\{|$)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex PhpPropertyHookAccessorRegex = new( + @"^\s*(?get|set)\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex DartClassDeclarationRegex = new( + @"^\s*(?:(?:abstract|base|final|interface|sealed)\s+)*(?:mixin\s+)?class\s+\w+", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex DartBareConstConstructorRegex = new( + @"^\s*const\s+(?[A-Z_]\w*(?:\.\w+)?)\s*\(", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CSharpSameLinePropertyStatementStartRegex = new( + $@"^\s*(?:(?:{CSharpVisibilityPattern})\s+|(?:static|virtual|override|abstract|sealed|new|required|partial|readonly|unsafe|extern|ref(?:\s+readonly)?)\s+)*(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?:ref(?:\s+readonly)?)\s+)?(?:{CSharpTypePattern})\s+(?:{CSharpExplicitInterfaceQualifierPattern}\.)?{CSharpIdentifierPattern}\s*(?:\{{|=>\s*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CSharpSameLineEventStatementStartRegex = new( + $@"^\s*(?:(?:{CSharpVisibilityPattern})\s+|(?:static|unsafe|extern|virtual|override|abstract|sealed|new|partial|file)\s+)*event\s+(?:{CSharpTypePattern})\s+{CSharpIdentifierPattern}\s*(?:[;=]|\{{)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CSharpSameLineDelegateStatementStartRegex = new( + $@"^\s*(?:(?:{CSharpVisibilityPattern})\s+|(?:static|unsafe|file|new)\s+)*delegate\s+(?:{CSharpTypePattern})\s+{CSharpIdentifierPattern}\s*[\(<]", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CSharpSameLineEventOrDelegateStatementStartRegex = new( + $@"^\s*(?:(?:{CSharpVisibilityPattern})\s+|(?:static|unsafe|extern|virtual|override|abstract|sealed|new|partial|file)\s+)*(?:event\s+(?:{CSharpTypePattern})\s+{CSharpIdentifierPattern}\s*(?:[;=]|\{{)|delegate\s+(?:{CSharpTypePattern})\s+{CSharpIdentifierPattern}\s*[\(<])", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly HashSet JavaScriptTypeScriptControlFlowHeaderKeywords = + [ + "if", "for", "while", "switch", "catch", "with" + ]; + + private readonly record struct JavaScriptTypeScriptMethodHeaderInfo( + string Name, + int BodyStartColumn, + string? Visibility = null, + int? GenericStartColumn = null, + int? GenericEndColumn = null, + int? ReturnTypeStartColumn = null, + int? ReturnTypeEndColumn = null, + int? HeaderEndColumn = null, + bool HasBody = true, + bool IsAsync = false, + bool IsGenerator = false, + // For class-field arrow properties with an expression body (`handleClick = () => 42;`), + // this marks the inclusive column of the last expression char (before `;`) in the + // accumulated sanitized header. Null means brace body or no expression body was detected. + // クラスフィールド矢印プロパティが式本体を持つ場合 (`handleClick = () => 42;`)、 + // 終端記号 `;` の直前にある式末尾の inclusive 列位置。null は block body か式本体非検出。 + int? ExpressionBodyEndColumn = null); + + private readonly record struct JavaScriptTypeScriptMethodHeaderCapture( + string SourceHeader, + JavaScriptTypeScriptMethodHeaderInfo HeaderInfo, + int HeaderEndLineIndex, + int HeaderEndColumn, + int BodyStartLineIndex, + int BodyStartColumn, + // For expression-body arrow fields, these are the source line/col of the last + // expression char (`;` の直前). Null for brace-body arrow fields. + // 式本体矢印 field の場合の式末尾 source 位置 (終端 `;` の直前)。block body は null。 + int? BodyEndLineIndex = null, + int? BodyEndColumn = null); + + private struct JavaScriptTypeScriptFunctionHeaderState + { + public bool Active; + public bool SawParameterList; + public bool InReturnType; + public int ParenDepth; + public int BracketDepth; + public int BraceDepth; + public int ReturnParenDepth; + public int ReturnBracketDepth; + public int ReturnAngleDepth; + public int ReturnBraceDepth; + public bool ReturnSawToken; + public string? PreviousReturnToken; + } + + private enum JavaScriptTypeScriptMethodHeaderParseStatus + { + IncompleteOrInvalid = 0, + Parsed = 1, + DeclarationOnly = 2, + } + + private enum JavaScriptTypeScriptFunctionHeaderConsumeResult + { + NotActive = 0, + Consumed = 1, + BodyStart = 2, + } + + private const string JavaScriptTypeScriptIdentifierPattern = @"[$\p{L}_][$\p{L}\p{Nd}_]*"; + + private static readonly Regex JavaScriptTypeScriptAnonymousDefaultExportRegex = new( + @"^\s*(?export)\s+default\b", + RegexOptions.Compiled); + + private static readonly Regex JavaScriptTypeScriptClassExpressionBindingRegex = new( + $@"^\s*(?:(?export)\s+)?(?:(?const|let|var)\s+(?{JavaScriptTypeScriptIdentifierPattern})|exports\.(?{JavaScriptTypeScriptIdentifierPattern})|module\.exports\.(?{JavaScriptTypeScriptIdentifierPattern})|(?module\.exports))\s*=", + RegexOptions.Compiled); + + private static readonly Regex TypeScriptExportEqualsRegex = new( + @"^\s*export\s*=", + RegexOptions.Compiled); + + // Matches the binding portion of object-literal declarations: LHS identifier plus the `=` + // assignment. The opening `{` is intentionally NOT required on the same line so multi-line + // forms like `const obj =\n{\n ... }` are still detected. Callers locate the `{` via + // TryFindJavaScriptTypeScriptObjectLiteralOpenBrace (lex-state aware), then hand the resulting + // (lineOfBrace, columnOfBrace) to ResolveRange(BodyStyle.Brace). Recognizes + // const/let/var/export plus CommonJS module.exports / exports.NAME assignments. + // オブジェクトリテラル宣言の binding 部分(LHS 識別子と `=`)に一致させる。右辺の `{` を同一行に + // 要求しないのは、`const obj =\n{\n ... }` のような複数行スタイルも拾うため。`{` の位置は + // TryFindJavaScriptTypeScriptObjectLiteralOpenBrace が lex 状態を引き継ぎつつ別途走査し、 + // 見つけた (lineOfBrace, columnOfBrace) を ResolveRange(BodyStyle.Brace) に渡す。const/let/var/export + // に加え、CommonJS の module.exports / exports.NAME 代入経路にも対応する。 + private static readonly Regex JavaScriptTypeScriptObjectLiteralBindingRegex = new( + $@"^\s*(?:(?export)\s+)?(?:(?const|let|var)\s+(?{JavaScriptTypeScriptIdentifierPattern})|exports\.(?{JavaScriptTypeScriptIdentifierPattern})|module\.exports\.(?{JavaScriptTypeScriptIdentifierPattern})|(?module\.exports))(?:\s*:\s*[^=]+?)?\s*=\s*", + RegexOptions.Compiled); + + // Matches `export default` at start of line. `export default { ... }` is an anonymous object + // that becomes the module's default export; its method-shorthand members are attached to a + // virtual "default" container. Uses the same lex-aware `{` scan as the binding regex. + // 行頭の `export default` に一致。`export default { ... }` は無名オブジェクトでモジュールの + // 既定エクスポートになり、そのメソッド省略記法のメンバは仮想コンテナ "default" に紐付ける。 + // 後続の `{` の位置は binding 用と同じ lex-aware 走査で特定する。 + private static readonly Regex JavaScriptTypeScriptExportDefaultObjectLiteralRegex = new( + @"^\s*export\s+default\s*", + RegexOptions.Compiled); + + private static readonly Regex JavaScriptTypeScriptStarReExportRegex = new( + $@"^\s*export\s*(?:type\s+)?\*(?:\s*as\s+(?{JavaScriptTypeScriptIdentifierPattern}))?\s*from\s*(?['""][^'""]+['""])(?:\s+(?:with|assert)\s+\{{[^}}]*\}})?\s*;?\s*$", + RegexOptions.Compiled); + + private static readonly Regex JavaScriptTypeScriptNamedReExportRegex = new( + @"^\s*export\s*(?:type\s+)?\{\s*(?[^}]+)\s*\}\s*from\s*(?['""][^'""]+['""])(?:\s+(?:with|assert)\s+\{[^}]*\})?\s*;?\s*$", + RegexOptions.Compiled); + + private static readonly Regex JavaScriptTypeScriptDestructuredNamedExportRegex = new( + @"^\s*export\s+(?:const|let|var)\s*\{", + RegexOptions.Compiled); + + private static readonly Regex JavaScriptTypeScriptExportedVariableDeclarationRegex = new( + @"^\s*export\s+(?:declare\s+)?(?:const|let|var)\b", + RegexOptions.Compiled); + + private static readonly Regex JavaScriptTypeScriptCommonJsNamedExportAssignmentRegex = new( + $@"^\s*(?:module\.exports|exports)(?:\.(?{JavaScriptTypeScriptIdentifierPattern})|\[\s*(?:['""](?[^'""]*)['""]|(?\d+(?:\.\d+)?))\s*\])(?:\s*:\s*[^=]+?)?\s*(?])=(?![=>])\s*(?.*)$", + RegexOptions.Compiled); + + private static readonly Regex JavaScriptTypeScriptCommonJsDefaultExportAssignmentRegex = new( + @"^\s*module\.exports(?:\s*:\s*[^=]+?)?\s*(?])=(?![=>])\s*(?.*)$", + RegexOptions.Compiled); + + private static readonly Regex JavaScriptTypeScriptQualifiedAssignmentRegex = new( + $@"^\s*(?[A-Z][\w$]*(?:\.[\w$]+)+)\s*(?])=(?![=>])\s*(?.*)$", + RegexOptions.Compiled); + + private static readonly Regex JavaScriptTypeScriptArrowAssignmentValueRegex = new( + $@"^(?:async\s+)?(?:\([^)]*\)|{JavaScriptTypeScriptIdentifierPattern})\s*=>", + RegexOptions.Compiled); + + private static readonly Regex SvelteReactivePropertyRegex = new( + @"^\s*\$:\s*(?\w+)\s*=", + RegexOptions.Compiled); + + private const string VbVisibilityPattern = @"(?:Public|Private|Protected|Friend)(?:\s+(?:Protected|Friend))?"; + private const string VbTypeModifierPattern = @"(?:Partial|MustInherit|NotInheritable)"; + private const string VbMemberModifierPattern = @"(?:Shared|Overrides|Overridable|NotOverridable|MustOverride|Overloads|Shadows|Async|Iterator|Partial|Declare|PtrSafe|Auto|Ansi|Unicode)"; + private const string VbOperatorModifierPattern = @"(?:Shared|Overrides|Overridable|MustOverride|Overloads|Shadows|Async|Partial|Widening|Narrowing)"; + private const string VbPropertyModifierPattern = @"(?:Shared|Overrides|Overridable|NotOverridable|MustOverride|Overloads|Shadows|Default|ReadOnly|WriteOnly)"; + private const string VbEventModifierPattern = @"(?:Shared|Overloads|Shadows|Custom)"; + private const string VbIdentifierPattern = @"(?:\[[^\]\r\n]+\]|\w+)"; + + private static readonly Dictionary> PatternCache = new() + { + ["python"] = + [ + new("function", new Regex(@"^\s*(?:async\s+)?def\s+(?\w+)\s*(?:\[[^\]]*\])?\s*(?:\(|\[)", RegexOptions.Compiled), BodyStyle.Indent), + new("lambda", new Regex(@"^\s*(?\w+)\s*=\s*lambda\b", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*class\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Indent), + new("class", new Regex(@"^\s*(?\w+)\s*=\s*(?:(?:typing|collections)\.)?(?:NamedTuple|namedtuple)\s*\(", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*(?\w+)\s*=\s*(?:dataclasses\.)?make_dataclass\s*\(", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*(?\w+)\s*=\s*(?:(?:typing|typing_extensions)\.)?TypedDict\s*\(", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*(?\w+)\s*=\s*(?:enum\.)?(?:Enum|IntEnum|Flag|IntFlag|StrEnum)\s*\(", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*(?\w+)\s*=\s*(?:pydantic\.)?create_model\s*\(", RegexOptions.Compiled), BodyStyle.None), + new("typealias", new Regex(@"^\s*type\s+(?\w+)\s*(?:\[[^\]]*\])?\s*=", RegexOptions.Compiled), BodyStyle.None), + new("typealias", new Regex(@"^\s*(?\w+)\s*:\s*(?:(?:typing|typing_extensions)\.)?TypeAlias\s*=", RegexOptions.Compiled), BodyStyle.None), + new("typealias", new Regex(@"^\s*(?\w+)\s*=\s*(?:(?:typing|typing_extensions)\.)?NewType\s*\(", RegexOptions.Compiled), BodyStyle.None), + new("type_parameter", new Regex(@"^\s*(?\w+)\s*=\s*(?:(?:typing|typing_extensions)\.)?(?:TypeVar|ParamSpec|TypeVarTuple)\s*\(", RegexOptions.Compiled), BodyStyle.None), + new("property", new Regex(@"^\s*(?\w+)\s*:\s*(?:(?:typing|typing_extensions)\.)?Final(?:\[[^\]]+\])?\s*=", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(@"^\s*(?:from\s+(?(?:\.+[\w.]*|[\w.]+))\s+import\b|import\s+(?[\w.]+))", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(@"^\s*(?:[_\p{L}]\w*\s*=\s*)?(?:importlib\.import_module|importlib\.util\.find_spec|__import__)\s*\(\s*['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.None), + ], + ["cython"] = + [ + new("import", new Regex(@"^\s*from\s+(?" + CythonDottedIdentifierPattern + @")\s+cimport\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*cimport\s+(?" + CythonDottedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*import\s+(?" + CythonDottedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*include\s+(?:'(?[^']+)'|""(?[^""]+)"")", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*cdef\s+extern\s+from\s+(?:'(?[^']+)'|""(?[^""]+)"")", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex(@"^\s*cdef\s+class\s+(?" + CythonIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), + new("class", new Regex(@"^\s*class\s+(?" + CythonIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), + new("struct", new Regex(@"^\s*(?:ctypedef\s+)?cdef\s+struct\s+(?" + CythonIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), + new("enum", new Regex(@"^\s*(?:ctypedef\s+)?cdef\s+enum\s+(?" + CythonIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), + new("typealias", new Regex(@"^\s*ctypedef\s+(?!(?:struct|enum|union)\b)(?.+?)\s+(?" + CythonIdentifierPattern + @")\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), + new("function", new Regex(@"^\s*(?:cdef|cpdef)\s+(?!(?:class|struct|enum|extern)\b)" + CythonDeclarationPrefixPattern + @"(?:(?[\w.<>*,\[\]\s]+?)\s+)?(?" + CythonIdentifierPattern + @")\s*(?:\[[^\]]+\]\s*)?\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent, ReturnTypeGroup: "returnType"), + new("function", new Regex(@"^\s*(?:async\s+)?def\s+(?" + CythonIdentifierPattern + @")\s*(?:\[[^\]]*\])?\s*(?:\(|\[)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), + new("function", new Regex(@"^\s*" + CythonNativeReturnTypePattern + @"(?" + CythonIdentifierPattern + @")\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), + new("property", new Regex(@"^\s*cdef\s+(?!(?:class|struct|enum|extern)\b)" + CythonDeclarationPrefixPattern + @"(?.+?)\s+(?" + CythonIdentifierPattern + @")\s*(?::|=|$)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), + ], + ["cobol"] = + [ + // COBOL is organized around program IDs rather than brace-scoped members. + // Keep the extraction deliberately small and conservative: one symbol per program. + // COBOL は brace ではなく program ID 単位で構成されるため、抽出は保守的に + // program ひとつにつき 1 symbol に絞る。 + new("class", new Regex(@"^\s*(?:IDENTIFICATION\s+DIVISION\.\s*)?(?:PROGRAM|CLASS)-ID\.\s*(?[A-Z0-9][A-Z0-9-]*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*METHOD-ID\.\s*(?:""(?[^""]+)""|'(?[^']+)'|(?[A-Z0-9][A-Z0-9-]*))", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["javascript"] = + [ + // Include optional `*` between `function` and name for generator functions (e.g. `function* gen()`, `async function* asyncGen()`) + // `function` と名前の間に任意の `*` を許容し、ジェネレータ関数 (`function* gen()`, `async function* asyncGen()`) にも対応 + new("function", new Regex(@"^\s*(?export)\s+(?default)\s+(?async\s+)?function(?:\s+|\s*(?\*)\s*)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("function", new Regex(@"^\s*(?:(?export)\s+(?:default\s+)?)?(?async\s+)?function(?:\s+|\s*(?\*)\s*)(?\w+)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("lambda", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("lambda", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?\w+)\s*(?::\s*.+?)?\s*=\s*(?async\s+)?function\s*(?\*)?\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("function", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?\w+)\s*(?::\s*.+?)?\s*=\s*(?async\s+)?function(?:\s+|\s*(?\*)\s*)\w+\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // HOC-wrapped / call-result component bindings such as + // `const Wrapped = React.memo(...)`, `const Box = React.forwardRef(...)`, + // `const Connected = connect(...)(Component)`, `const Styled = styled.div`...``, + // or `const WithAuth = withAuthentication(Home)`. The arrow pattern above does + // not fire for these because the RHS is a call expression, tagged template, + // or plain identifier — there is no `=>` right after the `=`. The RHS is + // restricted to a known set of HOC call shapes — `React.memo(` / + // `React.forwardRef(` / `React.lazy(`, `styled.`/`styled(`/`styled``, + // bare `connect(`/`memo(`/`forwardRef(`/`lazy(`/`observer(`, and + // `with(`. Styled factory captures (`const F = styled.div;`) and + // plain styled calls (`const F = styled(Component);`) are NOT real component + // bindings — they produce a factory / a styled-component-of-component but do + // not declare a rendered component here — so an additional post-match gate + // rejects them unless the source line carries a tagged-template backtick. + // The gate checks the raw (unmasked) line because + // StructuralLineMasker.MaskJsTsTemplateLiteralContents masks template + // delimiters to space, which would otherwise make the same regex accept the + // non-template forms too. Unlike the TypeScript row below, the JavaScript + // row deliberately does NOT accept an optional `` token + // between the HOC call name and its `(` — JavaScript has no generic + // syntax and `const Result = memo < Props > (Component);` is a chained + // comparison / call expression that must not produce a phantom HOC + // binding. The asymmetry with the TypeScript row is documented on + // TypeScriptOptionalHocTypeArgsPattern. Ordinary PascalCase constants like + // `const Config = loadConfig();` and `const Theme = React.createContext(null);` + // (non-HOC React API calls — `createContext`, hooks, etc.) and class + // expressions like `const Widget = class extends ...` do NOT produce phantom + // `function` symbols. The class-expression synthetic pass owns the `= class` + // shape on its own. BodyStyle.None because the RHS body span is not + // line-trackable from the declaration line alone; declaration-only visibility + // into the symbol is still strictly better than dropping the binding. Place + // AFTER the arrow-function pattern so a capitalized arrow binding wins that + // row via stopAfterFirstPatternMatch and is not shadowed here. Closes #240. + // React.memo / React.forwardRef / connect(...)(Component) / styled.div`...` / + // withAuthentication(Home) のような HOC ラップや呼び出し結果代入の + // コンポーネント束縛を取り込む。上の arrow パターンは `=` 直後に `=>` を + // 要求するため、RHS が呼び出し式・タグ付きテンプレート・プレーン識別子では + // 発火しない。RHS を既知の HOC 呼び出し形 — `React.memo(` / `React.forwardRef(` + // / `React.lazy(`、`styled.` / `styled(` / `styled``、素の `connect(` / + // `memo(` / `forwardRef(` / `lazy(` / `observer(`、`with(` — に + // 限定する。styled の factory 捕捉(`const F = styled.div;`)や素の呼び出し + // (`const F = styled(Component);`)は実体のあるコンポーネント束縛ではないため、 + // マッチ後のゲートでタグ付きテンプレートのバッククォートを原文行に要求し、 + // これらが phantom な function シンボルを生やさないようにする。ゲートは raw + // 行を参照する — `StructuralLineMasker.MaskJsTsTemplateLiteralContents` が + // テンプレート区切りを空白にマスクするため、同じ regex を使っても masked + // 経由では区別できないのがゲートを raw 行で行う理由。JavaScript 行は TypeScript + // 行と異なり、HOC 呼び出し名と `(` の + // 間に generic 型引数トークン `<...>` を意図的に受け付けない。JavaScript に + // generic 構文は無く、`const Result = memo < Props > (Component);` は単なる + // 比較・呼び出し連鎖式であって phantom な HOC 束縛を生やしてはならない。 + // 非対称な扱いは TypeScriptOptionalHocTypeArgsPattern のコメントで詳述する。 + // `const Config = loadConfig();` のような通常 PascalCase 定数や、 + // `const Theme = React.createContext(null);` のような非 HOC の React API 呼び出し + // (`createContext` や hooks 等)、`const Widget = class extends ...` の + // クラス式束縛で架空の `function` シンボルが生えないようにする。`= class` 形は + // class expression の合成パスが単独で処理する。RHS 本体は宣言行だけでは + // 行単位に追えないため BodyStyle.None。宣言のみでも束縛が消失するよりは実用的。 + // arrow パターンより後に置き、大文字始まりの arrow 束縛は先に一致した段階で + // stopAfterFirstPatternMatch が立ち、こちらで上書きされないようにする。 + // Closes #240. + new("function", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?[A-Z]\w*)\s*=\s*(?:React\.(?:memo|forwardRef|lazy)\s*\(|styled[.(`]|connect\s*\(|memo\s*\(|forwardRef\s*\(|lazy\s*\(|observer\s*\(|with[A-Z]\w*\s*\()", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("class", new Regex(@"^\s*(?:(?export)\s+)?(?:default\s+)?class\s+(?(?!extends\b)\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("import", new Regex(@"^\s*import\s+(?.+?)\s+from\s+", RegexOptions.Compiled), BodyStyle.None), + ], + ["typescript"] = + [ + // Include optional `*` between `function` and name for generator functions (e.g. `function* gen()`, `async function* asyncGen()`) + // `function` と名前の間に任意の `*` を許容し、ジェネレータ関数 (`function* gen()`, `async function* asyncGen()`) にも対応 + new("function", new Regex(@"^\s*(?export)\s+(?default)\s+(?async\s+)?function(?:\s+|\s*(?\*)\s*)" + TypeScriptOptionalTypeParameterListPattern + @"\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("function", new Regex(@"^\s*(?:(?export)\s+(?:default\s+)?)?(?:declare\s+)?(?async\s+)?function(?:\s+|\s*(?\*)\s*)(?\w+)\s*[\(<]", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("import", new Regex(@"^\s*(?:(?export)\s+)?(?:declare\s+)?type\s+(?\w+)" + TypeScriptOptionalTypeParameterListPattern + @"\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("property", new Regex(@"^\s*(?:(?export)\s+)?declare\s+(?:const|let|var)\s+(?\w+)(?::\s*[^;=]+)?\s*;", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("lambda", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?\w+)\s*(?::\s*.+?)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("lambda", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?\w+)\s*(?::\s*.+?)?\s*=\s*(?async\s+)?function\s*(?\*)?\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("function", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?\w+)\s*(?::\s*.+?)?\s*=\s*(?async\s+)?function(?:\s+|\s*(?\*)\s*)\w+\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // HOC-wrapped / call-result component bindings — same narrow HOC-prefix set + // as the JavaScript row above, extended with an optional TypeScript generic + // type-argument token between the HOC call name and its `(` via the shared + // TypeScriptOptionalHocTypeArgsPattern constant. The generic token balances + // up to three levels of nested angle brackets + // (`React.memo>>(Box)`) and allows + // parenthesised segments inside a generic argument + // (`React.memo<(props: Props) => JSX.Element>(Box)`) so function-type and + // conditional-type TS HOC call sites still match. The `React.` branch is + // pinned to `React.memo(` / `React.forwardRef(` / `React.lazy(` so non-HOC + // React API calls (`const Theme = React.createContext(null);`, + // `const Stable = React.useCallback(() => 1, []);`) do NOT produce phantom + // `function` rows on the TypeScript side either. The JavaScript row above + // intentionally does NOT carry the generic token because JS has no generic + // syntax and `memo < Props > (Component)` is a chained comparison / call + // expression; see the TypeScriptOptionalHocTypeArgsPattern comment for the + // ReDoS-safety reasoning behind the 3-level-plus-parens shape. TypeScript + // sources often carry a type annotation between the binding name and `=` + // (e.g. `const Connected: React.ComponentType = connect(...)(MyComponent);`). + // The optional `:` branch consumes the annotation lazily up to the first `=`; + // even when a type contains `=>` (as in `const F: () => void = fn;`), the + // lazy match back-tracks so the name group is still captured correctly. The + // arrow-function row above also accepts the same optional annotation so a + // typed arrow binding (`const Callback: (x: number) => number = (x) => + // x + 1;`) still wins with BodyStyle.Brace and is not shadowed here. + // Closes #240. + // HOC ラップや呼び出し結果代入のコンポーネント束縛 — JavaScript 行と同じ + // 狭い HOC プレフィックス集合を使い、共有定数 + // TypeScriptOptionalHocTypeArgsPattern で HOC 呼び出し名と `(` の間に + // TypeScript の generic 型引数トークンをオプションで受け入れる。この + // トークンは 3 段までのネストした山括弧 + // (`React.memo>>(Box)`)と、 + // generic 引数内の丸括弧付きセグメント + // (`React.memo<(props: Props) => JSX.Element>(Box)`)を許容するため、 + // 関数型・条件型を使う TS HOC 呼び出しもマッチする。`React.` 分岐は + // `React.memo(` / `React.forwardRef(` / `React.lazy(` に固定し、 + // `const Theme = React.createContext(null);` や + // `const Stable = React.useCallback(() => 1, []);` のような非 HOC の + // React API 呼び出しが TypeScript 側でも phantom `function` シンボルを + // 生やさないようにする。JavaScript 行は generic トークンを意図的に持たない。 + // JS に generic 構文は無く、`memo < Props > (Component)` は比較・呼び出しの + // 連鎖式だからである。3 段 + 括弧許容にした ReDoS 安全性の根拠は + // TypeScriptOptionalHocTypeArgsPattern のコメントを参照。TypeScript では + // 束縛名と `=` の間に型注釈(例: + // `const Connected: React.ComponentType = connect(...)(MyComponent);`) + // が入ることが多いため、オプションの `:` 分岐で最初の `=` まで遅延一致する。 + // 型に `=>` が含まれる場合(例: `const F: () => void = fn;`)もバックトラックで + // 名前グループは正しく取得できる。上の arrow 行も同じ型注釈を受け付けるため、 + // 型注釈付き arrow 束縛(`const Callback: (x: number) => number = (x) => + // x + 1;`)は BodyStyle.Brace 側で先勝ちし、こちらで上書きされない。 + // Closes #240. + new("function", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?[A-Z]\w*)\s*(?::\s*.+?)?\s*=\s*(?:React\.(?:memo|forwardRef|lazy)\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\(|styled[.(`]|connect\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\(|memo\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\(|forwardRef\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\(|lazy\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\(|observer\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\(|with[A-Z]\w*\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\()", RegexOptions.Compiled), BodyStyle.None, "visibility"), + // Abstract class, declare class / 抽象クラス、declare クラス + new("class", new Regex(@"^\s*(?:(?export)\s+)?(?:default\s+)?(?:(?:abstract|declare)\s+)*class\s+(?(?!(?:extends|implements)\b)\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // UMD namespace export / UMD 名前空間エクスポート + new("namespace", new Regex($@"^\s*export\s+as\s+namespace\s+(?{JavaScriptTypeScriptIdentifierPattern})", RegexOptions.Compiled), BodyStyle.None), + // namespace/module — supports both identifier (namespace Foo) and quoted ambient (declare module 'express') + // 名前空間・モジュール — 識別子形式と引用符付きアンビエント形式の両方に対応 + new("namespace", new Regex(@"^\s*(?:(?export)\s+)?(?:declare\s+)?(?:namespace|module)\s+['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("namespace", new Regex(@"^\s*(?:(?export)\s+)?(?:declare\s+)?(?:namespace|module)\s+(?[\w.]+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("interface", new Regex(@"^\s*(?:(?export)\s+)?(?:declare\s+)?interface\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("import", new Regex(@"^\s*(?:(?export)\s+)?(?:declare\s+)?type\s+(?\w+)(?:\s*<[^=]+>)?", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("enum", new Regex(@"^\s*(?:(?export)\s+)?(?:declare\s+)?(?:const\s+)?enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("import", new Regex(@"^\s*import\s+(?.+?)\s+from\s+", RegexOptions.Compiled), BodyStyle.None), + ], + ["csharp"] = + [ + // Verbatim and Unicode-escaped identifier segments (`@Foo.@Bar`, `\u0046oo`) are + // accepted via `CSharpNamespacePattern` / `CSharpIdentifierPattern` and later + // canonicalized by `CSharpSymbolNameNormalizer`. + // verbatim / Unicode escape 識別子の各セグメントを `CSharpNamespacePattern` / + // `CSharpIdentifierPattern` 経由で受け入れ、`CSharpSymbolNameNormalizer` で + // canonical 化する。 + new("namespace", new Regex($@"^\s*namespace\s+(?{CSharpNamespacePattern})\s*;", RegexOptions.Compiled), BodyStyle.None), // file-scoped namespace (C# 10+) + new("namespace", new Regex($@"^\s*namespace\s+(?{CSharpNamespacePattern})", RegexOptions.Compiled), BodyStyle.Brace), // block-scoped namespace + // extern alias (must precede using directives per C# spec) — captures assembly-alias reconciliation + // extern alias — C# 仕様上 using より前に置かれるファイル先頭宣言。アセンブリエイリアス用 + new("import", new Regex($@"^\s*extern\s+alias\s+(?{CSharpIdentifierPattern})\s*;", RegexOptions.Compiled), BodyStyle.None), + // using alias (using X = Y;) — must come before general using to capture alias name. + // Verbatim alias identifiers like `using @AliasAttr = A.BaseAttr;` still surface as an + // `import` row via `CSharpIdentifierPattern`; the DbWriter-side normalizer strips the + // leading `@`. + // using エイリアス — 一般 using より前に配置しエイリアス名を取得。verbatim 識別子 + // (`using @AliasAttr = A.BaseAttr;`) も `CSharpIdentifierPattern` 経由で import 行として + // 拾える。 + new("import", new Regex($@"^\s*(?:global\s+)?using\s+(?{CSharpIdentifierPattern})\s*=\s*[^;]+;", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(@"^\s*(?:global\s+)?using\s+(?:static\s+)?(?[^;=]+);", RegexOptions.Compiled), BodyStyle.None), + // Const field — must come before class/method patterns to avoid misclassification. + // Modifier order is free: visibility may appear anywhere in the modifier sequence, + // so `new public const` and `public new const` are both captured. Closes #355. + // returnType uses the shared CSharpTypePattern (same token the method / property / + // indexer / delegate / event rows already use) so tuple / named-tuple / + // nullable-tuple / generic-over-tuple / global::-qualified / tuple-array const field + // types are captured instead of silently dropped. The legacy hand-rolled char class + // had no `(`, `)`, or `\s`, so `public const (int, int) Pair = (1, 2);` failed the + // returnType group and fell through every subsequent row. Closes #346. + // const フィールド — クラス/メソッドパターンより前に配置し誤分類を防ぐ。 + // 修飾子順序は自由で、visibility は修飾子列の任意位置に現れてよい(例: `new public const` / + // `public new const`)。Closes #355. + // returnType は method / property / indexer / delegate / event 行で既に使っている共有 + // トークン CSharpTypePattern を使う。これにより tuple / 名前付き tuple / nullable tuple / + // generic-over-tuple / `global::` 修飾 / tuple-array を戻り値型とする const フィールドを + // 取りこぼさない。従来の手書き文字クラスには `(` / `)` / `\s` が無く、 + // `public const (int, int) Pair = (1, 2);` は returnType 群で失敗し、以降のどの行にも + // マッチしなかった。Closes #346. + new("function", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:new|static)\s+)*const\s+(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), + // Static readonly field / static readonly フィールド + // Modifier order is free: `static` and `readonly` may appear in any order, and `new` + // (member hiding) may appear anywhere in the modifier sequence. Visibility is also + // accepted anywhere, not just at the front, so legacy orderings like + // `readonly public static` / `static public readonly` still classify as fields + // instead of falling through to the plain-field (kind `property`) row. Closes #355. + // static/readonly の順序は自由で、`new`(メンバー隠蔽)も任意位置に置ける。visibility も + // 先頭以外の位置に現れることを許容し、`readonly public static` や `static public readonly` + // のような旧来の並びでも kind `field` で取り扱う。通常フィールド(kind `property`)の + // 正規表現に流れ落ちないようにする。Closes #355. + // Share CSharpTypePattern with const and plain fields so tuple, nullable-tuple, and + // generic-over-tuple types retain stable field kind and complete return-type metadata. + // const / 通常フィールドと CSharpTypePattern を共有し、tuple / nullable tuple / + // generic-over-tuple 型でも安定した field kind と完全な return-type metadata を保持する。 + // Closes #4616. + new("function", new Regex( + $@"^\s*" + + $@"(?=(?:(?:{CSharpVisibilityPattern}|new|static|readonly)\s+)*static\s+)" + + $@"(?=(?:(?:{CSharpVisibilityPattern}|new|static|readonly)\s+)*readonly\s+)" + + $@"(?:(?{CSharpVisibilityPattern})\s+|(?:new|static|readonly)\s+)+" + + $@"(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*[=;]", + RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), + // Plain field (instance, readonly, volatile, plain static, etc.) — kind `property`. + // Must come AFTER the `const` and `static readonly` patterns (which take priority + // with kind `function`), and BEFORE the structural declaration patterns. + // The terminator `=(?![=>])` or `;` distinguishes fields from methods (which end + // with `(`), property accessors (which end with `{`), expression-bodied members + // (which use `=>`), and comparison-operator overloads (which contain `==`). + // The negative lookahead repeats every visibility and modifier keyword so the + // regex engine cannot backtrack past an unconsumed `public static event …` + // declaration and match it as a field whose returnType is `public static event …`. + // Closes #298. + // 通常フィールド(instance / readonly / volatile / 通常 static など) — kind は `property`。 + // `const` / `static readonly` パターン(kind `function`)より後、型宣言パターンより前に置く。 + // 終端を `=(?![=>])` または `;` にすることで、メソッド(`(`)、プロパティアクセサ(`{`)、 + // 式本体メンバー(`=>`)、比較演算子オーバーロード(`==`)を除外する。 + // visibility / modifier キーワードを negative lookahead にも並べて、regex engine が + // それらを returnType として飲み込む方向に backtrack して `public static event …` + // のような宣言を field としてマッチすることを防ぐ。Closes #298. + // Modifier order is free, so visibility may appear anywhere in the modifier + // sequence (e.g. `static public int X;`). Closes #355. + // 修飾子順序は自由で、visibility を修飾子列の任意位置に置ける + // (例: `static public int X;`)。Closes #355. + new("property", new Regex( + $@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|readonly|volatile|new|unsafe|extern|required)\s+)*" + + @"(?!(?:public|private|protected|internal|static|readonly|volatile|new|unsafe|extern|required|abstract|virtual|override|sealed|async|partial|file|ref|var|class|struct|interface|enum|record|namespace|delegate\b(?!\*)|event|const|using|return|throw|yield|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await|try|do|typeof|sizeof|nameof|default|operator|this|base)\b)" + + $@"(?{CSharpTypePattern})\s+" + + @"(?" + CSharpIdentifierPattern + @")\s*(?:=(?![=>])|;)", + RegexOptions.Compiled), + BodyStyle.None, "visibility", "returnType"), + // Interface — visibility optional; modifier order is free, so visibility may appear + // anywhere in the modifier sequence (e.g. `partial public interface`, `file interface`, + // `new public interface` for nested types). Closes #355. + // インターフェース — visibility 省略可。修飾子順序は自由 + // (例: `partial public interface`、`file interface`、ネスト型向けの `new public interface`)。Closes #355. + new("interface", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:partial|unsafe|file|new)\s+)*interface\s+(?{CSharpIdentifierPattern})", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Enum — visibility optional / enum — visibility 省略可 + new("enum", CSharpEnumDeclarationRegex, BodyStyle.Brace, "visibility"), + // Struct (including record struct, ref struct, readonly struct) — visibility optional; + // modifier order is free, so visibility may appear anywhere in the modifier sequence + // (e.g. `readonly public struct`, `ref public struct`). Closes #355. + // 構造体(record struct, ref struct, readonly struct を含む)— visibility 省略可。 + // 修飾子順序は自由で、visibility は任意位置に置いてよい(例: `readonly public struct`、 + // `ref public struct`)。Closes #355. + new("struct", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|partial|readonly|file|new|ref|unsafe)\s+)*(?:record\s+)?struct\s+(?{CSharpIdentifierPattern})", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Class (including record, record class) — visibility optional (defaults to internal + // for top-level); modifier order is free, so visibility may appear anywhere in the + // modifier sequence (e.g. `abstract public class`, `sealed public class`). Closes #355. + // クラス(record, record class を含む)— visibility は省略可能(トップレベルでは internal がデフォルト)。 + // 修飾子順序は自由で、visibility は任意位置に置いてよい(例: `abstract public class`、 + // `sealed public class`)。Closes #355. + new("class", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|partial|abstract|sealed|readonly|file|new|unsafe)\s+)*(?:record\s+class\s+|record\s+|class\s+)(?{CSharpIdentifierPattern})", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Implicit/explicit conversion operator — must come before general operator pattern. + // Visibility may appear before or after `static` / `unsafe` / `extern`. Closes #355. + // Modifier slot also accepts `abstract|virtual|sealed|override|new` so C# 11 + // `static abstract` / `abstract static` interface conversion operators (generic + // math: `System.Numerics.INumber` etc.) and default-implementation / + // member-hiding forms on interfaces are not silently dropped. Closes #244. + // 暗黙的/明示的変換演算子 — 一般のoperatorパターンより先に配置。 + // visibility は `static` / `unsafe` / `extern` のどちら側にも置ける。Closes #355. + // 修飾子スロットは `abstract|virtual|sealed|override|new` も受け付ける。 + // これにより C# 11 の `static abstract` / `abstract static` interface 変換演算子 + // (generic math: `System.Numerics.INumber` など)と、interface 上の + // default implementation / member hiding 形態を黙って取りこぼさない。Closes #244. + new("operator", new Regex( + $@"^\s*" + + $@"(?=(?:(?:{CSharpVisibilityPattern}|static|abstract|virtual|sealed|override|new|unsafe|extern)\s+)*static\s+)" + + $@"(?:(?{CSharpVisibilityPattern})\s+|(?:static|abstract|virtual|sealed|override|new|unsafe|extern)\s+)+" + + @"(?(?:implicit|explicit)\s+operator\s+.+?)\s*\(", + RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Operator overload (+ - * / == != < > etc.) — must come before method pattern. + // Visibility may appear before or after `static`. Closes #355. + // Modifier slot also accepts `abstract|virtual|sealed|override|new` so C# 11 + // `static abstract` / `abstract static` interface operators (generic math: + // `IAdditionOperators`, `IComparisonOperators`, etc.) are not silently + // dropped. Closes #244. + // 演算子オーバーロード — メソッドパターンより前に配置。 + // visibility は `static` のどちら側にも置ける。Closes #355. + // 修飾子スロットは `abstract|virtual|sealed|override|new` も受け付ける。 + // これにより C# 11 の `static abstract` / `abstract static` interface 演算子 + // (generic math: `IAdditionOperators`、`IComparisonOperators` など)を + // 黙って取りこぼさない。Closes #244. + new("operator", new Regex( + $@"^\s*" + + $@"(?=(?:(?:{CSharpVisibilityPattern}|static|abstract|virtual|sealed|override|new|unsafe|extern)\s+)*static\s+)" + + $@"(?:(?{CSharpVisibilityPattern})\s+|(?:static|abstract|virtual|sealed|override|new|unsafe|extern)\s+)+" + + @".+?\s+(?operator\s+(?:checked\s+)?\S+)\s*\(", + RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Method with return type — visibility optional for explicit interface impl and nested members. + // Negative lookahead excludes call-site lines (await/return/throw/yield/var/typeof/sizeof/nameof/default/if/for/while/switch/catch/lock/using) + // and ternary continuation branches (`? Foo(...)` / `: Foo(...)`) that would otherwise resemble returnType + name. + // LINQ query-expression keywords (from/where/select/orderby/group/join/let/into/on/equals/ascending/descending/by) + // are also excluded so continuation lines like `select Mapper.Convert(x)` or `where Validator.Check(x)` do not + // fire returnType+qualifier+name phantoms. The lookahead is anchored to the line-leading token, so it only + // blocks continuation forms; ordinary method declarations whose NAME happens to be a LINQ keyword still match + // via their return type (e.g. `public void where() { }`). Closes #377. + // The `(?!(?:base|this)\b)` guard on the name capture belt-and-suspenders against constructor-chain + // initializers (`: base(...)` / `: this(...)`) leaking phantom `function base` / `function this` + // symbols if any upstream guard becomes permissive. Closes #331. + // Note: `new` is NOT excluded because `new void Hidden()` is a valid C# member-hiding declaration. + // 戻り値型付きメソッド — 明示的インターフェース実装やネストメンバー向けに visibility 省略可。 + // negative lookahead で呼び出し行(await/return/throw/yield/var/typeof 等)と ternary continuation を除外する。 + // LINQ 式キーワード (from/where/select/orderby/group/join/let/into/on/equals/ascending/descending/by) も除外し、 + // `select Mapper.Convert(x)` や `where Validator.Check(x)` のような continuation 行が returnType+qualifier+name + // phantom を生まないようにする。lookahead は行頭トークンに固定しているため、continuation 形のみを弾き、 + // LINQ キーワードと同名のメソッド(例: `public void where() { }`)は戻り値型を介して通常どおり一致する。Closes #377. + // `(?!(?:base|this)\b)` を name キャプチャに付け、上流ガードが緩んだ場合でも + // コンストラクタ初期化子 (`: base(...)` / `: this(...)`) が phantom `function base` / `function this` + // として漏れないよう二重化する。Closes #331. + // 注意: `new` は除外しない。`new void Hidden()` は C# のメンバー隠蔽宣言として有効。 + new("function", new Regex($@"^\s*(?!\[\s*(?:assembly|module|type|return|param|field|property|event|method)\s*:)(?:(?{CSharpVisibilityPattern})\s+|(?:static|sealed|partial|readonly|unsafe|extern|virtual|override|abstract|new|file|ref(?:\s+readonly)?)\s+)*async\s+(?(?=[\w@?.<>\[\],:\s]*IAsync(?:Enumerable|Enumerator)\b){CSharpTypePattern})\s+(?!(?:base|this)\b)(?{CSharpIdentifierPattern})\s*{CSharpMethodTypeParameterListPattern}\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), + new("function", new Regex($@"^\s*(?!\[\s*(?:assembly|module|type|return|param|field|property|event|method)\s*:)(?![?:])(?!(?:await|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|using|case|else|when|break|continue|goto|from|where|select|orderby|group|join|let|into|on|equals|ascending|descending|by)\b)(?!\s*(?:(?:{CSharpVisibilityPattern}|static|sealed|partial|readonly|unsafe|extern|virtual|override|abstract|async|new|file|ref(?:\s+readonly)?)\s+)*delegate\b(?!\s*\*))(?:(?{CSharpVisibilityPattern})\s+|(?:static|sealed|partial|readonly|unsafe|extern|virtual|override|abstract|async|new|file|ref(?:\s+readonly)?)\s+)*(?!{CSharpNonTypeKeywordPattern})(?{CSharpTypePattern})\s+(?!(?:base|this)\b)(?{CSharpIdentifierPattern})\s*{CSharpMethodTypeParameterListPattern}\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), + new("lambda", new Regex($@"^\s*(?:var|{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*=\s*(?:async\s+)?(?:\([^)]*\)|{CSharpIdentifierPattern})\s*=>", RegexOptions.Compiled), BodyStyle.None), + // Constructor (no return type, name followed by parenthesis) — needs visibility. + // `unsafe` / `extern` can appear before or after visibility, and C# 14 partial + // constructors place `partial` after visibility, so declarations like + // `unsafe public S(int* p) {}`, `extern public S(int x);`, and `public partial S();` + // are still captured with visibility populated. Closes #355. + // The negative lookahead after the opening paren rejects lines where the matching + // `)` is followed by an identifier + `{` / `(` / `;` / `=>` / `=` (with optional + // tuple-type suffixes `?` / `[]` / `[,]` / `[,,]` and whitespaced variants like + // `) []` / `) ?` in between via CSharpTupleSuffixPattern), which is the shape of a + // property with a modifier + tuple return type (`public required (int, int) R1 + // { get; init; }`, `public required (int, int) [] R4 { get; init; }`), an + // expression-bodied method with a modifier (`public readonly (int, int)? M() => + // null;`, `public readonly (int, int) ? M3() => default;`), or a plain field with a + // modifier + tuple type — both the uninitialized form (`public readonly (int, int) ? + // F5;`, terminated by `;`) and the initialized form (`public readonly (int, int) ? + // F4 = null;`, terminated by `=` excluding `==` / `=>`). A plain ctor signature + // cannot match because there is no identifier between the closing `)` and the body + // opener. The plain-field shapes are covered because #400's same-line plain-field + // advance no longer sets stopAfterFirstPatternMatch, so the ctor regex now runs on + // lines the plain-field pattern already claimed and would otherwise re-emit a phantom + // `function readonly` ctor row. Using a positional check (not a keyword deny-list) + // preserves support for legal (though unusual) type names that collide with + // contextual keywords. Multi-line ctor signatures where the closing `)` is on a + // later line are unaffected because the lookahead only triggers when a `)` is + // visible on the current line. Sharing CSharpTupleSuffixPattern with CSharpTypePattern + // keeps the ctor lookahead and the upstream property / method / plain-field rows in + // sync on which formatting variants count as a tuple-suffix return type. Closes #349. + // コンストラクタ(戻り値なし、名前の後に括弧)— visibility 必須。 + // `unsafe` / `extern` は visibility の前後どちらにも置け、C# 14 の partial + // constructor は visibility の後ろに `partial` を置くため、 + // `unsafe public S(int* p) {}`、`extern public S(int x);`、`public partial S();` + // でも visibility を拾える。Closes #355. + // 開き括弧の直後に置いた否定先読みは、「対応する `)` のあとに識別子 + `{` / `(` / `;` / + // `=>` / `=`(間に `?` / `[]` / `[,]` / `[,,]` の tuple サフィックス、および + // CSharpTupleSuffixPattern によって `) []` / `) ?` のような空白を挟んだ整形バリエーションも + // 許す)」形の行を弾く。これは `public required (int, int) R1 { get; init; }` や + // `public required (int, int) [] R4 { get; init; }` のような modifier 付き property、 + // `public readonly (int, int)? M() => null;` や `public readonly (int, int) ? M3() => default;` + // のような modifier 付き式形式メソッド、および modifier 付き tuple 型の plain field — + // `public readonly (int, int) ? F5;` のような未初期化(`;` 終端)形、 + // `public readonly (int, int) ? F4 = null;` のような初期化(`=` 終端、`==` / `=>` は除外)形 — + // であり、従来はいずれも `required` / `readonly` を ctor 名として greedy に喰っていた。 + // 通常の ctor シグネチャでは閉じ括弧と本体開始の間に識別子が入らないためマッチし続ける。 + // plain field 形が対象に入ったのは、#400 の同一行 plain-field 前進が + // stopAfterFirstPatternMatch をセットしなくなったため、ctor 正規表現が plain-field + // パターン既取得の行にも再走して phantom `function readonly` を再発する経路ができたため。 + // キーワード deny-list ではなく位置検査なので、contextual keyword と綴りが衝突する合法な + // 型名のコンストラクタも弾かない。複数行にまたがる ctor シグネチャ(閉じ括弧が次行以降にある場合)は、 + // 現在行に `)` が出ないため lookahead が発動せずそのままマッチする。 + // CSharpTupleSuffixPattern を CSharpTypePattern と共有することで、ctor 否定先読みと上流の + // property / method / plain-field 行が tuple サフィックス戻り値の受理形について常に一致する。Closes #349. + new("function", new Regex($@"^\s*(?:(?:unsafe|extern)\s+)*(?{CSharpVisibilityPattern})\s+(?:(?:unsafe|extern|partial)\s+)*(?{CSharpIdentifierPattern})\s*\((?!.*\){CSharpTupleSuffixPattern}\s*{CSharpIdentifierPattern}\s*(?:[{{(;]|=>|=(?![=>])))", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Partial method declaration with an omitted return type. Older partial-method + // syntax can omit the accessibility modifier and still mean `void`; keep this + // after the constructor row so `public partial Widget();` remains a constructor. + // 戻り値型を省略した partial method 宣言。旧来の partial method 構文では + // accessibility を省略し、戻り値型は `void` とみなされる。`public partial Widget();` + // は constructor のまま扱うため、この行は constructor 行の後ろに置く。 + new("function", new Regex($@"^\s*(?:(?:static|sealed|readonly|unsafe|extern|virtual|override|abstract|async|new|file|ref(?:\s+readonly)?)\s+)*(?partial)\s+(?{CSharpIdentifierPattern})\s*{CSharpMethodTypeParameterListPattern}\(", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + // Static constructor / 静的コンストラクタ + // Keep this ahead of the property rows so same-line compact bodies such as + // `class C { static C() { } public int P { get; set; } }` emit the static ctor + // before the later property match short-circuits the pattern scan. The shape is + // specific enough that it does not overlap with normal methods (no return type, + // empty parameter list, optional `unsafe` around `static`). Closes #478. + // 同一行のコンパクトな型本体 + // (`class C { static C() { } public int P { get; set; } }`) では、後続 property が + // pattern scan を打ち切る前に static ctor を先に拾う必要があるため、property 行より前に置く。 + // この形は「戻り値型なし・引数なし・`static` 前後の任意 `unsafe`」に限定されるため、 + // 通常メソッドとは重ならない。Closes #478. + new("function", new Regex($@"^\s*(?:unsafe\s+)?static\s+(?:unsafe\s+)?(?{CSharpIdentifierPattern})\s*\(\s*\)\s*\{{?", RegexOptions.Compiled), BodyStyle.Brace), + // Property with get/set/init — visibility optional + // Reject statement keywords (return/throw/switch/...) as the return type so that + // multi-line statement fragments merged by BuildCSharpPropertyMatchLine — e.g. + // `return o switch` combined with an opening `{` on the next line — are not + // misclassified as a property. Closes #233. + // プロパティ(get/set/init)— visibility 省略可 + // `return o switch` のような複数行にまたがる文断片が `BuildCSharpPropertyMatchLine` + // で結合された結果、property として誤判定されるのを防ぐため、戻り値型として + // ステートメントキーワードを拒否する。Closes #233. + new("property", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|virtual|override|abstract|sealed|new|required|partial|readonly|unsafe|extern|ref(?:\s+readonly)?)\s+)*(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*\{{", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), + // Expression-bodied property (public int X => ...) — must come before delegate. + // Uses BodyStyle.Brace so FindCSharpBraceRange detects '=>' and assigns a body + // range covering the declaration line through the terminating ';', which + // ReferenceExtractor.FindInnermostContainer needs to attribute accessor-internal + // calls to the property rather than the enclosing class. + // Closes #233. + // 式本体プロパティ (public int X => ...) — delegate の前に配置。 + // `BodyStyle.Brace` にして `FindCSharpBraceRange` の '=>' 検出で宣言行から + // 終端 ';' までを本体範囲として扱えるようにする。 + // ReferenceExtractor.FindInnermostContainer が accessor 内呼び出しを外側 + // クラスではなく property に帰属させるために必要。 + // Closes #233. + new("property", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|virtual|override|abstract|sealed|new|required|partial|readonly|unsafe|extern|ref(?:\s+readonly)?)\s+)*(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*=>\s*", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), + // Delegate — visibility optional; modifier order is free. Accepts `static` / `unsafe` / + // `file` (file-scoped delegate) / `new` (nested delegate hiding). Closes #355. + // デリゲート — visibility 省略可。修飾子順序は自由。`static` / `unsafe` / + // `file`(file スコープ delegate)/ `new`(ネスト delegate の隠蔽)を受け付ける。Closes #355. + new("delegate", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|unsafe|file|new)\s+)*delegate\s+(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*[\(<]", RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), + // Event — visibility optional; modifier order is free. Accepts `static` / `unsafe` / + // `extern` plus inheritance modifiers (`virtual` / `override` / `abstract` / `sealed` / `new`) + // which are all legal on event declarations per the C# spec. `partial` is also legal on + // events (C# 14 field-like partial events, and extended partial member support on accessor + // events), so accept it as well — otherwise every `partial event` declaration would be + // silently dropped from symbols / definition / outline. Closes #350. + // イベント — visibility 省略可。修飾子順序は自由。`static` / `unsafe` / `extern` に加え、 + // C# 仕様で event 宣言に有効な継承修飾子 (`virtual` / `override` / `abstract` / `sealed` / `new`) + // も受け付ける。event には `partial` も合法 (C# 14 field-like partial event、およびアクセサ + // ベースの partial member 拡張) なので、ここでも受け付けないと `partial event` 宣言が + // symbols / definition / outline から無言で欠落する。Closes #350. + new("event", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|unsafe|extern|virtual|override|abstract|sealed|new|partial)\s+)*event\s+(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*(?:[;=]|\{{)", RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), + // Explicit interface event implementation (e.g. event EventHandler IFoo.Changed) + // must capture the trailing member name rather than dropping the declaration or + // inventing the qualifier as the event name. BodyStyle.Brace lets accessor blocks + // on the same line or following lines share the normal brace-range path. + // 明示的インターフェース event 実装 (例: event EventHandler IFoo.Changed) は、 + // qualifier 側ではなく末尾のメンバー名を event 名として捕捉しなければならない。 + // BodyStyle.Brace を使い、同一行/次行どちらの accessor block も通常の brace-range + // 経路で扱う。 + new("event", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|unsafe|extern|virtual|override|abstract|sealed|new|partial)\s+)*event\s+(?{CSharpTypePattern})\s+{CSharpExplicitInterfaceQualifierPattern}\s*\.\s*(?{CSharpIdentifierPattern})\b", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), + // Explicit interface implementation (e.g. void IDisposable.Dispose()) + // Requires a valid return type (not a statement keyword) and interface name before the dot. + // Reject named-argument labels only when they are followed by a qualified call site, + // so alias-qualified types like `global::System.String` and `Alias::Type` still match. + // LINQ query-expression keywords are also excluded from the negative lookahead so that + // continuation lines like `where Validator.Check(x)` / `select Mapper.Convert(x)` / + // `orderby Math.Abs(x)` do not match as `returnType + interface.member`. `new` is also + // excluded so expression statements like `new System.Text.StringBuilder().Append(...)` + // or `new Outer.Inner().Consume()` do not masquerade as an explicit interface method + // (returnType=`new`, interface=the dot-chain qualifier preceding the constructed type + // — which may be a namespace prefix like `System.Text`, an enclosing-type chain like + // `Outer` in `new Outer.Inner()` where `Outer` is an outer class, or a mix of both + // like `MyApp.Outer` in `new MyApp.Outer.Inner()` where `MyApp` is a namespace and + // `Outer` is an enclosing type; the regex does not distinguish which segments are + // namespaces and which are enclosing types at this position — and name=the + // identifier right before the first `(`, i.e. the type being constructed: + // `StringBuilder` / `Inner`; the trailing `.Append(...)` / `.Consume()` chain is + // never part of the capture because the regex stops at the first `(`). + // Closes #362, #377. + // 明示的インターフェース実装 (例: void IDisposable.Dispose()) + // 有効な戻り値型(ステートメントキーワードではない)とドット前のインターフェース名を要求。 + // qualified call site を伴う named-argument label のみ除外し、 + // `global::System.String` や `Alias::Type` のような alias-qualified 型は許可する。 + // `new` も除外して、`new System.Text.StringBuilder().Append(...)` や + // `new Outer.Inner().Consume()` のような式文が、returnType=`new` / + // interface=構築型の手前のドット連鎖修飾子(namespace `System.Text` / 外側クラス + // `Outer` のみ / namespace と外側型の混在 `MyApp.Outer`(`MyApp` が namespace、 + // `Outer` が外側型)のいずれでもよく、正規表現はこの位置で namespace と外側型を + // 区別しない)/ name=構築される型(最初の `(` の直前の識別子、例: `StringBuilder` + // / `Inner`。正規表現は最初の `(` で止まるので、末尾の `.Append(...)` / + // `.Consume()` チェーンはキャプチャされない)として + // 明示的インターフェースメソッドに化けないようにする。 + new("function", new Regex($@"^\s*(?![?:])(?!(?:await|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|using|case|else|when|break|continue|goto|new|from|where|select|orderby|group|join|let|into|on|equals|ascending|descending|by)\b)(?!\w+\s*:\s*(?:global::)?[\w@.<>:]+\.\w+\s*{CSharpMethodTypeParameterListPattern}[\(\[])(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+{CSharpExplicitInterfaceQualifierPattern}\.(?{CSharpIdentifierPattern})\s*{CSharpMethodTypeParameterListPattern}[\(\[]", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + // Explicit interface property implementation (brace body), e.g. int IThing.Value { get; set; } + // Mirrors the explicit-interface method row above: the qualifier is non-capturing so the + // short property name (Value) is recorded as name, consistent with how the method row + // exposes Dispose/CompareTo instead of the qualified form. Closes #333. + // 明示的インターフェースプロパティ実装(ブレース本体)。例: int IThing.Value { get; set; } + // 上の明示的インターフェースメソッド行と同じ構造で、修飾子は非キャプチャにしてショート名 + // (Value) のみを name として記録する。メソッド側が Dispose / CompareTo を返すのと揃える。 + // Closes #333. + new("property", new Regex($@"^\s*(?![?:])(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+{CSharpExplicitInterfaceQualifierPattern}\.(?{CSharpIdentifierPattern})\s*\{{", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + // Explicit interface property implementation (expression body), e.g. string IThing.Name => "x"; + // 明示的インターフェースプロパティ実装(式本体)。例: string IThing.Name => "x"; + new("property", new Regex($@"^\s*(?![?:])(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+{CSharpExplicitInterfaceQualifierPattern}\.(?{CSharpIdentifierPattern})\s*=>\s*", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + // Indexer (this[...]) — `partial` is legal on indexers since C# 13 (extended partial + // member support), so accept it alongside the other modifiers. Otherwise every + // `partial` indexer declaration would be silently dropped from symbols / definition / + // outline. Closes #350. + // インデクサ (this[...]) — C# 13 で indexer に対しても `partial` が使える (partial + // member 拡張) ため、他の修飾子と並べて受け付ける。そうしないと `partial` indexer 宣言 + // が symbols / definition / outline から無言で欠落する。Closes #350. + new("function", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|virtual|override|abstract|sealed|new|readonly|unsafe|extern|partial|ref(?:\s+readonly)?)\s+)*(?{CSharpTypePattern})\s+(?this)\s*\[", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), + // Finalizer (destructor) / ファイナライザ(デストラクタ) + new("function", new Regex($@"^\s*~(?{CSharpIdentifierPattern})\s*\(\s*\)", RegexOptions.Compiled), BodyStyle.Brace), + // Enum member (e.g. Red, Green = 1,) — requires 4+ spaces indent, name only, + // and optional = with numeric/hex/identifier value. Does NOT match string/object assignments. + // enum メンバー(例: Red, Green = 1,)— 4+スペースインデント必須、名前のみ、 + // 数値/16進/識別子の値指定はオプション。文字列/オブジェクト代入にはマッチしない。 + new("enum", CSharpEnumMemberRegex, BodyStyle.None), + // #region for navigation / ナビゲーション用 #region + new("namespace", new Regex(@"^\s*#region\s+(?.+)$", RegexOptions.Compiled), BodyStyle.None), + ], + ["go"] = + [ + new("namespace", new Regex(@"^\s*package\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^func\s+(?:\([^)]+\)\s+)?(?\w+)(?:\[[^\]\r\n]+\])?\s*[\(\[]", RegexOptions.Compiled), BodyStyle.Brace), + new("lambda", new Regex(@"^\s*(?\w+)\s*(?::=|=)\s*func\s*\(", RegexOptions.Compiled), BodyStyle.Brace), + new("struct", new Regex(@"^type\s+(?\w+)(?:\[[^\]]+\])?\s+struct\b", RegexOptions.Compiled), BodyStyle.Brace), + new("protocol", new Regex(@"^type\s+(?\w+)(?:\[[^\]]+\])?\s+interface\b", RegexOptions.Compiled), BodyStyle.Brace), + // Type alias (type Name = OtherType or type Name OtherType) / 型エイリアス + new("import", new Regex(@"^type\s+(?\w+)(?:\[[^\]]+\])?\s+[=\w]", RegexOptions.Compiled), BodyStyle.None), + // Top-level const declarations / トップレベル const 宣言 + new("property", new Regex(@"^const\s+(?\w+)(?:\s+\w[\w.*\[\]]*)?\s*=", RegexOptions.Compiled), BodyStyle.None), + // Const declaration inside const block / const ブロック内の定数宣言 + new("property", new Regex(@"^\s+(?[A-Z]\w*)\s*=\s*", RegexOptions.Compiled), BodyStyle.None), + // Package-level var / パッケージレベル変数 + new("property", new Regex(@"^var\s+(?\w+)\s", RegexOptions.Compiled), BodyStyle.None), + ], + ["fortran"] = + [ + // Named interfaces / 名前付き interface + new("namespace", new Regex(@"^\s*interface\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), + // Fortran modules / モジュール + new("namespace", new Regex(@"^\s*module\s+(?!(?:procedure|subroutine|function)\b)(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), + // Fortran submodules / サブモジュール + new("namespace", new Regex(@"^\s*submodule\s*\(\s*[^)]*\)\s*(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), + // Program units / プログラム本体 + new("class", new Regex(@"^\s*program\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), + // Block data program units / block data プログラム単位 + new("class", new Regex(@"^\s*block\s+data\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), + // Derived types / 派生型 + new("class", new Regex(@"^\s*type(?!\s*\()\b(?:\s*,\s*(?:abstract|public|private|sequence|bind\s*\([^)]+\)|extends\s*\([^)]+\)))*\s*(?:::)?\s*(?!(?:is|default)\b)(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), + // Enumerators / enumerator 定数 + new("property", new Regex(@"^\s*enumerator(?:\s*::)?\s*(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Parameter constants / parameter 定数 + new("property", new Regex(@"^\s*(?:(?:integer|real|logical|complex)(?:\s*\([^)]+\))?|character(?:\s*\([^)]+\))?|double\s+precision|type\s*\([^)]+\)|class\s*\([^)]+\))\s*,[^:\r\n]*\bparameter\b[^:\r\n]*::\s*(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Old-style parameter constants / 旧形式 parameter 定数 + new("property", new Regex(@"^\s*parameter\s*\(\s*(?[A-Za-z_]\w*)(?.*)\)\s*(?:!.*)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Typed variables and components / 型付き変数・component + new("property", new Regex(@"^\s*(?(?:(?:integer|real|logical|complex)(?:\s*\([^)]+\))?|character(?:\s*\([^)]+\))?|double\s+precision|type\s*\([^)]+\)|class\s*\([^)]+\)))\s*(?:,\s*(?![^:\r\n]*\bparameter\b)[^:\r\n]*)?::\s*(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), + // Old-style typed variables without :: / :: なしの旧形式型付き変数 + new("property", new Regex(@"^\s*(?(?:(?:integer|real|logical|complex)(?:\s*\([^)]+\))?|character(?:\s*\([^)]+\))?|double\s+precision|type\s*\([^)]+\)|class\s*\([^)]+\)))\s+(?!(?:function|subroutine)\b)(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), + // Attribute-only variables / 属性のみの変数宣言 + new("property", new Regex(@"^\s*(?:(?:allocatable|pointer|target|optional|save|dimension\s*\([^)]+\)|intent\s*\([^)]+\))\s*,\s*)*(?:allocatable|pointer|target|optional|save|dimension\s*\([^)]+\)|intent\s*\([^)]+\))\s*(?:::)?\s*(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Common block members / common block メンバー + new("property", new Regex(@"^\s*common\s+(?:/\s*[A-Za-z_]\w*\s*/\s*)?(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Namelist members / namelist メンバー + new("property", new Regex(@"^\s*namelist\s+/\s*[A-Za-z_]\w*\s*/\s*(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Subroutines / サブルーチン + new("function", new Regex(@"^\s*(?:(?:pure|elemental|recursive|module|impure)\s+)*subroutine\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), + // Entry points / entry 手続き + new("function", new Regex(@"^\s*entry\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Module procedure implementations / module procedure 実装 + new("function", new Regex(@"^\s*module\s+procedure\s+(?[A-Za-z_]\w*)\s*(?:!.*)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), + // Procedure declarations in interfaces / interface 内の手続き宣言 + new("function", new Regex(@"^\s*(?:(?:pure|elemental|recursive|impure)\s+)*(?:(?:module\s+)?procedure)(?:\s*\([^)]+\))?(?:\s*,\s*[A-Za-z_]\w*)*\s*(?:::\s*)?(?[A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Typed or untyped functions / 型付き・型なし関数 + new("function", new Regex(@"^\s*(?:(?:pure|elemental|recursive|module|impure)\s+)*(?:(?:(?:integer|real|logical|complex)(?:\s*\([^)]+\))?|character(?:\s*\([^)]+\))?|double\s+precision|type\s*\([^)]+\)|class\s*\([^)]+\)|procedure\s*\([^)]+\))\s+)?function\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), + ], + ["rust"] = + [ + // macro_rules! / マクロ定義 + new("function", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?macro_rules!\s+(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // const/static items / 定数・静的変数 + new("function", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?(?:const|static)\s+(?(?:r#)?\w+)\s*:", RegexOptions.Compiled), BodyStyle.None, "visibility"), + // fn with expanded modifiers: async, const, unsafe, default, extern (ABI optional) / + // 拡張修飾子: async, const, unsafe, default, extern(ABI は省略可) + new("function", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?(?:(?:async|const|unsafe|default|extern(?:\s+""[^""]+"")?)\s+)*fn\s+(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("class", new Regex(@"\b(?unsafe)\s*\{", RegexOptions.Compiled), BodyStyle.Brace), + new("struct", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?(?:struct|union)\s+(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("enum", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?enum\s+(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Enum variants / `Red`, `Ok(T)`, `Circle { radius: f64 }`, `Point` + new("property", new Regex(@"^\s{4,}(?[A-Z][A-Za-z0-9_]*)\s*(?:\([^()\r\n]*\)|\{[^{}\r\n]*\})?\s*,?\s*$", RegexOptions.Compiled), BodyStyle.None), + new("protocol", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?trait\s+(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // impl Trait for Type / `unsafe impl Trait for Type` should attach to the type being extended. + // `impl Trait for Type` / `unsafe impl Trait for Type` は、拡張先の型に紐づける。 + new("class", new Regex(@"^\s*(?:unsafe\s+)?impl(?:<[^>]+>)?\s+.+?\s+for\s+(?:(?:r#)?\w+::)*(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*(?:unsafe\s+)?impl(?:<[^>]+>)?\s+(?:(?:r#)?\w+::)*(?(?:r#)?\w+)(?!\s+for\b)", RegexOptions.Compiled), BodyStyle.Brace), + // file module declarations and inline modules / ファイルモジュール宣言とインラインモジュール + new("file_module", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?mod\s+(?(?:r#)?\w+)\s*;", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("namespace", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?mod\s+(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Trait associated type defaults / trait 関連型のデフォルト + new("property", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?type\s+(?(?:r#)?\w+)(?:\s*<[^=>]+>)?(?:\s*:\s*[^=;]+)?\s*=\s*(?[^;]+)", RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), + // type alias / 型エイリアス + new("import", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?type\s+(?(?:r#)?\w+)(?:\s*<[^=]+>)?", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("import", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?use\s+(?.+);", RegexOptions.Compiled), BodyStyle.None, "visibility"), + ], + ["java"] = + [ + // Package declaration / package 宣言 + new("namespace", new Regex($@"^\s*package\s+(?{JavaQualifiedIdentifierPattern})\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + // Module declaration (Java 9+ module-info.java) / モジュール宣言(Java 9+ の module-info.java) + new("namespace", new Regex($@"^\s*(?:open\s+)?module\s+(?{JavaQualifiedIdentifierPattern})\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + // Annotation type (@interface) / アノテーション型 + new("class", new Regex($@"^\s*(?public|private|protected)?\s*@interface\s+(?{JavaIdentifierPattern})", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, "visibility"), + // record (Java 16+) — must come before general class pattern / record は一般クラスパターンの前に配置 + new("class", new Regex($@"^\s*(?public|private|protected)?\s*(?:(?:static|final|abstract|sealed|non-sealed|strictfp)\s+)*record\s+(?{JavaIdentifierPattern})", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, "visibility"), + // Interface / インターフェース + new("interface", new Regex($@"^\s*(?public|private|protected)?\s*(?:(?:static|abstract|sealed|non-sealed|strictfp)\s+)*interface\s+(?{JavaIdentifierPattern})", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, "visibility"), + // Enum / enum + new("enum", new Regex($@"^\s*(?public|private|protected)?\s*(?:(?:static|strictfp)\s+)*enum\s+(?{JavaIdentifierPattern})", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, "visibility"), + // Class — with extended modifiers (final, sealed, static, abstract, strictfp) + // クラス — 拡張修飾子対応(final, sealed, static, abstract, strictfp) + new("class", new Regex($@"^\s*(?public|private|protected)?\s*(?:(?:static|final|abstract|sealed|non-sealed|strictfp)\s+)*class\s+(?{JavaIdentifierPattern})", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, "visibility"), + // Static final field (Java equivalent of C# const) — order-flexible and annotation-friendly. + // static final フィールド — 語順柔軟かつアノテーション併用にも対応。 + new("function", new Regex($@"^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?public|private|protected)?\s*(?=(?:(?:static|final|transient|volatile)\s+)*static\b)(?=(?:(?:static|final|transient|volatile)\s+)*final\b)(?:(?:static|final|transient|volatile)\s+)*(?{JavaReturnTypePattern})\s+(?[A-Z_]\w*)\s*=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, "visibility", "returnType"), + // Method with return type — expanded modifiers (default, native, synchronized, final) + // 戻り値型付きメソッド — 拡張修飾子対応(default, native, synchronized, final) + new("function", new Regex($@"^\s*(?!(?:return|throw|new|if|for|while|switch|do|case|else|try|catch|finally|synchronized|break|continue|yield|assert)\b)(?:@\w+(?:\([^)]*\))?\s+)*(?public|private|protected)?\s*(?:(?:static|abstract|synchronized|final|default|native|strictfp)\s+)*(?!(?:record)\b){JavaMethodTypeParameterPattern}(?{JavaReturnTypePattern})\s+(?{JavaIdentifierPattern})\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, "visibility", "returnType"), + // Enum members are extracted by ExtractJavaEnumMembers using a body-scoped scanner, + // which handles any indent style (tab, 2-space, 4-space) and skips member-like lines + // outside the enum body (e.g. `\tRED();` method calls inside a class body). + // enum メンバーは ExtractJavaEnumMembers の body-scoped scanner で抽出する。 + // 任意のインデントスタイル(タブ、2スペース、4スペース)に対応しつつ、enum 本体外の + // メンバー風の行(例: クラス本体内の `\tRED();` メソッド呼び出し)を誤検出しない。 + new("import", new Regex(@"^\s*import\s+(?.+);", RegexOptions.Compiled), BodyStyle.None), + ], + ["kotlin"] = + [ + // Companion object / コンパニオンオブジェクト + new("class", new Regex($@"^\s*companion\s+object(?:\s+(?{KotlinIdentifierPattern}))?", RegexOptions.Compiled), BodyStyle.Brace), + // Interface / インターフェース + // Kotlin fun interface / Kotlin の fun interface も interface として扱う。 + new("interface", new Regex($@"^\s*(?public|private|protected|internal)?\s*(?:(?:sealed|expect|actual)\s+)*(?:fun\s+)?interface\s+(?{KotlinIdentifierPattern})", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Enum class / enum クラス + new("enum", new Regex($@"^\s*(?public|private|protected|internal)?\s*(?:(?:expect|actual)\s+)*enum\s+class\s+(?{KotlinIdentifierPattern})", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Class/object with expanded modifiers: data, sealed, value, inline, inner, annotation, expect, actual + // クラス/オブジェクト — 拡張修飾子対応: data, sealed, value, inline, inner, annotation, expect, actual + new("class", new Regex($@"^\s*(?public|private|protected|internal)?\s*(?:(?:abstract|data|sealed|open|inner|value|inline|annotation|expect|actual)\s+)*(?:class|object)\s+(?{KotlinIdentifierPattern})", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Function / 関数 (including extension, secondary constructor, override, and abstract forms) + // 関数 — 拡張・セカンダリコンストラクタ・override・abstract 形を含む + new("function", new Regex($@"^\s*(?public|private|protected|internal)?\s*(?:(?:suspend|inline|infix|operator|tailrec|external|expect|actual|abstract|override|open|final)\s+)*fun\s+(?:<[^>]+>\s+)?(?:{KotlinIdentifierPattern}(?:<[^>]+>)?\.)?(?{KotlinIdentifierPattern})\s*[\(<](?:.*?\))?(?::\s*(?[^ {{=]+))?", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), + // Secondary constructor / セカンダリコンストラクタ + new("function", new Regex(@"^\s*(?public|private|protected|internal)?\s*constructor\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Enum entry / enum エントリ + new("property", new Regex($@"^\s{{2,}}(?(?:[A-Z][A-Z0-9_]*|`[^`\r\n]+`))\s*(?:\((?[^)]*)\))?\s*(?:,|\{{|;)?\s*$", RegexOptions.Compiled), BodyStyle.Brace, "returnType"), + // Top-level val/var property / トップレベルプロパティ + new("property", new Regex($@"^\s*(?public|private|protected|internal)?\s*(?:(?:const|lateinit|override)\s+)?(?:val|var)\s+(?{KotlinIdentifierPattern})\s*[=:]", RegexOptions.Compiled), BodyStyle.None, "visibility"), + // Type alias / 型エイリアス + new("import", new Regex($@"^\s*(?public|private|protected|internal)?\s*typealias\s+(?{KotlinIdentifierPattern})(?:\s*<[^=]+>)?\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("import", new Regex(@"^\s*import\s+(?.+)", RegexOptions.Compiled), BodyStyle.None), + ], + ["ruby"] = + [ + // attr_accessor/attr_reader/attr_writer as property declarations / プロパティ宣言 + new("property", new Regex(@"^\s*attr_(?:accessor|reader|writer)\s+:(?\w+)", RegexOptions.Compiled), BodyStyle.None), + // alias_method / alias — capture the introduced method name for navigation + new("function", new Regex(@"^\s*alias_method\b\s+:?(?\w+[?!=]?)\s*,\s*:?\w+[?!=]?", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*alias\b\s+:?(?\w+[?!=]?)\s+:?\w+[?!=]?", RegexOptions.Compiled), BodyStyle.None), + // scope/has_many/belongs_to (Rails DSL) — extracted as function for navigation + new("function", new Regex(@"^\s*(?:scope|has_many|has_one|belongs_to)\s+:(?\w+)", RegexOptions.Compiled), BodyStyle.None), + new("property", new Regex(@"^\s*enum\s+:(?\w+)", RegexOptions.Compiled), BodyStyle.None), + new("property", new Regex(@"^\s*attribute\s+:(?\w+)", RegexOptions.Compiled), BodyStyle.None), + new("property", new Regex(@"^\s*store_accessor\s+:\w+\s*,\s*:(?\w+)", RegexOptions.Compiled), BodyStyle.None), + new("namespace", new Regex(@"^\s*namespace\s+:(?\w+)\b.*\bdo\b", RegexOptions.Compiled), BodyStyle.RubyEnd), + new("function", new Regex(@"^\s*factory\s+:(?\w+)\b.*\bdo\b", RegexOptions.Compiled), BodyStyle.RubyEnd), + new("function", new Regex(@"^\s*shared_examples(?:_for)?\s+(?['""])(?[^'""]+)\k\s*do\b", RegexOptions.Compiled), BodyStyle.RubyEnd), + new("property", new Regex(@"^\s*subject\s*\(\s*:(?\w+)\s*\)\s*do\b", RegexOptions.Compiled), BodyStyle.RubyEnd), + new("property", new Regex(@"^\s*let!?\s*\(\s*:(?\w+)\s*\)\s*do\b", RegexOptions.Compiled), BodyStyle.RubyEnd), + new("function", new Regex(@"^\s*task\s+(?::(?\w+)|(?\w+)\s*:)", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*(?[A-Z][A-Za-z0-9_]*)\s*=\s*Class\.new\b.*\bdo\b", RegexOptions.Compiled), BodyStyle.RubyEnd), + new("class", new Regex(@"^\s*(?[A-Z][A-Za-z0-9_]*)\s*=\s*Struct\.new\b.*\bdo\b", RegexOptions.Compiled), BodyStyle.RubyEnd), + new("property", new Regex(@"^\s*(?[A-Z][A-Za-z0-9_]*)\s*=", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*(?:(?:private|protected|public)\s+)?def\s+(?:(?:self|[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\.)?(?\[\]=?|\*\*|<<|>>|<=>|===|==|!=|!~|=~|<=|>=|[+\-*/%&|^~<>]=?|[+\-]@|!)", RegexOptions.Compiled), BodyStyle.RubyEnd), + new("function", new Regex(@"^\s*(?:(?:private|protected|public)\s+)?def\s+(?:(?:self|[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\.)?(?\w+[?!=]?)", RegexOptions.Compiled), BodyStyle.RubyEnd), + new("class", new Regex(@"^\s*class\s+(?[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)", RegexOptions.Compiled), BodyStyle.RubyEnd), + new("class", new Regex(@"^\s*module\s+(?[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)", RegexOptions.Compiled), BodyStyle.RubyEnd), + new("import", new Regex(@"^\s*require(?:_relative)?\s+(?.+)", RegexOptions.Compiled), BodyStyle.None), + ], + ["crystal"] = + [ + new("namespace", new Regex(@"^\s*module\s+(?[A-Z]\w*(?:::[A-Z]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), + new("class", new Regex(@"^\s*(?:abstract\s+)?class\s+(?[A-Z]\w*(?:::[A-Z]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), + new("struct", new Regex(@"^\s*struct\s+(?[A-Z]\w*(?:::[A-Z]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), + new("enum", new Regex(@"^\s*enum\s+(?[A-Z]\w*(?:::[A-Z]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), + new("function", new Regex(@"^\s*(?:(?:private|protected)\s+)*abstract\s+def\s+(?:self\.)?(?[A-Za-z_]\w*[?!=]?)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?:private\s+|protected\s+)?def\s+(?:self\.)?(?[A-Za-z_]\w*[?!=]?)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), + new("function", new Regex(@"^\s*macro\s+(?[A-Za-z_]\w*[?!=]?)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), + new("typealias", new Regex(@"^\s*alias\s+(?[A-Z]\w*)\s*=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*require\s+(?.+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["groovy"] = + [ + new("namespace", new Regex(@"^\s*package\s+(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("interface", new Regex(@"^\s*(?:(?:public|private|protected|static|abstract)\s+)*(?:interface|trait)\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("enum", new Regex(@"^\s*(?:(?:public|private|protected|static)\s+)*enum\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("class", new Regex(@"^\s*(?:(?:public|private|protected|static|abstract|final)\s+)*class\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("function", new Regex(@"^\s*(?:@[A-Za-z_$][\w.$]*(?:\s*\([^)\r\n]*\))?\s+)*(?!(?:if|for|while|switch|catch|return|throw|new)\b)(?:(?:public|private|protected|static|final|abstract|synchronized|native|strictfp)\s+)*(?:<[^(){}\r\n]+>\s+)?(?def|void|boolean|byte|char|short|int|long|float|double|BigDecimal|BigInteger|String|[A-Za-z_$][\w.$]*(?:\s*<[^(){}\r\n]+>)?(?:\s*\[\])*)\s+(?[A-Za-z_]\w*)\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("lambda", new Regex(@"^\s*(?:def\s+)?(?[A-Za-z_]\w*)\s*=\s*\{", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("import", new Regex(@"^\s*import\s+(?:static\s+)?(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\.\*)?)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["julia"] = + [ + new("namespace", new Regex(@"^\s*(?:baremodule|module)\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), + new("struct", new Regex(@"^\s*(?:mutable\s+)?struct\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), + new("type", new Regex(@"^\s*(?:abstract|primitive)\s+type\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), + new("function", new Regex(@"^\s*function\s+(?" + JuliaQualifiedCallableIdentifierPattern + @")\s*(?:\(|\{)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), + new("function", new Regex(@"^\s*macro\s+(?[A-Za-z_]\w*)\s*(?:\(|$)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), + new("function", new Regex(@"^\s*(?" + JuliaQualifiedCallableIdentifierPattern + @")\s*\([^)\r\n]*\)\s*(?:where\s*(?:\{[^}\r\n]*\}|[A-Za-z_]\w*)\s*)?=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.JuliaShortFunction), + new("property", new Regex(@"^\s*const\s+(?[A-Z_]\w*)\s*=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*(?:using|import)\s+(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["tcl"] = + [ + new("namespace", new Regex(@"^\s*namespace\s+eval\s+(?[A-Za-z_:][\w:.-]*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex(@"^\s*oo::class\s+create\s+(?[A-Za-z_:][\w:.-]*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*proc\s+(?[A-Za-z_:][\w:.-]*)\s+", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*(?:variable|set)\s+(?[A-Za-z_:][\w:.-]*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*package\s+(?:require|provide)\s+(?[A-Za-z_:][\w:.-]*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["ada"] = + [ + new("namespace", new Regex(@"^\s*package\s+(?:body\s+)?(?[A-Za-z]\w*(?:\.[A-Za-z]\w*)*)\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.AdaEnd), + new("type", new Regex(@"^\s*(?:subtype|type)\s+(?[A-Za-z]\w*)\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("type", new Regex(@"^\s*(?:task|protected)\s+(?:type\s+)?(?[A-Za-z]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.AdaEnd), + new("function", new Regex(@"^\s*(?:(?:overriding|not\s+overriding)\s+)?(?:function|procedure)\s+(?:(?:[A-Za-z]\w*)\.)*(?[A-Za-z]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.AdaEnd), + new("import", new Regex(@"^\s*with\s+(?[A-Za-z]\w*(?:\.[A-Za-z]\w*)*)\s*;", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["d"] = + [ + new("namespace", new Regex(@"^\s*module\s+(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("interface", new Regex(@"^\s*(?:(?:public|private|protected|package|static|abstract|extern)\s+)*interface\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("class", new Regex(@"^\s*(?:(?:public|private|protected|package|static|abstract|final|extern)\s+)*class\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("struct", new Regex(@"^\s*(?:(?:public|private|protected|package|static|extern)\s+)*struct\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("union", new Regex(@"^\s*(?:(?:public|private|protected|package|static|extern)\s+)*union\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("enum", new Regex(@"^\s*(?:(?:public|private|protected|package|static)\s+)*enum\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("typealias", new Regex(@"^\s*(?:alias|typedef)\s+(?[A-Za-z_]\w*)\s*=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?!(?:if|for|while|switch|catch|return|throw|new|assert|version|debug)\b)(?:(?:public|private|protected|package|static|extern|export|final|abstract|override|synchronized|pure|nothrow|@safe|@trusted|@system)\s+)*(?(?:auto|void|bool|byte|ubyte|short|ushort|int|uint|long|ulong|cent|ucent|float|double|real|char|wchar|dchar|string|[A-Za-z_][\w.]*)(?:\s*[*\[\]])*)\s+(?[A-Za-z_]\w*)\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("import", new Regex(@"^\s*import\s+(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["nim"] = + [ + new("type", new Regex(@"^\s*type\s+(?[A-Za-z_]\w*)\*?\s*=\s*(?:ref\s+)?(?:object|enum|tuple|distinct)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), + new("type", new Regex(@"^\s+(?[A-Za-z_]\w*)\*?\s*=\s*(?:ref\s+)?(?:object|enum|tuple|distinct)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), + new("function", new Regex(@"^\s*(?:proc|func|method|iterator|template|macro|converter)\s+(?`[^`\r\n]+`|[A-Za-z_]\w*)\*?", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), + new("property", new Regex(@"^\s*(?:const|let|var)\s+(?[A-Za-z_]\w*)\*?\s*(?::|=)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*(?:import|include)\s+(?[A-Za-z_][\w./]*(?:\s*,\s*[A-Za-z_][\w./]*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*from\s+(?[A-Za-z_][\w./]*)\s+import\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["perl"] = + [ + // Perl package declarations / Perl の package 宣言 + new("namespace", new Regex(@"^\s*package\s+(?" + PerlQualifiedIdentifierPattern + @")\b(?:\s+v?[\d._]+)?\s*\{", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("namespace", new Regex(@"^\s*package\s+(?" + PerlQualifiedIdentifierPattern + @")\b(?:\s+v?[\d._]+)?\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + // Perl class feature declarations / Perl class feature の宣言 + new("class", new Regex(@"^\s*class\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("interface", new Regex(@"^\s*role\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + // Perl constants are compile-time subroutines, so expose them as functions for navigation. + // Perl constant はコンパイル時 subroutine なので、ナビゲーション用に function として出す。 + new("function", new Regex(@"^\s*use\s+constant\s+(?" + PerlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + // Perl module imports / Perl の module import + new("import", new Regex(@"^\s*use\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*require\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + // Moose/Moo attributes / Moose/Moo の属性 + new("property", new Regex(@"^\s*has\s+(?['""]?)\+?(?" + PerlIdentifierPattern + @")\k\s*=>", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + // Package variables / package 変数 + new("property", new Regex(@"^\s*our\s+[$@%](?" + PerlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + // Perl class feature fields / Perl class feature の field + new("property", new Regex(@"^\s*field\s+[$@%](?" + PerlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + // Perl subroutines / Perl の subroutine + new("function", new Regex(@"^\s*(?:(?:my|state)\s+)?sub\s+(?" + PerlQualifiedIdentifierPattern + @")\b(?:\s*:[^{;]+)?", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("function", new Regex(@"^\s*(?:method|fun)\s+(?" + PerlIdentifierPattern + @")\b(?:\s*:[^{;]+)?", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + ], + ["matlab"] = + [ + new("class", new Regex(@"^\s*classdef\s*(?:\([^)]*\)\s*)?(?[A-Za-z]\w*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), + new("function", new Regex(@"^\s*function\s+(?:(?:\[[^\]]+\]|[A-Za-z]\w*)\s*=\s*)?(?[A-Za-z]\w*)\s*(?:\(|$)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), + new("import", new Regex(@"^\s*import\s+(?[A-Za-z]\w*(?:\.[A-Za-z*]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["prolog"] = + [ + new("namespace", new Regex(@"^\s*:-\s*module\s*\(\s*(?[a-z][A-Za-z0-9_]*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*:-\s*use_module\s*\(\s*(?[a-z][A-Za-z0-9_]*(?:\([^)]*\))?)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?[a-z][A-Za-z0-9_]*)\s*(?:\([^\r\n]*\))?\s*(?::-|-->|\.(?=\s*(?:$|:-|[a-z][A-Za-z0-9_]*\s*\(\s*$|[a-z][A-Za-z0-9_]*(?:\s*\([^)]*\))?\s*(?::-|-->|\.))))", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["ambiguous_pl"] = + [ + // Keep ambiguous .pl files structured without choosing Perl or Prolog prematurely. + // .pl の判定が曖昧でも Perl / Prolog のどちらかへ早計に固定せず、構造を保持する。 + new("namespace", new Regex(@"^\s*package\s+(?" + PerlQualifiedIdentifierPattern + @")\b(?:\s+v?[\d._]+)?\s*\{", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("namespace", new Regex(@"^\s*package\s+(?" + PerlQualifiedIdentifierPattern + @")\b(?:\s+v?[\d._]+)?\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex(@"^\s*class\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("interface", new Regex(@"^\s*role\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("function", new Regex(@"^\s*use\s+constant\s+(?" + PerlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*use\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*require\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*our\s+[$@%](?" + PerlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?:(?:my|state)\s+)?sub\s+(?" + PerlQualifiedIdentifierPattern + @")\b(?:\s*:[^{;]+)?", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("function", new Regex(@"^\s*(?:method|fun)\s+(?" + PerlIdentifierPattern + @")\b(?:\s*:[^{;]+)?", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("namespace", new Regex(@"^\s*:-\s*module\s*\(\s*(?[a-z][A-Za-z0-9_]*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*:-\s*use_module\s*\(\s*(?[a-z][A-Za-z0-9_]*(?:\([^)]*\))?)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?[a-z][A-Za-z0-9_]*)\s*(?:\([^\r\n]*\))?\s*(?::-|-->|\.(?=\s*(?:$|:-|[a-z][A-Za-z0-9_]*\s*\(\s*$|[a-z][A-Za-z0-9_]*(?:\s*\([^)]*\))?\s*(?::-|-->|\.))))", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["c"] = + [ + new("function", new Regex(CFunctionStartBlacklistPattern + CFunctionReturnTypePattern + CFunctionNameBlacklistPattern + @"(?\w+)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + // #define macros / #define マクロ + new("function", new Regex(@"^\s*#\s*define\s+(?[A-Za-z_]\w*)\(", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*#\s*define\s+(?[A-Za-z_]\w*)(?=\s|$)", RegexOptions.Compiled), BodyStyle.None), + new("struct", new Regex(@"^\s*typedef\s+struct\s+(?:\w+\s*)?(?:\*+\s*)?(?\w+)\s*;", RegexOptions.Compiled), BodyStyle.None), + new("struct", new Regex(@"^\s*(?:typedef\s+)?struct\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("union", new Regex(@"^\s*typedef\s+union\s+(?:\w+\s*)?(?:\*+\s*)?(?\w+)\s*;", RegexOptions.Compiled), BodyStyle.None), + new("union", new Regex(@"^\s*(?:typedef\s+)?union\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("enum", new Regex(@"^\s*typedef\s+enum\s+(?:\w+\s+)?(?\w+)\s*;", RegexOptions.Compiled), BodyStyle.None), + new("enum", new Regex(@"^\s*(?:typedef\s+)?enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("import", new Regex(@"^\s*#\s*(?:include(?:_next)?|import)\s+(?:<(?[^>]+)>|""(?[^""]+)""|(?[^\s]+))", RegexOptions.Compiled), BodyStyle.None), + ], + ["cpp"] = + [ + new("namespace", new Regex(CppFunctionStartBlacklistPattern + @"(?:export\s+)?module\s+(?[\w.]+(?::[\w.]+)?)\b", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(CppFunctionStartBlacklistPattern + @"(?:export\s+)?import\s+(?:<(?[^>\r\n]+)>|""(?[^""\r\n]+)""|(?:?[A-Za-z_]\w*(?:[.:][A-Za-z_]\w*)*))\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("namespace", new Regex(CppFunctionStartBlacklistPattern + @"inline\s+namespace\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("interface", new Regex(CppFunctionStartBlacklistPattern + CppTemplatePrefixPattern + @"(?:export\s+)?concept\s+(?\w+)\b", RegexOptions.Compiled), BodyStyle.None), + new("specialization", new Regex(CppFunctionStartBlacklistPattern + @"\s*template\s*<[^>]*>\s*(?:class|struct|union)\s+(?(?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)\s*<[^;{}]+>", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("specialization", new Regex(CppFunctionStartBlacklistPattern + @"\s*template\s*<>\s*" + CppAttributePrefixPattern + @"(?:extern\s+""(?:C|C\+\+)""\s*)?" + CppAttributePrefixPattern + @"(?:(?(?:(?:" + CppFunctionReturnTypeAtomPattern + @")[\s*&]+)+))?(?:(?:[\w:<>]+\s*::\s*)+)?" + CFunctionNameBlacklistPattern + @"(?~?\w+|operator(?:\s*\(\)|\s*\[\]|\s*[^\s(]+(?:\s+[^\s(]+)?))\s*<[^>\r\n]+>\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("function", new Regex(CppFunctionStartBlacklistPattern + CppTemplatePrefixPattern + CppAttributePrefixPattern + @"(?:extern\s+""(?:C|C\+\+)""\s*)?" + CppAttributePrefixPattern + @"(?:(?(?:(?:" + CppFunctionReturnTypeAtomPattern + @")[\s*&]+)+))?(?:(?:[\w:<>]+\s*::\s*)+)?" + CFunctionNameBlacklistPattern + @"(?~?\w+|operator(?:\s*\(\)|\s*\[\]|\s*[^\s(]+(?:\s+[^\s(]+)?))(?:\s*<[^>]+>)?\s*\(", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + // Type alias / 型エイリアス + new("import", new Regex(CppFunctionStartBlacklistPattern + @"using\s+enum\s+(?(?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(CppFunctionStartBlacklistPattern + @"template\s*<[^>]+>\s*(?:export\s+)?using\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(CppFunctionStartBlacklistPattern + CppTemplatePrefixPattern + @"(?:export\s+)?using\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(CppFunctionStartBlacklistPattern + @"template\s*<[^>]+>\s*(?:export\s+)?using\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.Brace), + new("import", new Regex(CppFunctionStartBlacklistPattern + CppTemplatePrefixPattern + @"(?:export\s+)?using\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.Brace), + new("import", new Regex(@"^\s*typedef\s+(?![^;]*\().*\b(?\w+)\s*;", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(@"^\s*typedef\s+(?![^;]*\().*\b(?\w+)\s*;", RegexOptions.Compiled), BodyStyle.Brace), + // #define macros / #define マクロ + new("function", new Regex(@"^\s*#\s*define\s+(?[A-Za-z_]\w*)\(", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*#\s*define\s+(?[A-Za-z_]\w*)(?=\s|$)", RegexOptions.Compiled), BodyStyle.None), + new("property", new Regex(@"^(?:export\s+)?(?:(?:inline|static)\s+)*constexpr\s+(?(?:[\w:<>~]+(?:\s*[*&])?\s+)+)(?(?:[A-Z_]\w*|k[A-Z]\w*))\s*=", RegexOptions.Compiled), BodyStyle.None, ReturnTypeGroup: "returnType"), + new("property", new Regex(CppFunctionStartBlacklistPattern + CppTemplatePrefixPattern + @"(?(?:[\w:<>~]+[\s*&]+)+)(?:(?:[\w:<>]+\s*::\s*)+)(?\w+)\s*=\s*[^;]+;", RegexOptions.Compiled), BodyStyle.None, ReturnTypeGroup: "returnType"), + new("class", new Regex(CppFunctionStartBlacklistPattern + @"\s*(?:export\s+)?" + CppTemplatePrefixPattern + @"class\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("struct", new Regex(CppFunctionStartBlacklistPattern + @"\s*(?:export\s+)?" + CppTemplatePrefixPattern + @"struct\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("union", new Regex(CppFunctionStartBlacklistPattern + @"\s*(?:export\s+)?" + CppTemplatePrefixPattern + @"union\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("namespace", new Regex(@"^\s*(?:export\s+)?namespace\s+(?!\w+\s*=)(?\w+(?:::\w+)*)", RegexOptions.Compiled), BodyStyle.Brace), + new("enum", new Regex(@"^\s*(?:export\s+)?(?:typedef\s+)?enum\s+(?:class\s+)?(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("import", new Regex(@"^\s*#\s*(?:include|import)\s+(?:<(?[^>]+)>|""(?[^""]+)""|(?[^\s]+))", RegexOptions.Compiled), BodyStyle.None), + ], + ["php"] = + [ + // Variable-bound closures / 変数に束縛されたクロージャ + new("function", new Regex(@"^\s*\$(?\w+)\s*=\s*(?:static\s+)?function\s*\(", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*\$(?\w+)\s*=\s*(?:static\s+)?fn\s*\(", RegexOptions.Compiled), BodyStyle.None), + // Const declaration / 定数宣言 + new("function", new Regex(@"^\s*define\s*\(\s*['""](?[A-Za-z_]\w*)['""]\s*,", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?:(?public|private|protected)\s+)?const\s+(?\??[A-Za-z_\\][\w\\]*(?:\s*[|&]\s*\??[A-Za-z_\\][\w\\]*)*)\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility", ReturnTypeGroup: "returnType"), + new("function", new Regex(@"^\s*(?:(?public|private|protected)\s+)?const\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility"), + // Class property declarations / クラスプロパティ宣言 + new("property", new Regex(@"^\s*(?:(?public|private|protected|var)\s+)(?:(?:static|readonly)\s+)*(?:(?\??[A-Za-z_\\][\w\\]*(?:\s*[|&]\s*\??[A-Za-z_\\][\w\\]*)*)\s+)?\$(?\w+)\b", RegexOptions.Compiled), BodyStyle.None, "visibility", ReturnTypeGroup: "returnType"), + new("function", new Regex(@"^\s*(?:(?:(?public|private|protected)|static|abstract|final)\s+)*function\s+(?\w+)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Class with expanded modifiers: abstract, final, readonly (PHP 8.2+) + // 拡張修飾子対応: abstract, final, readonly (PHP 8.2+) + new("class", new Regex(@"^\s*(?:(?:abstract|final|readonly)\s+)*class\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("interface", new Regex(@"^\s*interface\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("trait", new Regex(@"^\s*trait\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("enum", new Regex(@"^\s*enum\s+(?\w+)(?:\s*:\s*(?[A-Za-z_\\][\w\\]*))?", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("property", new Regex(@"^\s*case\s+(?\w+)(?:\s*=\s*(?[^;]+?))?\s*;", RegexOptions.Compiled), BodyStyle.None, ReturnTypeGroup: "returnType"), + // Namespace / 名前空間 + new("namespace", new Regex(@"^\s*namespace\s+(?[\w\\]+)", RegexOptions.Compiled), BodyStyle.Brace), + ], + ["swift"] = + [ + // Swift function names may be ordinary identifiers or escaped identifiers + // wrapped in backticks (e.g. `func `repeat`() {}`). + // Swift の関数名は通常識別子に加えて、バッククォートでエスケープした識別子 + // (例: `func `repeat`() {}`)も取りうる。 + new("function", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:static|class|nonisolated|mutating|nonmutating|prefix|infix|postfix)\s+)*(?:override\s+)?func\s+(?`[^`]+`|\w+|[~!%^&*+\-=|/?<>.]+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("function", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:required|convenience|nonisolated|mutating|nonmutating|override)\s+)*(?init)(?:\?)?\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("function", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:nonisolated)\s+)*(?deinit)\s*(?:\{|$)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("function", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:static|class|nonisolated|mutating|nonmutating|override)\s+)*(?subscript)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("struct", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:final)\s+)*struct\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("enum", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:indirect\s+)?enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("property", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:indirect\s+)?case\s+(?\w+)(?(?:\s*(?:\([^:\r\n]*\))?(?:\s*=\s*(?(?:""(?:\\.|[^""\\])*""|[^,\r\n])+))?\s*(?:,\s*\w+(?:\s*\([^:\r\n]*\))?(?:\s*=\s*(?:""(?:\\.|[^""\\])*""|[^,\r\n])+)?)*)\s*)$", RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), + new("property", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:indirect\s+)?case\s+(?\w+)(?:\s*\([^)]*\))?(?:\s*=\s*(?.+?))?\s*$", RegexOptions.Compiled), BodyStyle.None, "visibility", ReturnTypeGroup: "returnType"), + new("protocol", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"protocol\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("associatedtype", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"associatedtype\s+(?\w+)", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("typealias", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"typealias\s+(?\w+)(?:\s*<[^=]+>)?\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("property", new Regex(@"^\s*" + SwiftAttributePattern + @"(?(?:public|private|internal|open|fileprivate|package)(?:\s*\(\s*set\s*\))?)?\s*" + SwiftAttributePattern + @"(?:(?:lazy|weak|unowned|final|static|class|nonisolated)\s+)*(?:let|var)\s+(?`[^`]+`|\w+)(?=\s*(?:[:=]|$))", RegexOptions.Compiled), BodyStyle.None, "visibility"), + // Extension declarations are important search anchors in Swift-heavy codebases. + // A dedicated parser keeps nested generic targets searchable even when the + // extension also carries protocol conformances or `where` clauses. + // extension 宣言は Swift コード検索における重要なアンカー。 + // 専用パーサにより、protocol conformance や `where` 句が付く場合でも + // ネストした generic target を検索対象として維持する。 + new("class", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:final)\s+)?extension\s+(?[^\r\n{]+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // actor (Swift 5.5+) / アクター + new("class", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:final|distributed)\s+)*(?:class|actor)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Type alias / 型エイリアス: backtick-escaped names and generic/where clauses. + new("typealias", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"typealias\s+(?`[^`]+`|\w+)(?=\s*(?:<|=|where\b|$))", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("function", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"macro\s+(?\w+)", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("interface", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"precedencegroup\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("function", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:prefix|infix|postfix)\s+operator\s+(?\S+)", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("import", new Regex(@"^\s*" + SwiftAttributePattern + @"(?:(?:public|private|internal|open|fileprivate|package)\s+)?import\s+(?.+)", RegexOptions.Compiled), BodyStyle.None), + ], + ["objc"] = + [ + new("class", new Regex(@"^\s*@interface\s+(?\w+)\b", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*@implementation\s+(?\w+)\b", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*@(?:interface|implementation)\s+(?\w+\s*\(\s*[^)]+?\s*\))\b", RegexOptions.Compiled), BodyStyle.Brace), + new("interface", new Regex(@"^\s*@protocol\s+(?\w+)\b", RegexOptions.Compiled), BodyStyle.Brace), + // Apple enum macros / Apple の enum マクロ + new("enum", new Regex(@"^\s*typedef\s+(?:NS_(?:CLOSED_)?ENUM|NS_EXTENSIBLE_ENUM)\s*\([^,]+,\s*(?\w+)\s*\)", RegexOptions.Compiled), BodyStyle.Brace), + new("enum", new Regex(@"^\s*typedef\s+NS_OPTIONS\s*\([^,]+,\s*(?\w+)\s*\)", RegexOptions.Compiled), BodyStyle.Brace), + new("enum", new Regex(@"^\s*typedef\s+NS_ERROR_ENUM\s*\([^,]+,\s*(?\w+)\s*\)", RegexOptions.Compiled), BodyStyle.Brace), + new("enum", new Regex(@"^\s*typedef\s+(?:CF_ENUM|CF_OPTIONS)\s*\([^,]+,\s*(?\w+)\s*\)", RegexOptions.Compiled), BodyStyle.Brace), + new("property", new Regex(@"^\s*@property\b(?:\s*\([^)]*\))?.*?(?\w+)\s*;", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*[+-]\s*\([^)]*\)\s*(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("import", new Regex(@"^\s*#(?:import|include)\s+[<""](?[^"">]+)[>""]", RegexOptions.Compiled), BodyStyle.None), + ], + ["fsharp"] = + [ + new("function", new Regex(@"^\s*let!?\s+(?:(?:rec|mutable|inline|private|internal|public)\s+)*(?(?:``[^`]+``|\w+))(?:\s+(?:\w+|\())?", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*use!?\s+(?(?:``[^`]+``|\w+))\s*(?:=|:)", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*and\s+(?(?:``[^`]+``|\w+))\s+(?:``[^`]+``|\w+|\()", RegexOptions.Compiled), BodyStyle.None), + new("struct", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^>]+>)?\s*=\s*\{", RegexOptions.Compiled), BodyStyle.Brace), + new("interface", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^>]+>)?\s*=\s*interface\b", RegexOptions.Compiled), BodyStyle.None), + new("struct", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^>]+>)?\s*=\s*struct\b", RegexOptions.Compiled), BodyStyle.None), + new("delegate", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^>]+>)?\s*=\s*delegate\b", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^=]+?>)?(?:\s+when\b[^=]+)?\s*=\s*class\b", RegexOptions.Compiled), BodyStyle.None), + // Generic abbreviations such as `type Result<'T> = Choice<'T, string>` should not be + // mistaken for union cases just because the right-hand side starts with a capitalized + // type name. + // `type Result<'T> = Choice<'T, string>` のような generic abbreviation は、 + // 右辺が大文字始まりの型名でも union case と誤認しない。 + new("typealias", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))\s*<[^=]+?>\s*(?:when\b[^=]+)?\s*=\s*(?![^\r\n]*\|)(?!(?:class|delegate|interface|struct|enum|exception)\b)(?!\{)(?!\|)(?!\()", RegexOptions.Compiled), BodyStyle.None), + new("enum", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^>]+>)?\s*=\s*(?:\|?\s*[A-Z][\w']*\b(?:\s*\|[^=].*)?)", RegexOptions.Compiled), BodyStyle.Brace), + // Simple aliases without generic parameters stay searchable as `typealias`. + // generic 引数なしの単純な alias も `typealias` として検索可能にする。 + new("typealias", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))\s*=\s*(?![^\r\n]*\|)(?!(?:class|delegate|interface|struct|enum|exception)\b)(?!\{)(?!\|)(?!\()", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^>]+>)?(?:\s+when\b[^=]+)?\s*(?:\([^)]*\))\s*=", RegexOptions.Compiled), BodyStyle.None), + new("struct", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))\s*=\s*\{", RegexOptions.Compiled), BodyStyle.None), + new("enum", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))\s*=\s*(?:\|\s*)?\w+(?:\s*\|\s*\w+)+", RegexOptions.Compiled), BodyStyle.None), + new("exception", new Regex(@"^\s*exception\s+(?:(?:private|internal)\s+)?(?(?:``[^`]+``|\w+))", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))\s*=\s*(?!\{)(?!\|)(?!class\b)(?!delegate\b)(?!struct\b)(?!interface\b)(?!enum\b).+", RegexOptions.Compiled), BodyStyle.None), + new("namespace", new Regex(@"^\s*namespace\s+(?:(?:rec|global)\s+)*(?[\w.]+)", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(@"^\s*module\s+(?:(?:(?:private|internal)\s+|rec\s+))*(?:``[^`]+``|[\w.]+)\s*=\s*(?(?:``[^`]+``|[\w.]+))", RegexOptions.Compiled), BodyStyle.None), + new("namespace", new Regex(@"^\s*module\s+(?:(?:(?:private|internal)\s+|rec\s+))*(?(?:``[^`]+``|[\w.]+))", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*(?:(?private|internal|public)\s+)?override\s+(?:(?:this|_|\w+)\.)?(?(?:``[^`]+``|\w+))\s*(?:\(|=|:)", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("function", new Regex(@"^\s*(?:(?private|internal|public)\s+)?abstract\s+(?!member\b)(?(?:``[^`]+``|\w+))\s*:", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("property", new Regex(@"^\s*(?:(?private|internal|public)\s+)?(?:static\s+)?val\s+(?:mutable\s+)?(?(?:``[^`]+``|\w+))\s*:", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("property", new Regex(@"^\s*(?:(?private|internal|public)\s+)?(?:(?:static|abstract|override|default)\s+)*member\s+(?:(?:private|internal)\s+)?val\s+(?(?:``[^`]+``|\w+))(?=\s|\(|=|:|$)", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("function", new Regex(@"^\s*(?:(?private|internal|public)\s+)?(?:(?:static|abstract|override|default)\s+)*member\s+(?:(?:private|internal)\s+)?(?:(?:inline)\s+)?(?:(?:this|_|\w+)\.)?(?!val\b)(?(?:``[^`]+``|\w+))(?=\s|\(|=|:|$)", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("import", new Regex(@"^\s*open\s+(?:type\s+)?(?(?:``[^`]+``|[\w.]+))", RegexOptions.Compiled), BodyStyle.None), + ], + ["vb"] = + [ + new("namespace", new Regex(@"^\s*Namespace\s+(?(?:Global\.)?" + VbIdentifierPattern + @"(?:\." + VbIdentifierPattern + @")*)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.VisualBasicEnd), + new("delegate", new Regex(@$"^\s*(?:(?:{VbMemberModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?Delegate\s+(?:Sub|Function)\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, "visibility"), + new("function", new Regex(@$"^\s*(?:(?:{VbMemberModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:{VbMemberModifierPattern})\s+)*(?:Sub|Function)\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, "visibility"), + new("operator", new Regex(@$"^\s*(?:(?:{VbOperatorModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:{VbOperatorModifierPattern})\s+)*(?Operator\s+[^\s(]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.VisualBasicEnd, "visibility"), + new("property", new Regex(@$"^\s*(?:(?:Shared|Shadows)\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:Shared|Shadows)\s+)*Const\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, "visibility"), + new("property", new Regex(@$"^\s*(?:(?:Shared|Shadows|ReadOnly|WithEvents)\s+)*(?{VbVisibilityPattern})\s+(?:(?:Shared|Shadows|ReadOnly|WithEvents)\s+)*(?{VbIdentifierPattern})\s+As\s+", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, "visibility"), + new("property", new Regex(@$"^\s*(?:(?:{VbPropertyModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:{VbPropertyModifierPattern})\s+)*Property\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, "visibility"), + new("event", new Regex(@$"^\s*(?:(?:{VbEventModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:{VbEventModifierPattern})\s+)*Event\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, "visibility"), + new("interface", new Regex(@$"^\s*(?:(?:{VbTypeModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:{VbTypeModifierPattern})\s+)*Interface\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.VisualBasicEnd, "visibility"), + new("enum", new Regex(@$"^\s*(?:(?{VbVisibilityPattern})\s+)?Enum\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.VisualBasicEnd, "visibility"), + new("struct", new Regex(@$"^\s*(?:(?:Partial)\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:Partial)\s+)*Structure\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.VisualBasicEnd, "visibility"), + new("class", new Regex(@$"^\s*(?:(?:{VbTypeModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:{VbTypeModifierPattern})\s+)*(?:Class|Module)\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.VisualBasicEnd, "visibility"), + new("import", new Regex(@"^\s*Imports\s+<\s*xmlns:(?[A-Za-z_][\w.-]*)\s*=", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@$"^\s*Imports\s+(?{VbIdentifierPattern})\s*=", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*Imports\s+(?.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["scala"] = + [ + new("implicit", new Regex(@"^\s*(?private|protected)?\s*implicit\s+(?:override\s+)?(?:def|val|var|class)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("given", new Regex(@"^\s*(?private|protected)?\s*given\s+(?:(?\w+)\s*(?::|as)|(?[A-Z]\w*)\b)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("function", new Regex(@"^\s*(?private|protected)?\s*(?:override\s+)?def\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("interface", new Regex(@"^\s*(?private|protected)?\s*(?:sealed\s+)?trait\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("enum", new Regex(@"^\s*(?private|protected)?\s*enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("class", new Regex(@"^\s*(?private|protected)?\s*(?:abstract\s+|sealed\s+|final\s+)?(?:case\s+)?class\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("object", new Regex(@"^\s*(?private|protected)?\s*(?:sealed\s+|final\s+)?(?:case\s+)?object\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("import", new Regex(@"^\s*type\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(@"^\s*import\s+(?.+)", RegexOptions.Compiled), BodyStyle.None), + ], + ["haskell"] = + [ + new("function", new Regex(@"^(?:>\s+|\s*)(?[a-z_]\w*)\s+::", RegexOptions.Compiled), BodyStyle.None), + new("interface", new Regex(@"^\s*class\s+(?[A-Z]\w*)", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*(?:data|newtype|type)\s+(?[A-Z]\w*)", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(@"^\s*import\s+(?:qualified\s+)?(?[\w.]+)", RegexOptions.Compiled), BodyStyle.None), + ], + ["r"] = + [ + new("function", new Regex(@"^\s*`(?[^`]+)`\s*<[\w.]+)\s*<[^`]+)`\s*=\s*(?:function\s*\(|\\\s*\()", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*(?[\w.]+)\s*=\s*(?:function\s*\(|\\\s*\()", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*assign\s*\(\s*(?:x\s*=\s*)?['""](?[^'""]+)['""]\s*,\s*(?:value\s*=\s*)?(?:function\s*\(|\\\s*\()", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*(?:function\s*\(|\\\s*\()[^\r\n#]*(?:->>|->)\s*`(?[^`]+)`", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*(?:function\s*\(|\\\s*\()[^\r\n#]*(?:->>|->)\s*(?[\w.]+)", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*(?:(?:[\w.]+)::)?test_that\s*\(\s*['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*(?:(?:[\w.]+)::)?(?:describe|it)\s*\(\s*['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*output\$(?[\w.]+)\s*<[^'""]+)['""]\s*\]\s*\]\s*<[^`]+)`\s*(?:<[\w.]+)\s*(?:<[^'""]+)['""]|(?[\w.]+))", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*(?:(?:[\w.]+)::)?setIs\s*\(.*?\b(?:class2|to)\s*=\s*(?:['""](?[^'""]+)['""]|(?[\w.]+))", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*inherit\s*=\s*(?:c\(\s*)?(?:['""](?[^'""]+)['""]|(?[A-Z][\w.]*))", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*(?:(?:[\w.]+)::)?setValidity\s*\(\s*(?:(?:Class|class|classes|name)\s*=\s*)?(?:['""](?[^'""]+)['""]|(?[\w.]+))", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*(?:(?:[\w.]+)::)?(?:setGeneric|setGroupGeneric)\s*\(\s*(?:(?:f|generic|name)\s*=\s*)?['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*(?:(?:[\w.]+)::)?setMethod\s*\(\s*(?:(?:f|generic|name)\s*=\s*)?(?:['""](?[^'""]+)['""]|(?[\w.]+))\s*,", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*(?public|private|active)\s*=\s*list\(\s*(?[\w.]+)\s*=\s*function\s*\(", RegexOptions.Compiled), BodyStyle.None, "visibility"), + new("import", new Regex(@"^\s*(?:(?:[\w.]+)::)?(?:library|require)\s*\(\s*help\s*=\s*(?:['""](?[^'""]+)['""]|(?[\w.]+))", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(@"^\s*(?:(?:[\w.]+)::)?(?:library|require|requireNamespace)\s*\(\s*(?:(?:package|pkg)\s*=\s*)?(?:['""](?[^'""]+)['""]|(?[\w.]+))", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(@"^\s*(?:(?:[\w.]+)::)?(?:source|sys\.source)\s*\(\s*(?:file\s*=\s*)?['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.None), + ], + ["lua"] = + [ + new("function", new Regex(@"^\s*(?:local\s+)?function\s+(?[\w.:]+)\s*\(", RegexOptions.Compiled), BodyStyle.ElixirEnd), + new("function", new Regex(@"^\s*local\s+(?[\w]+)\s*=\s*function\s*\(", RegexOptions.Compiled), BodyStyle.ElixirEnd), + new("function", new Regex(@"^\s*(?[\w]+(?:[.:][\w]+)+)\s*=\s*function\s*\(", RegexOptions.Compiled), BodyStyle.ElixirEnd), + new("import", new Regex(@"^\s*(?:local\s+\w+\s*=\s*)?require\s*\(?['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.None), + ], + ["elixir"] = + [ + new("function", new Regex(@"^\s*(?:def|defp|defmacro|defguardp?)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.ElixirEnd), + new("class", new Regex(@"^\s*defmodule\s+(?[\w.]+)", RegexOptions.Compiled), BodyStyle.ElixirEnd), + new("interface", new Regex(@"^\s*defprotocol\s+(?[\w.]+)", RegexOptions.Compiled), BodyStyle.ElixirEnd), + new("protocol_impl", new Regex(@"^\s*defimpl\s+(?[\w.]+(?:\s*,\s*for:\s*(?:\[[^\]]+\]|[\w.{}]+))?)", RegexOptions.Compiled), BodyStyle.ElixirEnd), + new("import", new Regex(@"^\s*(?:import|alias|use|require)\s+(?[\w.]+)", RegexOptions.Compiled), BodyStyle.None), + ], + ["clojure"] = + [ + // Clojure forms are parenthesized, so use conservative line anchors. + // Clojure の form は括弧ベースなので、保守的な行アンカーだけを拾う。 + new("namespace", new Regex(@"^\s*\(\s*ns\s+(?[^\s\)\[\{]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex(@"^\s*\(\s*(?:defrecord|deftype)\s+(?[^\s\)\[\{]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("protocol", new Regex(@"^\s*\(\s*defprotocol\s+(?[^\s\)\[\{]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*\(\s*(?:defn-?|defmacro|defmulti|defmethod)\s+(?[^\s\)\[\{]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*\(\s*(?:def|defonce)\s+(?[^\s\)\[\{]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["erlang"] = + [ + new("namespace", new Regex(@"^\s*-module\s*\(\s*(?[a-z][\w@]*|'[^'\r\n]+')\s*\)\s*\.", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("struct", new Regex(@"^\s*-record\s*\(\s*(?[a-z][\w@]*|'[^'\r\n]+')\s*,", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("type", new Regex(@"^\s*-(?:type|opaque)\s+(?[a-z][\w@]*|'[^'\r\n]+')\s*(?:\(|::)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?[a-z][\w@]*|'[^'\r\n]+')\s*\([^)\r\n]*\)\s*(?:when\b[^-\r\n]*)?->", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*-(?:import|include(?:_lib)?)\s*\(\s*(?[^)\r\n]+)\)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["ocaml"] = + [ + new("namespace", new Regex(@"^\s*module\s+(?:type\s+)?(?[A-Z][A-Za-z0-9_']*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex(@"^\s*class(?:\s+type)?\s+(?[A-Za-z_][A-Za-z0-9_']*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("type", new Regex(@"^\s*type\s+(?:nonrec\s+)?(?:'[\w]+\s+)*(?[A-Za-z_][A-Za-z0-9_']*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*let\s+(?:rec\s+)?(?[A-Za-z_][A-Za-z0-9_']*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*val\s+(?[A-Za-z_][A-Za-z0-9_']*)\s*:", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*open\s+(?[A-Z][\w.']*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["raku"] = + [ + new("namespace", new Regex(@"^\s*(?:unit\s+)?(?:module|package)\s+(?[\w:.-]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("interface", new Regex(@"^\s*(?:unit\s+)?role\s+(?[\w:.-]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), + new("class", new Regex(@"^\s*(?:unit\s+)?(?:class|grammar)\s+(?[\w:.-]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), + new("enum", new Regex(@"^\s*enum\s+(?[\w:.-]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?:(?:my|our|multi|proto|only)\s+)*(?:sub|method|submethod|macro)\s+(?[\w:!?.-]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), + new("property", new Regex(@"^\s*(?:(?:my|our|state|constant)\s+)*(?[$@%&]\w[\w-]*)\s*=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["dart"] = + [ + new("function", new Regex(@"^\s*(?!return\b|await\b|const\b|new\b|throw\b|yield\b|if\b|else\b|for\b|while\b|switch\b|case\b|catch\b|do\b|try\b|finally\b|class\b|enum\b|mixin\b|extension\b|typedef\b|library\b|part\b|import\b|export\b)(?:(?:static|abstract|override|external)\s+)*(?\w[\w<>,\s\?]*?)\s+(?(?!if\b|else\b|for\b|while\b|switch\b|case\b|class\b|enum\b|mixin\b|extension\b|typedef\b|library\b|part\b|import\b|export\b|abstract\b|void\b|var\b|final\b|late\b|const\b|new\b|return\b|throw\b|yield\b|await\b|extends\b|implements\b|with\b|on\b|is\b|as\b|in\b|of\b|super\b|this\b)\w+)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "rt"), + new("function", new Regex(@"^\s*factory\s+(?[A-Z_]\w*(?:\.\w+)?)\s*\(", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*const\s+(?[A-Z_]\w*(?:\.\w+)?)\s*\((?=[^)]*(?:\bthis\b|\bsuper\b))", RegexOptions.Compiled), BodyStyle.None), + new("function", DartBareConstConstructorRegex, BodyStyle.None), + new("function", new Regex(@"^\s*(?[A-Z_]\w*(?:\.\w+)?)\s*\(", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*typedef\s+(?\w+)(?:<[^>]*>)?\s*=", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*typedef\s+(?:[\w<>,\[\]\?\.\s]+\s+)+(?\w+)\s*\(", RegexOptions.Compiled), BodyStyle.None), + new("enum", new Regex(@"^\s*enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*(?:abstract\s+)?(?:class|mixin)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*extension\s+(?\w+)\s+on\s+", RegexOptions.Compiled), BodyStyle.Brace), + new("import", new Regex(@"^\s*import\s+'(?[^']+)'", RegexOptions.Compiled), BodyStyle.None), + ], + ["pascal"] = + [ + new("namespace", new Regex(@"^\s*(?:unit|program|library|package)\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex(@"^\s*(?[A-Za-z_]\w*)\s*=\s*(?:class|object)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("struct", new Regex(@"^\s*(?[A-Za-z_]\w*)\s*=\s*record\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("interface", new Regex(@"^\s*(?[A-Za-z_]\w*)\s*=\s*interface\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("enum", new Regex(@"^\s*(?[A-Za-z_]\w*)\s*=\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?:(?:class|static)\s+)?(?:procedure|function|constructor|destructor)\s+(?:(?:[A-Za-z_]\w*)\.)?(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.PascalEnd), + new("property", new Regex(@"^\s*property\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*uses\s+(?.+?)(?:;|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["smalltalk"] = + [ + new("class", new Regex(@"^\s*(?:[A-Za-z_]\w*)\s+subclass:\s*#(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex(@"^\s*(?:Class\s+named:|Object\s+subclass:)\s*#(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?:[A-Za-z_]\w*)(?:\s+class)?\s*>>\s*(?[A-Za-z_]\w*:?(?:\s+[A-Za-z_]\w+\s+[A-Za-z_]\w*:)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.SmalltalkMethod), + ], + ["graphql"] = + [ + new("interface", new Regex(@"^\s*interface\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("enum", new Regex(@"^\s*enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*(?:type|union|scalar|input)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*(?:query|mutation|subscription)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*fragment\s+(?\w+)\s+on\s+\w+", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*directive\s+@(?\w+)", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*extend\s+(?:type|interface|input|enum)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*extend\s+(?:union|scalar)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*schema\s*\{", RegexOptions.Compiled), BodyStyle.Brace), + ], + ["gradle"] = + [ + new("function", new Regex(@"^\s*(?:task|def)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("import", new Regex(@"^\s*(?:apply\s+plugin\s*:\s*|id\s*[\s(]\s*)['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.None), + ], + ["makefile"] = + [ + new("property", new Regex(@"^(?[\w.-]+)\s*(?::=|::=|=|\?=|\+=)", RegexOptions.Compiled), BodyStyle.None), // Makefile variable assignments / Makefile変数代入 + new("rule", new Regex(@"^(?\.PHONY)\s*:(?!=|:=)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), // Makefile special-target metadata / Makefile特殊ターゲットメタデータ + new("function", new Regex(@"^(?!\.PHONY\s*:)(?[\w.%-]+)\s*:(?!=|:=)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), // Makefile targets / Makefileターゲット + ], + ["cmake"] = + [ + new("function", new Regex(@"^\s*(?:function|macro)\s*\(\s*(?[A-Za-z_][\w.-]*)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?:add_executable|add_library|add_custom_target)\s*\(\s*(?[A-Za-z_][\w.-]*)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*(?:set|option)\s*\(\s*(?[A-Za-z_][\w.-]*)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*(?:include|find_package)\s*\(\s*(?[A-Za-z_][\w.:+-]*)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["justfile"] = + [ + new("import", new Regex(@"^\s*(?:import|mod)\s+[""'](?[^""']+)[""']", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^(?[A-Za-z_][\w.-]*)\s*(?::=|=|\+=)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^(?[A-Za-z_][\w.-]*)(?:\s+[^:#\r\n]+)?\s*:(?![:=])", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["msbuild"] = [], + ["dockerfile"] = + [ + new("build_arg", new Regex(@"^\s*ARG\s+(?[A-Za-z_][A-Za-z0-9_]*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("environment", new Regex(@"^\s*ENV\s+(?[A-Za-z_][A-Za-z0-9_]*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("label", new Regex(@"^\s*LABEL\s+(?[A-Za-z0-9_.-]+)\s*=", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("label", new Regex(@"^\s*LABEL\s+(?[A-Za-z0-9_.-]+)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("expose", new Regex(@"^\s*EXPOSE\s+(?\d+(?:/(?:tcp|udp))?)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("user", new Regex(@"^\s*USER\s+(?[A-Za-z0-9_][A-Za-z0-9_.-]*(?::[A-Za-z0-9_][A-Za-z0-9_.-]*)?)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("workdir", new Regex(@"^\s*WORKDIR\s+(?\S+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("volume", new Regex(@"^\s*VOLUME\s+(?(?!\[)\S+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("stopsignal", new Regex(@"^\s*STOPSIGNAL\s+(?\S+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("stage", new Regex(@"^\s*FROM\s+(?:--platform=\S+\s+)?\S+\s+(?:AS|as)\s+(?[A-Za-z0-9_.-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), // Named stage / 名前付きステージ + new("base_image", new Regex(@"^\s*FROM\s+(?:--platform=\S+\s+)?(?\S+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), // Base image / ベースイメージ + ], + ["protobuf"] = + [ + new("class", new Regex(@"^\s*message\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("enum", new Regex(@"^\s*enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("namespace", new Regex(@"^\s*package\s+(?[\w.]+)\s*;", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*oneof\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*extend\s+(?[\w.]+)", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*service\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*rpc\s+(?\w+)", RegexOptions.Compiled), BodyStyle.None), + new("import", new Regex(@"^\s*import\s+""(?[^""]+)"";", RegexOptions.Compiled), BodyStyle.None), + ], + ["verilog"] = + [ + new("module", new Regex(@"^\s*(?:module|macromodule|primitive)\s+(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*function\s+(?:automatic\s+|static\s+)?(?:(?[\w$:\[\]\s]+?)\s+)?(?" + HdlIdentifierPattern + @")\s*(?:\(|;)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), + new("function", new Regex(@"^\s*task\s+(?:automatic\s+|static\s+)?(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*(?:parameter|localparam)\s+(?:type\s+)?" + HdlDeclaratorPrefixPattern + @"(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*(?:input|output|inout|wire|reg|logic)\s+" + HdlDeclaratorPrefixPattern + @"(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*`include\s+""(?[^""]+)""", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["systemverilog"] = + [ + new("module", new Regex(@"^\s*(?:module|macromodule|primitive|program)\s+(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("interface", new Regex(@"^\s*interface\s+(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("package", new Regex(@"^\s*package\s+(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex(@"^\s*(?:virtual\s+)?class\s+(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("enum", new Regex(@"^\s*typedef\s+enum\b[^\r\n]*\s+(?" + HdlIdentifierPattern + @")\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("struct", new Regex(@"^\s*typedef\s+struct\b[^\r\n]*\s+(?" + HdlIdentifierPattern + @")\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("typealias", new Regex(@"^\s*typedef\s+(?!(?:enum|struct|union)\b)[^\r\n]*\s+(?" + HdlIdentifierPattern + @")\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*function\s+(?:automatic\s+|static\s+|virtual\s+)?(?:(?[\w$:\[\]\s]+?)\s+)?(?" + HdlIdentifierPattern + @")\s*(?:\(|;)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), + new("function", new Regex(@"^\s*task\s+(?:automatic\s+|static\s+|virtual\s+)?(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*(?:parameter|localparam)\s+(?:type\s+)?" + HdlDeclaratorPrefixPattern + @"(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*(?:input|output|inout|wire|reg|logic|rand|randc)\s+" + HdlDeclaratorPrefixPattern + @"(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*import\s+(?" + HdlIdentifierPattern + @"(?:::(?:" + HdlIdentifierPattern + @"|\*))?)\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*`include\s+""(?[^""]+)""", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["vhdl"] = + [ + new("import", new Regex(@"^\s*library\s+(?" + VhdlIdentifierPattern + @")\s*;", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex(@"^\s*use\s+(?" + VhdlIdentifierPattern + @"(?:\." + VhdlIdentifierPattern + @")*)\s*;", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("module", new Regex(@"^\s*entity\s+(?" + VhdlIdentifierPattern + @")\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("module", new Regex(@"^\s*architecture\s+(?" + VhdlIdentifierPattern + @")\s+of\s+" + VhdlIdentifierPattern + @"\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("package", new Regex(@"^\s*package\s+body\s+(?" + VhdlIdentifierPattern + @")\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("package", new Regex(@"^\s*package\s+(?" + VhdlIdentifierPattern + @")\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("module", new Regex(@"^\s*component\s+(?" + VhdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("module", new Regex(@"^\s*configuration\s+(?" + VhdlIdentifierPattern + @")\s+of\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*function\s+(?" + VhdlIdentifierPattern + @")\s*(?:\(|return\b)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*procedure\s+(?" + VhdlIdentifierPattern + @")\s*(?:\(|is\b)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?" + VhdlIdentifierPattern + @")\s*:\s*process\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("typealias", new Regex(@"^\s*(?:type|subtype)\s+(?" + VhdlIdentifierPattern + @")\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*(?:signal|constant|variable|generic|port)\s+(?" + VhdlIdentifierPattern + @")\s*:", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["glsl"] = + [ + new("struct", new Regex(@"^\s*struct\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("property", new Regex(@"^\s*" + ShaderAttributePrefixPattern + @"(?:uniform|buffer)\s+(?:(?" + ShaderTypePattern + @")\s+)?(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("property", new Regex(@"^\s*" + ShaderAttributePrefixPattern + @"(?:in|out|attribute|varying)\s+(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), + new("function", new Regex(ShaderFunctionStartBlacklistPattern + @"\s*" + ShaderAttributePrefixPattern + @"(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + ], + ["hlsl"] = + [ + new("struct", new Regex(@"^\s*struct\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("property", new Regex(@"^\s*(?:cbuffer|tbuffer)\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("property", new Regex(@"^\s*(?:globallycoherent\s+)?(?:RW)?(?:Texture\w*|Buffer|StructuredBuffer|RWStructuredBuffer|ByteAddressBuffer|RWByteAddressBuffer|Sampler\w*)\s*(?:<[^>\r\n]+>)?\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*(?:groupshared|static|uniform)\s+(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), + new("function", new Regex(ShaderFunctionStartBlacklistPattern + @"\s*" + ShaderAttributePrefixPattern + @"(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + ], + ["metal"] = + [ + new("struct", new Regex(@"^\s*struct\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("property", new Regex(@"^\s*(?:constant|device|threadgroup)\s+(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), + new("function", new Regex(ShaderFunctionStartBlacklistPattern + @"\s*(?:kernel|vertex|fragment)\s+(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + new("function", new Regex(ShaderFunctionStartBlacklistPattern + @"\s*(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), + ], + ["wgsl"] = + [ + new("struct", new Regex(@"^\s*struct\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("typealias", new Regex(@"^\s*alias\s+(?" + ShaderIdentifierPattern + @")\s*=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:var(?:<[^>\r\n]+>)?|let|const|override)\s+(?" + ShaderIdentifierPattern + @")\s*:", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex(@"^\s*(?:@\w+(?:\([^)]*\))?\s*)*fn\s+(?" + ShaderIdentifierPattern + @")\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), + ], + ["shell"] = + [ + // Bash/Zsh function declarations / Bash/Zsh 関数宣言 + new("function", new Regex(@"^\s*(?:function\s+)?(?\w+)\s*\(\s*\)\s*\{?", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*function\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), + // Alias definitions / エイリアス定義 + new("alias", new Regex(@"^\s*alias(?:\s+-[^\s=]+)*\s+(?[A-Za-z_][A-Za-z0-9_-]*)\s*=", RegexOptions.Compiled), BodyStyle.None), + ], + ["sql"] = + [ + // Identifier shape accepts PG double-quoted ("name"), T-SQL bracketed ([name]), or bare + // ([\w$#]+) to cover Oracle identifiers such as SYS$LINK / USER#1, optionally qualified + // with dots (schema.name, [dbo].[sp_X], "s"."n"). + // 識別子形式は PG の "name"、T-SQL の [name]、裸 ([\w$#]+) を受け入れる。裸 ID は + // SYS$LINK / USER#1 のような Oracle 識別子も拾える。ドットで修飾可能 + //(schema.name、[dbo].[sp_X]、"s"."n")。 + // CREATE TABLE / VIEW — Postgres TEMP/UNLOGGED + MATERIALIZED VIEW, T-SQL `CREATE OR ALTER` (2016+) + // CREATE TABLE / VIEW — Postgres の TEMP/UNLOGGED や MATERIALIZED VIEW、T-SQL の `CREATE OR ALTER`(2016+)に対応 + new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?(?:(?:(?:GLOBAL|LOCAL)\s+)?(?:TEMP|TEMPORARY)\s+|UNLOGGED\s+)?(?:TABLE|(?:MATERIALIZED\s+)?VIEW)\s+(?:IF\s+NOT\s+EXISTS\s+)?(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // CREATE PROCEDURE / PROC / FUNCTION / TRIGGER — Postgres `OR REPLACE` and T-SQL `OR ALTER` / `PROC` short form + // Uses BodyStyle.SqlProcBody so the body range covers the BEGIN...END / dollar-quoted body, + // letting ReferenceExtractor.ResolveContainerForCall attribute calls inside the body to the + // enclosing procedure (see issue #429). + // CREATE PROCEDURE / PROC / FUNCTION / TRIGGER — Postgres の `OR REPLACE` と T-SQL の `OR ALTER` / 短縮形 `PROC` に対応 + // BodyStyle.SqlProcBody により BEGIN...END / dollar-quoted の本体範囲を求め、ReferenceExtractor の + // ResolveContainerForCall が本体内の呼び出しを外側のプロシージャに帰属させられるようにする(issue #429)。 + new("function", new Regex($@"^\s*CREATE\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?(?:PROCEDURE|PROC|FUNCTION|TRIGGER)\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.SqlProcBody), + // SQL Server aggregate definitions are callable search anchors too, but they do not have + // a statement body to scan, so they stay on the BodyStyle.None path. + // SQL Server の aggregate 定義も検索アンカーとして有用だが、走査すべき statement body は + // 持たないため BodyStyle.None のまま扱う。 + new("function", new Regex($@"^\s*CREATE\s+AGGREGATE\b\s+(?{SqlQualifiedIdentifierPattern})\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("enum", new Regex($@"^\s*CREATE\s+TYPE\s+(?{SqlQualifiedIdentifierPattern})\s+AS\s+ENUM\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Oracle: CREATE [OR REPLACE] TYPE BODY and CREATE [OR REPLACE] PACKAGE [BODY] . + // These must precede the bare CREATE TYPE / CREATE PACKAGE rows so the `BODY` keyword is + // not absorbed as the object name. + // Oracle: CREATE [OR REPLACE] TYPE BODY と CREATE [OR REPLACE] PACKAGE [BODY] 。 + // 裸の CREATE TYPE / CREATE PACKAGE 行より前に置き、`BODY` キーワードを name として + // 飲み込まないようにする。 + new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?TYPE\s+BODY\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:EDITIONABLE\s+|NONEDITIONABLE\s+)?PACKAGE\s+BODY\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:EDITIONABLE\s+|NONEDITIONABLE\s+)?PACKAGE\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?TYPE\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // SQL Server legacy scalar-object definitions still appear in older T-SQL codebases. + // The `AS ` tail is part of the definition, not a body to track. + // SQL Server の legacy な scalar-object 定義は古い T-SQL コードベースに残っている。 + // 末尾の `AS ` は定義の一部であり、追跡すべき body ではない。 + new("class", new Regex($@"^\s*CREATE\s+(?:RULE|DEFAULT)\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("namespace", new Regex($@"^\s*CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:(?(?!AUTHORIZATION\b){SqlQualifiedIdentifierPattern})|AUTHORIZATION\s+(?{SqlQualifiedIdentifierPattern}))", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex($@"^\s*CREATE\s+(?:SEQUENCE|DOMAIN)\s+(?:IF\s+NOT\s+EXISTS\s+)?(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex($@"^\s*CREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // T-SQL SYNONYM (also Oracle / DB2) + new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:PUBLIC\s+)?SYNONYM\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Oracle: CREATE [SHARED] [PUBLIC] DATABASE LINK — must precede the bare CREATE DATABASE row + // so the `LINK` token is not taken as a name. SHARED and PUBLIC may appear together in that order. + // Oracle: CREATE [SHARED] [PUBLIC] DATABASE LINK — 裸の CREATE DATABASE 行より前に置き、 + // `LINK` を name として飲み込まないようにする。SHARED と PUBLIC はこの順で 2 語並ぶことがある。 + new("class", new Regex($@"^\s*CREATE\s+(?:SHARED\s+)?(?:PUBLIC\s+)?DATABASE\s+LINK\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // T-SQL server-level / database-level principals and objects, plus Oracle-only DIRECTORY / CONTEXT / PROFILE. + // Include T-SQL SECURITY POLICY so row-level-security policy definitions are discoverable. + // T-SQL のサーバ/データベースレベルのプリンシパル・オブジェクトと、Oracle 固有の DIRECTORY / CONTEXT / PROFILE。 + // T-SQL の SECURITY POLICY も含め、行レベルセキュリティポリシー定義を検索可能にする。 + new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:DATABASE|LOGIN|USER|ROLE|CERTIFICATE|DIRECTORY|CONTEXT|PROFILE|ASSEMBLY|XML\s+SCHEMA\s+COLLECTION|SECURITY\s+POLICY)\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // T-SQL partitioning and full-text catalogs + // T-SQL のパーティション関連と全文検索カタログ + new("function", new Regex($@"^\s*CREATE\s+PARTITION\s+FUNCTION\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex($@"^\s*CREATE\s+PARTITION\s+SCHEME\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex($@"^\s*CREATE\s+FULLTEXT\s+CATALOG\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?(?!ON\b)(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // ALTER covers the same object kinds we create above, so migration scripts remain visible. + // Kinds are split to match the CREATE side (procedure-like → function, schema → namespace, + // extension → import, everything else → class) so `symbols --kind` / `definition` / `inspect` + // stay consistent across a CREATE + ALTER pair on the same object. + // ALTER も上記の CREATE と同じ種類をカバーし、マイグレーションスクリプトが可視になるようにする。 + // CREATE 側に合わせて kind を分割し(プロシージャ類 → function、SCHEMA → namespace、 + // EXTENSION → import、その他 → class)、同じオブジェクトに対する CREATE と ALTER で + // `symbols --kind` / `definition` / `inspect` の種別が揃うようにする。 + // ALTER PROCEDURE / PROC / FUNCTION / TRIGGER share the body shape with CREATE so they + // also get BodyStyle.SqlProcBody. ALTER PARTITION FUNCTION is body-less (it modifies the + // partition boundary, not code), so it keeps BodyStyle.None via a separate pattern below. + // ALTER PROCEDURE / PROC / FUNCTION / TRIGGER は CREATE と同じ本体形状を持つため + // BodyStyle.SqlProcBody を使う。ALTER PARTITION FUNCTION は本体を持たない + // (パーティション境界の変更のみ)ため、下の別パターンで BodyStyle.None のままにする。 + new("function", new Regex($@"^\s*ALTER\s+(?:PROCEDURE|PROC|FUNCTION|TRIGGER)\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.SqlProcBody), + new("function", new Regex($@"^\s*ALTER\s+AGGREGATE\b\s+(?{SqlQualifiedIdentifierPattern})\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("function", new Regex($@"^\s*ALTER\s+PARTITION\s+FUNCTION\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("namespace", new Regex($@"^\s*ALTER\s+SCHEMA\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("import", new Regex($@"^\s*ALTER\s+EXTENSION\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Oracle: ALTER DATABASE LINK — must precede the bare ALTER DATABASE row so `LINK` + // is not absorbed as the object name. Real Oracle body compilation is expressed as + // `ALTER PACKAGE COMPILE BODY` / `ALTER TYPE COMPILE BODY` and falls through + // to the generic ALTER row below; there is no `ALTER PACKAGE BODY ` syntax in Oracle. + // Oracle: ALTER DATABASE LINK — 裸の ALTER DATABASE 行より前に置き `LINK` を name + // として飲み込まないようにする。Oracle の body コンパイルは実際には + // `ALTER PACKAGE COMPILE BODY` / `ALTER TYPE COMPILE BODY` の形で、下の + // generic ALTER 行で拾う。`ALTER PACKAGE BODY ` のような構文は Oracle に存在しない。 + new("class", new Regex($@"^\s*ALTER\s+DATABASE\s+LINK\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("class", new Regex($@"^\s*ALTER\s+(?:TABLE|(?:MATERIALIZED\s+)?VIEW|SEQUENCE|SYNONYM|LOGIN|USER|ROLE|DATABASE|CERTIFICATE|INDEX|PACKAGE|TYPE|DOMAIN|DIRECTORY|PROFILE|ASSEMBLY|XML\s+SCHEMA\s+COLLECTION|PARTITION\s+SCHEME|FULLTEXT\s+CATALOG|SECURITY\s+POLICY)\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["terraform"] = + [ + // Terraform resource/data: capture the logical name (second quoted token), not the type + // Terraform resource/data: 型ではなく論理名(第2引用トークン)をキャプチャ + new("class", new Regex(@"^\s*resource\s+""[^""]+""\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*data\s+""[^""]+""\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*module\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*provider\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*(?terraform)\s*\{", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*(?import|moved|removed)\s*\{", RegexOptions.Compiled), BodyStyle.Brace), + new("class", new Regex(@"^\s*check\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*variable\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*output\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), + new("function", new Regex(@"^\s*(?locals)\s*\{", RegexOptions.Compiled), BodyStyle.Brace), + ], + ["css"] = + [ + // @import / @use (SCSS) / インポート + new("import", new Regex(@"^\s*@(?:import|use|forward)\s+(?.+?)\s*;", RegexOptions.Compiled), BodyStyle.None), + // @counter-style / カウンタースタイル + new("function", new Regex(@"^\s*@counter-style\s+(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), + // @function (SCSS) / 関数 + new("function", new Regex(@"^\s*@function\s+(?[\w-]+)", RegexOptions.Compiled), BodyStyle.Brace), + // @mixin (SCSS) / ミックスイン + new("function", new Regex(@"^\s*@mixin\s+(?[\w-]+)", RegexOptions.Compiled), BodyStyle.Brace), + // @keyframes / キーフレーム + new("function", new Regex(@"^\s*@keyframes\s+(?[\w-]+)", RegexOptions.Compiled), BodyStyle.Brace), + // @font-face / フォントフェイス + new("function", new Regex(@"^\s*@font-face\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), + // @property / カスタムプロパティ登録 + new("property", new Regex(@"^\s*@property\s+(?--[\w-]+)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), + // @page / ページ規則 + new("namespace", new Regex(@"^\s*@page(?:\s+(?:[\w-]+))?", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), + // @namespace / 名前空間 + new("namespace", new Regex(@"^\s*@namespace(?:\s+(?[\w-]+))?", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // @layer reset, base, theme; / レイヤー順序宣言 + new("namespace", new Regex(@"^\s*@layer\s+(?[\w-]+)(?:\s*,\s*[\w-]+)*\s*;", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Grouping at-rules / grouping at-rule + new("namespace", new Regex(@"^\s*@(?layer|container|supports|media)\b[^{]*\{", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), + // :root selector / :root セレクタ + new("class", new Regex(@"^\s*(?:root)\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), + // Standalone attribute selector / 単独属性セレクタ + new("class", new Regex(@"^\s*(?\[[^\]]+\](?:(?:::?[\w-]+)|(?:\[[^\]]+\]))*)\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), + // Pseudo-class / pseudo-element / attribute selectors / 疑似クラス・疑似要素・属性セレクタ + new("class", new Regex(@"^\s*(?(?:[#.]?[\w-]+|\*)(?:(?:::?[\w-]+)|(?:\[[^\]]+\]))+)\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), + // CSS class selector at top level (not nested) / トップレベルのCSSクラスセレクタ + new("class", new Regex(@"^\s*(?\.[\w-]+)(?=[\s\.,:>+~\[\{])", RegexOptions.Compiled), BodyStyle.Brace), + // CSS ID selector at top level / トップレベルのIDセレクタ + new("class", new Regex(@"^\s*(?#[\w-]+)\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), + // Native CSS nesting selectors / ネイティブ CSS nesting セレクタ + new("property", new Regex(@"^\s*&(?:(?::(?[\w-]+))|(?:\s*(?:[>+~]\s*)?(?:\.|#)?(?[\w-]+)))(?:(?:::?[\w-]+)|(?:\[[^\]]+\]))*\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), + // CSS custom property declaration / CSS カスタムプロパティ宣言 + new("property", new Regex(@"^\s*(?--[\w-]+)\s*:", RegexOptions.Compiled), BodyStyle.None), + // SCSS $variable declaration / SCSS 変数宣言 + new("property", new Regex(@"^\$(?[\w-]+)\s*:", RegexOptions.Compiled), BodyStyle.None), + // SCSS placeholder selector / SCSS プレースホルダーセレクタ + new("class", new Regex(@"^\s*(?%[\w-]+)\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), + ], + ["sass"] = + [ + // Sass indented syntax has no braces, so keep these as line-level anchors. + // Sass インデント構文は波括弧を持たないため、行単位のアンカーとして扱う。 + new("import", new Regex(@"^\s*@(?:import|use|forward)\s+(?.+?)(?:\s*!default)?\s*$", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*(?:@mixin\s+|=)(?[\w-]+)", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*@function\s+(?[\w-]+)", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*@keyframes\s+(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*\$(?[\w-]+)\s*:", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*(?[.#%][\w-]+)(?=[\s\.,:>+~\[]|$)", RegexOptions.Compiled), BodyStyle.None), + new("property", new Regex(@"^\s*&(?:(?::(?[\w-]+))|(?:\s*(?:[>+~]\s*)?(?:\.|#)?(?[\w-]+)))", RegexOptions.Compiled), BodyStyle.None), + ], + ["stylus"] = + [ + // Stylus supports optional punctuation, so only capture conservative declaration shapes. + // Stylus は句読点を省略できるため、保守的な宣言形だけを捕捉する。 + new("import", new Regex(@"^\s*@(?:import|require|use)\s+(?.+?)\s*$", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^(?[A-Za-z_][\w-]*)\s*\([^)\r\n]*\)\s*$", RegexOptions.Compiled), BodyStyle.None), + new("function", new Regex(@"^\s*@keyframes\s+(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + new("property", new Regex(@"^\s*\$?(?[A-Za-z_][\w-]*)\s*(?:=|:=)\s*", RegexOptions.Compiled), BodyStyle.None), + new("class", new Regex(@"^\s*(?[.#%][\w-]+)(?=[\s\.,:>+~\[]|$)", RegexOptions.Compiled), BodyStyle.None), + new("property", new Regex(@"^\s*&(?:(?::(?[\w-]+))|(?:\s*(?:[>+~]\s*)?(?:\.|#)?(?[\w-]+)))", RegexOptions.Compiled), BodyStyle.None), + ], + // HTML does not use the regex pattern loop — it needs true tag-structure + // awareness (attribute enumeration, quoted-value handling, custom-element + // detection) that regex alone can't express without losing outer-tag + // context. `Extract` dispatches to `ExtractHtmlSymbols`, which drives a + // character state machine. The empty list here keeps "html" listed as a + // supported language via `GetSupportedLanguages()` without pretending to + // offer regex-based extraction. + // HTML は汎用の regex パターンループではなく、タグ構造を理解した走査(属性列挙、 + // 引用符付き値の処理、カスタム要素検出)を必要とするため、`Extract` は + // `ExtractHtmlSymbols` に分岐して文字単位の state machine で抽出する。空リストは + // `GetSupportedLanguages()` で "html" を対応言語として残すための置き場であり、 + // regex 抽出を模したものではない。 + ["html"] = [], + ["powershell"] = + [ + // DSC configuration / workflow declarations / DSC 構成・workflow 宣言 + new("function", new Regex(@"^\s*configuration\s+(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), + new("function", new Regex(@"^\s*workflow\s+(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), + // Function/filter declarations with optional scope prefixes / scope プレフィックス付き関数・フィルタ宣言 + new("function", new Regex(@"^\s*(?:function|filter)\s+(?:(?:script|global|local|private):)?(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), + // PowerShell class members / PowerShell クラスメンバー + // Return-typed methods and modifiers such as `static` / `hidden` / `static hidden` + // stay on the function path. + // 戻り値付き method と `static` / `hidden` / `static hidden` のような修飾子は + // function パスで扱う。 + new("function", new Regex(@"^\s*(?:(?:static|hidden)\s+)*(?:\[[^\]]+\]\s+)+(?[\w-]+)\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), + // Constructors are bare class-name declarations inside a class body, so the + // PascalCase gate keeps most cmdlet-style calls out while still catching the + // canonical PS5+ shape. + // コンストラクタは class 本体内に置かれる bare な class-name 宣言なので、 + // PascalCase の条件で cmdlet 風の呼び出しを大半弾きつつ、PS5+ の標準形を拾う。 + new("function", new Regex(@"^\s*(?[A-Z]\w*)\s*\(", RegexOptions.Compiled), BodyStyle.Brace), + // Alias definitions / エイリアス定義 + new("alias", new Regex(@"^\s*(?:Set-Alias|New-Alias)\s+(?:-Name\s+)?(?[\w-]+)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Attributes and typed properties / 属性付きプロパティと型付きプロパティ + new("property", new Regex(@"^\s*(?:(?:static|hidden)\s+)*(?:\[[^\]]+\]\s*)+\$(?\w+)\s*(?:=|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Class (PowerShell 5+) / クラス (PowerShell 5+) + new("class", new Regex(@"^\s*class\s+(?\w+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), + // Enum (PowerShell 5+) / enum (PowerShell 5+) + new("enum", new Regex(@"^\s*enum\s+(?\w+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), + // Enum values / enum 値 + new("enum", new Regex(@"^\s{2,}(?[\w-]+)\s*(?:=\s*[^#\r\n]+)?\s*$", RegexOptions.Compiled), BodyStyle.None), + // Import-Module / using module / using namespace / using assembly / モジュールインポート + new("import", new Regex(@"^\s*(?:Import-Module|using\s+(?:module|namespace|assembly))\s+(?\S+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + ], + ["batch"] = + [ + // Labels — goto :X / call :X targets, the only navigation anchors in a batch script. + // `::` comment form has no label name, so the name character class naturally rejects it. + // Dotted labels like `:build.release` are real batch label names, so accept `.` too. + // `:EOF` is a reserved batch target used by `goto :EOF` / `call :EOF`, not a user-defined + // label, so exclude it — but only the literal full-name `eof`. Labels that merely begin + // with `eof` such as `:eof2` / `:eofish` / `:end-of-file` / `:eof.x` must still surface, + // which is why the negative lookahead checks for name-terminating characters instead of `\b`. + // ラベル — goto :X / call :X の着地点であり、batch スクリプト内で唯一のナビゲーションアンカー。 + // `::` コメント形式はラベル名を持たないため名前文字クラスが自然に弾く。 + // `:build.release` のようなドット付きラベルも正規のラベル名として受け入れる。 + // `:EOF` は `goto :EOF` / `call :EOF` 用の予約ターゲットであってユーザー定義ラベルではないため除外するが、 + // 除外するのは名前全体が `eof` のときだけ。`:eof2` / `:eofish` / `:end-of-file` / `:eof.x` のように + // 単に `eof` で始まるだけのラベルは通す必要があるため、`\b` ではなく名前終端文字を見る negative lookahead を使う。 + new("function", new Regex(@"^\s*:(?!eof(?![\w.-]))(?[\w.\-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + // Variable assignment — set VAR=value, set /a VAR=expr, set /p VAR=prompt, set "VAR=value". + // Also handles `@set VAR=...` (echo suppression prefix), `set /a VAR+=1` (compound + // assignment operators), `if ... set VAR=...` (inline assignment inside a one-line + // control statement), and same-line multi-statement forms `set A=1 & set B=2`, + // `( set X=1 )`, `if ... ( set P=1 ) else set Q=2`, `for ... do set LOOPVAR=...`. + // Boundary alternation: line-leading `^`, or after `&` / `(` / `\belse` / `\bdo` so + // the regex (paired with the batch multi-match advance in the extractor loop) can + // emit one symbol per `set` occurrence on the same line instead of dropping every + // assignment after the first match. `rem` / `@rem` / `::` comment lines can also + // contain those boundary tokens (e.g. `REM & set FAKE=1`), so they are short- + // circuited by `IsBatchCommentLine` before this pattern ever runs — the boundary + // alternation alone is not enough to keep comment bodies out of the capture. + // 変数代入 — set VAR=value、set /a VAR=expr、set /p VAR=prompt、set "VAR=value" に対応。 + // 併せて `@set VAR=...` (echo 抑止プレフィクス) 、`set /a VAR+=1` (複合代入演算子) 、 + // `if ... set VAR=...` (1 行制御文内の代入) 、および `set A=1 & set B=2` / `( set X=1 )` / + // `if ... ( set P=1 ) else set Q=2` / `for ... do set LOOPVAR=...` のような同一行複数ステートメント形も拾う。 + // 境界は `^` / `&` / `(` / `\belse` / `\bdo` のいずれかで、extractor 側の batch 専用 + // multi-match advance と組み合わせて 1 行中の `set` ごとに 1 シンボルを出す。 + // `rem` / `@rem` / `::` コメント行にもこれらの境界トークンが入りうる + // (`REM & set FAKE=1` 等) ため、この正規表現が走る前に `IsBatchCommentLine` で + // 行ごと早期スキップしている — 境界 alternation だけではコメント本文を弾ききれない。 + new("property", new Regex(@"(?:(?:^|&|\()\s*|(?:\belse|\bdo)\s+)(?:@\s*)?(?:if\s+.+?\s+)?set\s+(?:/[aApP]\s+)?""?(?[A-Za-z_][\w]*)\s*(?:[+\-*/%&^|]|<<|>>)?=", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), + ], + // Assembly uses a dedicated line scanner because label body ranges extend until + // the next label/section rather than a brace or indentation boundary. + // assembly は label の body range が次の label / section まで続くため、 + // brace / indent 境界ではなく専用の行走査で抽出する。 + ["assembly"] = [], + ["zig"] = + [ + // Public and private function declarations / 公開・非公開の関数宣言 + new("function", new Regex(@"^\s*(?:(?pub)\s+)?(?:inline\s+)?fn\s+(?\w+)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Struct/union/enum defined via const / const による struct/union/enum 定義 + new("struct", new Regex(@"^\s*(?:(?pub)\s+)?const\s+(?\w+)\s*=\s*(?:extern\s+|packed\s+)?struct\b", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("enum", new Regex(@"^\s*(?:(?pub)\s+)?const\s+(?\w+)\s*=\s*(?:extern\s+)?enum\b", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + new("class", new Regex(@"^\s*(?:(?pub)\s+)?const\s+(?\w+)\s*=\s*(?:extern\s+|packed\s+)?union\b", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Error set / エラーセット + new("class", new Regex(@"^\s*(?:(?pub)\s+)?const\s+(?\w+)\s*=\s*error\b", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), + // Test declarations / テスト宣言 + new("function", new Regex(@"^\s*test\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), + // @import / インポート + new("import", new Regex(@"^\s*(?:(?:pub)\s+)?const\s+\w+\s*=\s*@import\s*\(\s*""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.None), + ], + }; + + private static readonly string[] BuiltInSymbolLanguages = PatternCache.Keys.ToArray(); +} diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 40c5cdf07..0d861febc 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -15,9 +15,6 @@ public static partial class SymbolExtractor { private const int SymbolListInitialCapacityLineThreshold = 128; private const int SymbolListInitialCapacityMax = 1024; - private const string JuliaIdentifierPattern = @"[\p{L}_]\w*"; - private const string JuliaQualifiedCallableIdentifierPattern = - JuliaIdentifierPattern + @"(?:\." + JuliaIdentifierPattern + @")*!?"; private static string[] SplitContentLines(string content) => content.IndexOf('\n', StringComparison.Ordinal) < 0 ? [content] : content.Split('\n'); @@ -76,2251 +73,6 @@ private static IReadOnlyList BuildEnumDeclarationSnapshot(IReadOnl return snapshot; } - // THREAD-SAFETY: Symbol extraction is intentionally stateless per call. Shared Regex - // instances and lookup tables are initialized once by the CLR and must be treated as - // immutable after type initialization; per-file extraction state belongs in local - // variables or per-call collections, never in static mutable caches. - private const string SqlQualifiedIdentifierSegmentPattern = @"(?:\[(?:[^\]\r\n]|\]\])+\]|""[^""]+""|[\w$#]+)"; - private const string SqlQualifiedIdentifierPattern = - @"(?:" + SqlQualifiedIdentifierSegmentPattern + @")(?:\s*\.\s*(?:" + SqlQualifiedIdentifierSegmentPattern + @"))*"; - // Swift declarations commonly carry attributes on the same line as the declaration keyword. - // Allow those prefixes so annotated declarations still index by their actual names. - // Swift の宣言では、宣言キーワードと同じ行に属性が付くことが多い。 - // その前置きを許容し、注釈付き宣言でも実際の名前でインデックスできるようにする。 - private const string SwiftAttributeNamePattern = @"\w+(?:\.\w+)*"; - private const string SwiftAttributePattern = @"(?:@" + SwiftAttributeNamePattern + @"(?:\([^)]*\))?\s+)*"; - private static readonly Regex SwiftPropertyDeclarationRegex = new( - @"^\s*(?(?:@" + SwiftAttributeNamePattern + @"(?:\([^)]*\))?\s+)*)?(?:(?:public|private|internal|open|fileprivate|package)(?:\s*\(\s*set\s*\))?\s+)?(?:(?:lazy|weak|unowned|final|static|class|nonisolated)\s+)*(?:let|var)\s+(?`[^`]+`|\w+)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex SwiftPropertyWrapperAttributeRegex = new( - @"@(?[A-Z]\w*(?:\.[A-Z]\w*)?)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex SwiftAccessorDeclarationRegex = new( - @"^\s*" + SwiftAttributePattern + @"(?:(?:mutating|nonmutating)\s+)?(?:@(?=willSet\b|didSet\b))?(?get|set|willSet|didSet)\b(?:\s*\([^)]*\))?(?:\s+(?:async|throws|rethrows))*\s*(?:\{|$)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly HashSet SwiftNonWrapperPropertyAttributes = new(StringComparer.Ordinal) - { - "IBOutlet", - "IBOutletCollection", - "IBInspectable", - "NSManaged", - "GKInspectable", - }; - // C++ return-type atoms need to accept both ordinary word tokens and `decltype(...)`. - // The decltype branch allows nested parentheses so modern forms such as - // `decltype(auto)`, `decltype((value))`, and `decltype(foo(x))` stay searchable. - // C++ の戻り値型トークンは通常の単語トークンに加え `decltype(...)` も受け入れる必要がある。 - // ここで括弧の入れ子を許容し、`decltype(auto)` / `decltype((value))` / - // `decltype(foo(x))` のような現代的な形も検索可能なままにする。 - private const string CppDecltypePattern = - @"decltype\s*\((?:(?>[^()]+)|\((?)|\)(?<-CppDecltypeDepth>))*(?(CppDecltypeDepth)(?!))\)"; - private const string CppFunctionReturnTypeAtomPattern = @"(?:" + CppDecltypePattern + @"|[\w:<>~]+)"; - // GCC/Clang/MSVC attribute specifiers can appear before the return type or between return - // type tokens. Keep them inside the function return-type matcher so common annotated C - // functions still surface in `symbols` / `search`. - // GCC/Clang/MSVC の attribute specifier は戻り値型の前や、戻り値型トークンの途中に現れる。 - // それらを戻り値型マッチャーに含めて、よくある注釈付き C 関数も `symbols` / `search` に出るようにする。 - private const string CAttributeSpecifierTokenPattern = - @"(?:\[\[[^\r\n]*?\]\]\s*|__attribute__\s*\(\((?:(?>[^()]+)|\((?)|\)(?<-CAttributeDepth>))*(?(CAttributeDepth)(?!))\)\)\s*|__declspec\s*\((?:(?>[^()]+)|\((?)|\)(?<-CAttributeDepth>))*(?(CAttributeDepth)(?!))\)\s*|_Noreturn\s+)"; - private const string CFunctionReturnTypePattern = - @"(?(?:(?:\w+[\s*]+)|" + CAttributeSpecifierTokenPattern + @")+)"; - private const string JavaUnicodeEscapePattern = @"\\u+[0-9A-Fa-f]{4}"; - private const string JavaIdentifierPattern = - @"(?:[\p{L}_$]|" + JavaUnicodeEscapePattern + @")(?:[\p{L}\p{Nd}_$]|" + JavaUnicodeEscapePattern + @")*"; - private const string JavaQualifiedIdentifierPattern = JavaIdentifierPattern + @"(?:\s*\.\s*" + JavaIdentifierPattern + @")*"; - private const string JavaMethodTypeParameterPattern = - @"(?:<(?:(?>[^<>]+)|<(?)|>(?<-JavaMethodTypeParameterDepth>))*(?(JavaMethodTypeParameterDepth)(?!))>\s+)?"; - private const string JavaReturnTypePattern = - @"(?:" + JavaQualifiedIdentifierPattern + @"(?:\s*<[^;=(){}]+>)?(?:\s*\[\s*\])*)"; - private const string KotlinIdentifierPattern = @"(?:\w+|`[^`\r\n]+`)"; - private const string CythonIdentifierPattern = @"[A-Za-z_]\w*"; - private const string CythonDottedIdentifierPattern = CythonIdentifierPattern + @"(?:\." + CythonIdentifierPattern + @")*"; - private const string CythonDeclarationPrefixPattern = @"(?:(?:public|readonly|api|inline|extern|nogil|const|volatile)\s+)*"; - private const string CythonNativeReturnTypePattern = - @"(?(?:(?:const|volatile|unsigned|signed|long|short|int|double|float|char|void|bint|object|Py_ssize_t|size_t|" + CythonDottedIdentifierPattern + @")(?:\s*[*&])?\s+)+)"; - private const string HdlIdentifierPattern = @"[A-Za-z_$][A-Za-z0-9_$]*"; - private const string HdlDeclaratorPrefixPattern = - @"(?:(?:signed|unsigned|automatic|static|wire|reg|logic|bit|byte|shortint|int|longint|integer|time|real|realtime|string|chandle|event)\s+|\[[^\]\r\n]+\]\s+)*"; - private const string VhdlIdentifierPattern = @"[A-Za-z][A-Za-z0-9_]*"; - private static readonly Regex HdlInlineParameterRegex = new( - @"\b(?:parameter|localparam)\s+(?:type\s+)?" + HdlDeclaratorPrefixPattern + @"(?" + HdlIdentifierPattern + @")\b", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private const string ShaderIdentifierPattern = @"[A-Za-z_]\w*"; - private const string ShaderTypePattern = @"[\w:<>,]+(?:\s*[*&])?(?:\s*\[[^\]\r\n]+\])?"; - private const string ShaderAttributePrefixPattern = @"(?:(?:layout\s*\([^)]*\)|\[[^\]\r\n]+\]|@\w+(?:\([^)]*\))?)\s*)*"; - private const string ShaderFunctionStartBlacklistPattern = @"^(?!\s*(?:if|for|while|switch|return|discard)\b)"; - private static readonly Regex RPacmanPackageLoaderStartRegex = new( - @"^\s*(?:(?:[\w.]+)::)?p_load\s*\(", - RegexOptions.Compiled); - private static readonly Regex RPacmanPackageLoaderArgumentRegex = new( - @"(?:^|,)\s*(?!(?:[A-Za-z.][\w.]*\s*=))(?:['""](?[^'""]+)['""]|(?[A-Za-z.][\w.]*))", - RegexOptions.Compiled); - private static readonly Regex CobolProgramIdLineRegex = new( - @"^\s*(?:IDENTIFICATION\s+DIVISION\.\s*)?(?:PROGRAM|CLASS)-ID\.\s*(?[A-Z0-9][A-Z0-9-]*)\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex CobolProcedureDivisionRegex = new( - @"^\s*PROCEDURE\s+DIVISION\.\s*$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex CobolEntryRegex = new( - @"^\s*ENTRY\s+(?:""(?[^""]+)""|'(?[^']+)'|(?[A-Z0-9][A-Z0-9-]*))", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex CobolSectionHeaderRegex = new( - @"^\s{0,6}(?[A-Z0-9][A-Z0-9-]*)\s+SECTION\.\s*$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex CobolParagraphHeaderRegex = new( - @"^\s{0,6}(?[A-Z0-9][A-Z0-9-]*)\.\s*$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex CobolEndProgramRegex = new( - @"^\s*END\s+(?:PROGRAM|CLASS)(?:\s+(?[A-Z0-9][A-Z0-9-]*))?\.\s*$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex PhpGroupUseRegex = new( - @"^\s*use\s+(?:(?function|const)\s+)?(?[\w\\]+\\)\{\s*(?[^{}]+?)\s*\}\s*;", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex PhpUseRegex = new( - @"^\s*use\s+(?:(?function|const)\s+)?(?[\w\\]+)(?:\s+as\s+(?\w+))?\s*;", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex PhpRequireIncludeRegex = new( - @"^\s*(?:require|include)(?:_once)?\s*\(?\s*(?:'(?[^']+)'|""(?[^""]+)"")\s*\)?\s*;", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex PhpPrefixedRequireIncludeRegex = new( - @"^\s*(?:require|include)(?:_once)?\s*\(?\s*(?(?:(?:__DIR__|__FILE__|dirname\s*\(\s*__FILE__\s*\))\s*\.\s*)+)\s*(?:'(?[^']+)'|""(?[^""]+)"")\s*\)?\s*;", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private const string CFunctionStartBlacklistPattern = @"^(?!\s*typedef\b)(?!\s*(?:if|else|for|while|switch|return|sizeof)\s*[\(\{;])"; - private const string CFunctionNameBlacklistPattern = @"(?!(?:int|void|char|short|long|float|double|signed|unsigned|bool|_Bool|size_t|ssize_t|intptr_t|uintptr_t|int8_t|int16_t|int32_t|int64_t|uint8_t|uint16_t|uint32_t|uint64_t)\b)"; - private const string CppFunctionStartBlacklistPattern = @"^(?!\s*typedef\b)(?!\s*(?:if|else|for|while|switch|return|sizeof|using|namespace)\s*[\(\{;<])"; - private const string CppTemplatePrefixPattern = @"(?:template\s*<[^>]*>\s*)*"; - private const string CppAttributePrefixPattern = @"(?:\[\[[^\r\n]*?\]\]\s*)*"; - private static readonly Regex CppFriendTypeDeclarationRegex = new( - @"\bfriend\s+(?class|struct|union|enum(?:\s+class)?)\s+(?(?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)\s*;", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppFriendFunctionDeclarationRegex = new( - @"\bfriend\s+(?!(?:class|struct|union|typename|enum)\b)(?[^;()]*?)\b(?(?:[A-Za-z_]\w*::)*(?:[A-Za-z_]\w*|operator\s*(?:new\[\]|delete\[\]|new|delete|\[\]|[^\s(]+)))(?:\s*<[^>]+>)?\s*\(", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex PartialModifierRegex = new(@"\bpartial\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex GoImportSpecRegex = new( - @"^(?(?:(?:[._]|[\p{L}_][\p{L}\p{Nd}_]*)\s+)?""(?:\\.|[^""\\])*"")(?:\s*;)?(?:\s*(?://.*|/\*.*\*/))?\s*$", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex GoTypeBlockSpecRegex = new( - @"^(?\w+)(?:\[[^\]]+\])?\s+(?:(?struct|interface)\b|.+)$", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex GoInterfaceHeaderRegex = new( - @"^\s*(?:type\s+)?\w+(?:\[[^\]]+\])?\s+interface\b", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex GoInterfaceMethodRegex = new( - @"^\s*(?[A-Za-z_]\w*)\s*(?:\[[^\]\r\n]+\])?\s*\(", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex GoInterfaceEmbeddedTypeRegex = new( - @"^\s*(?:~\s*)?(?[A-Za-z_]\w*(?:\s*\.\s*[A-Za-z_]\w*)*)(?:\[[^\]\r\n]+\])?\s*$", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex GoStructEmbeddedTypeRegex = new( - @"^\s*\*?\s*(?[A-Za-z_]\w*(?:\s*\.\s*[A-Za-z_]\w*)*)(?:\[[^\]\r\n]+\])?\s*$", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly HashSet GoInterfaceEmbeddedTypeBlacklist = new(StringComparer.Ordinal) - { - "bool", - "byte", - "complex64", - "complex128", - "float32", - "float64", - "int", - "int8", - "int16", - "int32", - "int64", - "rune", - "string", - "uint", - "uint8", - "uint16", - "uint32", - "uint64", - "uintptr", - }; - private static readonly Regex GoValueBlockSpecRegex = new( - @"^(?[A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex GoLabelRegex = new( - @"^(?[A-Za-z_]\w*)\s*:\s*(?!=)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex RustUseStartRegex = new( - @"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?use\b", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private readonly record struct RustUseSymbolOccurrence(string Name, int Line, int Column); - private const string RustIdentifierPattern = @"(?:r#)?\w+"; - private static readonly Regex RustMultilineImplForRegex = new( - @"^\s*(?:unsafe\s+)?impl(?:<[^>]+>)?\s+.+?\s+for\s+(?" + RustIdentifierPattern + @")\b", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); - private static readonly Regex RustMultilineImplTypeRegex = new( - @"^\s*(?:unsafe\s+)?impl(?:<[^>]+>)?\s+(?" + RustIdentifierPattern + @")(?!\s+for\b)\b", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); - private static readonly Regex DockerfileNamedFromImageRegex = new( - @"^\s*FROM\s+(?:--platform=\S+\s+)?(?\S+)\s+(?:AS|as)\s+[A-Za-z0-9_.-]+", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex XamlClassRegex = new( - @"\bx:Class\s*=\s*[""'](?[^""']+)[""']", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex XamlDataTypeRegex = new( - @"\bx:DataType\s*=\s*[""'](?[^""']+)[""']", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex XamlTypeArgumentsRegex = new( - @"\bx:TypeArguments\s*=\s*[""'](?[^""']+)[""']", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex XamlTargetTypeRegex = new( - @"\bTargetType\s*=\s*[""'](?[^""']+)[""']", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex XamlTypeObjectElementRegex = new( - @"<\s*x:Type(?:Extension)?\b[^>]*\bTypeName\s*=\s*[""'](?[^""']+)[""']", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); - private static readonly Regex XamlTypePropertyElementRegex = new( - @"<\s*(?x:Type(?:Extension)?)\.TypeName\b[^>]*>(?.*?)\.TypeName\s*>", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); - private static readonly Regex XamlNameRegex = new( - @"\bx:Name\s*=\s*[""'](?[^""']+)[""']", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex XamlKeyRegex = new( - @"\bx:Key\s*=\s*[""'](?[^""']+)[""']", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly string[] XamlEventAttributeNames = - [ - "Clicked", - "Tapped", - "Loaded", - "Unloaded", - "SelectionChanged", - "TextChanged", - "CheckedChanged", - "Unchecked", - "SelectedIndexChanged", - "PointerPressed", - "PointerReleased", - "PointerEntered", - "PointerExited", - "Drop", - "DragOver", - "Completed", - "Appearing", - "Disappearing", - "NavigatedTo", - "NavigatedFrom", - "SizeChanged", - ]; - private static readonly Regex XamlEventHandlerRegex = new( - @"\b(?:" + string.Join("|", XamlEventAttributeNames) + @")\s*=\s*[""'](?[^""']+)[""']", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex XamlBindingRegex = new( - @"\{(?Binding|x:Bind|TemplateBinding|CompiledBinding|ReflectionBinding)\b(?(?:[^{}]|{[^{}]*})*)\}", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex XamlBindingPathPropertyElementRegex = new( - @"<\s*Binding\.Path\b[^>]*>(?.*?)", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); - private static readonly Regex XamlBindingElementNamePropertyElementRegex = new( - @"<\s*Binding\.ElementName\b[^>]*>(?.*?)", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); - private static readonly Regex XamlReferenceNamePropertyElementRegex = new( - @"<\s*(?x:Reference(?:Extension)?)\.Name\b[^>]*>(?.*?)\.Name\s*>", - RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline); - private static readonly string[] XamlResourceReferenceMarkupPrefixes = - [ - "{StaticResource", - "{StaticResourceExtension", - "{DynamicResource", - "{DynamicResourceExtension", - ]; - private static readonly string[] XamlReferenceMarkupPrefixes = - [ - "{x:ReferenceExtension", - "{x:Reference", - ]; - private static readonly string[] XamlReferenceObjectElementPrefixes = - [ - "\w+)\s*\(\s*(?[^)]+?)\s*\)(?:\s*<[^>]+>)?", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex SqlDefinerRegex = new( - @"\bDEFINER\s*=\s*(?:'(?[^'\r\n]+)'|`(?[^`\r\n]+)`|(?[^\s@'`]+))\s*@\s*(?:'(?[^'\r\n]+)'|`(?[^`\r\n]+)`|(?[^\s'`]+))", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex SqlDefinerMarkerRegex = new( - @"\bDEFINER\s*=", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex SqlCteDefinitionRegex = new( - $@"(?{SqlQualifiedIdentifierSegmentPattern})(?:\s*\([^)]*\))?\s+AS\s*\(", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex SqlAlterTableAddGeneratedColumnRegex = new( - $@"(?{SqlQualifiedIdentifierPattern})\s+ADD(?:\s+COLUMN)?\s+(?!CONSTRAINT\b)(?{SqlQualifiedIdentifierSegmentPattern})\b(?=[^;]*?\b(?:GENERATED\s+(?:ALWAYS\s+)?AS|AS\s*\(|DEFAULT\s+NEXT\s+VALUE\s+FOR)\b)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex SqlCreateTableBodyRegex = new( - $@"(?{SqlQualifiedIdentifierPattern})\s*\((?[\s\S]*?)\)\s*;", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex SqlGeneratedColumnDefinitionMarkerRegex = new( - @"\b(?:GENERATED\s+(?:ALWAYS\s+)?AS|AS\s*\(|DEFAULT\s+NEXT\s+VALUE\s+FOR)\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex SqlColumnDefinitionNameRegex = new( - $@"^\s*(?{SqlQualifiedIdentifierSegmentPattern})\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex SqlReturnsTableMarkerRegex = new( - @"\bRETURNS\s+TABLE\s*\(", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex SqlOutParameterRegex = new( - @"(?:^|,)\s*(?:OUT|INOUT)\s+(?(?:\[(?:[^\]\r\n]|\]\])+\]|`[^`\r\n]+`|""(?:""""|[^""\r\n])+""|[_\p{L}][\p{L}\p{Mn}\p{Mc}\p{Nd}\p{Pc}$]*))\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex SqlCreateRoutineHeaderRegex = new( - @"^\s*CREATE\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?(?:DEFINER\s*=\s*(?:'[^'\r\n]+'|`[^`\r\n]+`|[^\s@'`]+)\s*@\s*(?:'[^'\r\n]+'|`[^`\r\n]+`|[^\s'`]+)\s+)?(?:PROCEDURE|PROC|FUNCTION)\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - - // Optional TypeScript generic type-argument token that may sit between an HOC call - // name and its `(`. Consumed only by the TypeScript HOC-binding row — the JavaScript - // row intentionally does NOT accept this token, because JavaScript has no generic - // syntax and a bare `memo < Props > (Component)` is a chained comparison / call - // expression that must NOT produce a phantom HOC binding. The expression balances up - // to three levels of nested angle brackets (`>>`) - // and allows parenthesised segments (`<(props: Props) => JSX.Element>`) inside a - // generic argument, which covers the function-type / conditional-type shapes real TS - // HOC call sites use. Each parenthesised segment itself balances one level of nested - // parens — `\((?:[^()]|\([^()]*\))*\)` — so callback-prop shapes such as - // `<(props: { onClick: (x: number) => void }) => JSX.Element>` still match; the - // inner `\([^()]*\)` branch is disjoint from `[^()]` (first char `(` vs not `(`), so - // the paren balancer stays ReDoS-safe. The outer alternation treats `=>` as a single - // two-character token via `=>?` (greedy `?` so the `>` is consumed when present) - // instead of letting the `>` leak out and close the outer `<...>` early, which would - // otherwise drop function-type generic arguments. Each alternation branch starts - // with a distinct character class — `[^<>()=]` (plain), `=>?` (=-rooted), `\(` - // (paren), `<` (nested angle) — so the engine never has overlapping choices at a - // single input position, which rules out catastrophic backtracking on long or - // malformed inputs. Four or more levels of angle-bracket nesting, or two or more - // levels of paren nesting inside a single generic argument, are vanishingly rare in - // real HOC signatures and would require a full bracket walker to stay ReDoS-safe. - // Closes #240. - // HOC 呼び出し名と `(` の間に入りうる、TypeScript の generic 型引数トークン(オプション)。 - // TypeScript 行の HOC 束縛だけがこのトークンを受け付け、JavaScript 行は意図的に - // 受け付けない。JavaScript には generic 構文が無く、`memo < Props > (Component)` は - // 比較・呼び出しの連鎖式であって、ここから phantom な HOC 束縛を生やしてはいけないため。 - // 式は 3 段までのネストした山括弧(`>>`)と、 - // generic 引数内の丸括弧付きセグメント(`<(props: Props) => JSX.Element>`)を許容する - // ので、実在する TS HOC 呼び出しで使われる関数型・条件型形状までカバーできる。各 - // 丸括弧セグメント自身も 1 段のネスト丸括弧を許容する(`\((?:[^()]|\([^()]*\))*\)`) - // ため、callback-prop 形 - // (`<(props: { onClick: (x: number) => void }) => JSX.Element>`)もマッチする。 - // 内側の `\([^()]*\)` 分岐は `[^()]` と先頭文字が互いに素(`(` vs それ以外)なので、 - // 丸括弧バランサーも ReDoS 安全に保たれる。外側 alternation は `=>` を `=>?` の 2 - // 文字トークンとして 1 度に消費する(greedy の `?` によって後続の `>` があれば必ず - // 消費)。こうしないと `=>` の `>` が外側の山括弧閉じとして早期マッチしてしまい、 - // 関数型 generic 引数全体が落ちる。各 alternation 分岐は先頭文字クラスが互いに素 - // (`[^<>()=]`(平文字)、`=>?`(=-root)、`\(`(丸括弧)、`<`(ネスト山括弧))で、 - // 同一入力位置で選択が重ならないため、長い入力や不正な入力に対しても catastrophic - // backtracking が発生しない。4 段以上の山括弧ネストや、単一 generic 引数内での 2 段 - // 以上の丸括弧ネストは実 HOC シグネチャでは極めて稀で、ReDoS 安全に受理するには完全 - // な bracket walker が必要になるため、それぞれ 3 段・1 段で打ち切る。#240 解消。 - private const string TypeScriptOptionalHocTypeArgsPattern = @"(?:<(?:[^<>()=]|=>?|\((?:[^()]|\([^()]*\))*\)|<(?:[^<>()=]|=>?|\((?:[^()]|\([^()]*\))*\)|<(?:[^<>()=]|=>?|\((?:[^()]|\([^()]*\))*\))*>)*>)*>\s*)?"; - // Optional TypeScript generic parameter list that may follow a `type` alias name. - // Allow defaulted parameters (`T = string`) in addition to constraints and nested - // type expressions so generic aliases stay searchable. - // `type` エイリアス名の後に続く TypeScript の generic parameter list(オプション)。 - // `T = string` のような default 付き parameter に加え、constraint や入れ子の - // type expression も許容して generic alias を検索対象に残す。 - private const string TypeScriptOptionalTypeParameterListPattern = @"(?:<(?:[^<>()=]|=(?!>)|=>?|\((?:[^()]|\([^()]*\))*\)|<(?:[^<>()=]|=(?!>)|=>?|\((?:[^()]|\([^()]*\))*\)|<(?:[^<>()=]|=(?!>)|=>?|\((?:[^()]|\([^()]*\))*\))*>)*>)*>\s*)?"; - - private enum BodyStyle - { - None, - Brace, - Indent, - RubyEnd, - FortranEnd, - ElixirEnd, - ScientificEnd, - JuliaShortFunction, - VisualBasicEnd, - PascalEnd, - AdaEnd, - SmalltalkMethod, - SqlProcBody, - } - - private sealed record SymbolPattern( - string Kind, - Regex Regex, - BodyStyle BodyStyle, - string? VisibilityGroup = null, - string? ReturnTypeGroup = null); - - private enum CssContextKind - { - GroupingAtRule, - QualifiedRule, - } - - private enum JavaScriptLexMode - { - Code, - SingleQuote, - DoubleQuote, - TemplateString, - BlockComment, - } - - private enum JavaScriptPrevTokenKind - { - None, - Identifier, - Number, - CloseParen, - CloseBracket, - CloseBrace, - Other, - } - - private enum CSharpLexMode - { - Code, - String, - Char, - VerbatimString, - RawString, - BlockComment, - } - - private enum JavaScriptScopeKind - { - Other, - Block, - Function, - StaticBlock, - Class, - Namespace, - Object, - } - - [Flags] - private enum JavaScriptScopePrivacyFlags - { - None = 0, - FunctionLike = 1, - Block = 2, - Namespace = 4, - } - - private readonly record struct JavaScriptLexState( - JavaScriptLexMode Mode = JavaScriptLexMode.Code, - bool EscapeNext = false, - JavaScriptPrevTokenKind PreviousTokenKind = JavaScriptPrevTokenKind.None, - bool PreviousIdentifierAllowsRegex = false, - bool ExpectingControlFlowOpenParen = false, - int ControlFlowParenDepth = 0, - bool RegexAllowedAfterControlFlowParen = false); - - private readonly record struct JavaScriptLexedLine( - string SanitizedLine, - JavaScriptLexState EndState); - - private readonly record struct CSharpLexState( - CSharpLexMode Mode = CSharpLexMode.Code, - bool EscapeNext = false, - int RawDelimiterLength = 0, - // Interpolation tracking for $@"..." / @$"..." / $"""...""" / $$"""...""" etc. - // IsInterpolated / InterpolationDollarCount describe the CURRENT string mode - // (only meaningful while Mode is a string mode). Return* fields preserve the - // outer interpolated string's info while we are inside an interpolation hole - // (Mode = Code with InterpolationBraceDepth > 0). InterpolationParent keeps - // an immutable stack when another interpolated string starts inside that hole. - // 補間 verbatim / raw 文字列のホール追跡。IsInterpolated / InterpolationDollarCount は - // 現在のモード(string 系モードのときだけ意味を持つ)を表し、Return* は - // ホール内(Mode = Code かつ InterpolationBraceDepth > 0)の間、外側の - // 補間文字列情報を退避する。ホール内で別の補間文字列が始まった場合は - // InterpolationParent の immutable stack に外側の状態を退避する。 - bool IsInterpolated = false, - int InterpolationDollarCount = 0, - int InterpolationBraceDepth = 0, - CSharpLexMode InterpolationReturnMode = CSharpLexMode.Code, - int InterpolationReturnRawDelimiterLength = 0, - int InterpolationReturnDollarCount = 0, - CSharpInterpolationFrame? InterpolationParent = null); - - private sealed record CSharpInterpolationFrame(CSharpLexState State); - - private readonly record struct CSharpLexedLine( - string SanitizedLine, - CSharpLexState EndState); - - private readonly record struct CSharpPropertyMatchCandidate( - string MatchLine, - int LastConsumedLineIndex, - int SignatureLastLineIndex, - int? SignatureLastLineExclusiveEndColumn = null, - int? ExpressionBodyEndLineIndex = null, - int? ExpressionBodyEndLineExclusiveEndColumn = null); - - private readonly record struct FortranContinuationMatchCandidate( - string MatchLine, - int LastConsumedLineIndex); - - private enum CSharpAccessorProbeStatus - { - Pending, - Found, - Rejected - } - - - private readonly record struct JavaScriptClassScanTarget( - int StartIndex, - int StartColumn, - int ScanStartIndex, - int ScanEndExclusive, - int FirstLineScanOffset, - string ContainerKind, - string ContainerName, - bool IsExported = false); - - private static readonly HashSet TypeScriptBareMethodModifiers = - [ - "public", "private", "protected", "static", "readonly", "abstract", "override", "async", "get", "set" - ]; - - // Enum declaration — visibility optional; modifier order is free. Accepts `file` (file-scoped - // enum) and `new` (member-hiding nested enum in a derived type) as non-visibility modifiers. - // Closes #353. - // enum 宣言 — visibility は任意で、修飾子の順序は自由。非 visibility 修飾子として `file` - // (ファイルスコープ enum)と `new`(派生型でのネスト enum 隠蔽)を受け付ける。Closes #353. - private static readonly Regex CSharpEnumDeclarationRegex = new($@"^\s*(?:(?public|private|protected\s+internal|private\s+protected|protected|internal)\s+|(?:file|new)\s+)*enum\s+(?{CSharpIdentifierPattern})", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CSharpEnumMemberRegex = new($@"^\s*(?{CSharpIdentifierPattern})\s*(?:=\s*(?:-?\d|0x|{CSharpIdentifierPattern}(?:\s*\|\s*{CSharpIdentifierPattern})*)[^""']*)?,?\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CSharpEnumMemberNameRegex = new($@"^\s*(?{CSharpIdentifierPattern})\b", RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex JavaCompactConstructorRegex = new( - @"^\s*(?:(?public|private|protected)\s+)?(?\w+)\s*(?=\{|$)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex PhpPropertyHookAccessorRegex = new( - @"^\s*(?get|set)\b", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex DartClassDeclarationRegex = new( - @"^\s*(?:(?:abstract|base|final|interface|sealed)\s+)*(?:mixin\s+)?class\s+\w+", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex DartBareConstConstructorRegex = new( - @"^\s*const\s+(?[A-Z_]\w*(?:\.\w+)?)\s*\(", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CSharpSameLinePropertyStatementStartRegex = new( - $@"^\s*(?:(?:{CSharpVisibilityPattern})\s+|(?:static|virtual|override|abstract|sealed|new|required|partial|readonly|unsafe|extern|ref(?:\s+readonly)?)\s+)*(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?:ref(?:\s+readonly)?)\s+)?(?:{CSharpTypePattern})\s+(?:{CSharpExplicitInterfaceQualifierPattern}\.)?{CSharpIdentifierPattern}\s*(?:\{{|=>\s*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CSharpSameLineEventStatementStartRegex = new( - $@"^\s*(?:(?:{CSharpVisibilityPattern})\s+|(?:static|unsafe|extern|virtual|override|abstract|sealed|new|partial|file)\s+)*event\s+(?:{CSharpTypePattern})\s+{CSharpIdentifierPattern}\s*(?:[;=]|\{{)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CSharpSameLineDelegateStatementStartRegex = new( - $@"^\s*(?:(?:{CSharpVisibilityPattern})\s+|(?:static|unsafe|file|new)\s+)*delegate\s+(?:{CSharpTypePattern})\s+{CSharpIdentifierPattern}\s*[\(<]", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CSharpSameLineEventOrDelegateStatementStartRegex = new( - $@"^\s*(?:(?:{CSharpVisibilityPattern})\s+|(?:static|unsafe|extern|virtual|override|abstract|sealed|new|partial|file)\s+)*(?:event\s+(?:{CSharpTypePattern})\s+{CSharpIdentifierPattern}\s*(?:[;=]|\{{)|delegate\s+(?:{CSharpTypePattern})\s+{CSharpIdentifierPattern}\s*[\(<])", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - private static readonly HashSet JavaScriptTypeScriptControlFlowHeaderKeywords = - [ - "if", "for", "while", "switch", "catch", "with" - ]; - - private readonly record struct JavaScriptTypeScriptMethodHeaderInfo( - string Name, - int BodyStartColumn, - string? Visibility = null, - int? GenericStartColumn = null, - int? GenericEndColumn = null, - int? ReturnTypeStartColumn = null, - int? ReturnTypeEndColumn = null, - int? HeaderEndColumn = null, - bool HasBody = true, - bool IsAsync = false, - bool IsGenerator = false, - // For class-field arrow properties with an expression body (`handleClick = () => 42;`), - // this marks the inclusive column of the last expression char (before `;`) in the - // accumulated sanitized header. Null means brace body or no expression body was detected. - // クラスフィールド矢印プロパティが式本体を持つ場合 (`handleClick = () => 42;`)、 - // 終端記号 `;` の直前にある式末尾の inclusive 列位置。null は block body か式本体非検出。 - int? ExpressionBodyEndColumn = null); - - private readonly record struct JavaScriptTypeScriptMethodHeaderCapture( - string SourceHeader, - JavaScriptTypeScriptMethodHeaderInfo HeaderInfo, - int HeaderEndLineIndex, - int HeaderEndColumn, - int BodyStartLineIndex, - int BodyStartColumn, - // For expression-body arrow fields, these are the source line/col of the last - // expression char (`;` の直前). Null for brace-body arrow fields. - // 式本体矢印 field の場合の式末尾 source 位置 (終端 `;` の直前)。block body は null。 - int? BodyEndLineIndex = null, - int? BodyEndColumn = null); - - private struct JavaScriptTypeScriptFunctionHeaderState - { - public bool Active; - public bool SawParameterList; - public bool InReturnType; - public int ParenDepth; - public int BracketDepth; - public int BraceDepth; - public int ReturnParenDepth; - public int ReturnBracketDepth; - public int ReturnAngleDepth; - public int ReturnBraceDepth; - public bool ReturnSawToken; - public string? PreviousReturnToken; - } - - private enum JavaScriptTypeScriptMethodHeaderParseStatus - { - IncompleteOrInvalid = 0, - Parsed = 1, - DeclarationOnly = 2, - } - - private enum JavaScriptTypeScriptFunctionHeaderConsumeResult - { - NotActive = 0, - Consumed = 1, - BodyStart = 2, - } - - private const string JavaScriptTypeScriptIdentifierPattern = @"[$\p{L}_][$\p{L}\p{Nd}_]*"; - - private static readonly Regex JavaScriptTypeScriptAnonymousDefaultExportRegex = new( - @"^\s*(?export)\s+default\b", - RegexOptions.Compiled); - - private static readonly Regex JavaScriptTypeScriptClassExpressionBindingRegex = new( - $@"^\s*(?:(?export)\s+)?(?:(?const|let|var)\s+(?{JavaScriptTypeScriptIdentifierPattern})|exports\.(?{JavaScriptTypeScriptIdentifierPattern})|module\.exports\.(?{JavaScriptTypeScriptIdentifierPattern})|(?module\.exports))\s*=", - RegexOptions.Compiled); - - private static readonly Regex TypeScriptExportEqualsRegex = new( - @"^\s*export\s*=", - RegexOptions.Compiled); - - // Matches the binding portion of object-literal declarations: LHS identifier plus the `=` - // assignment. The opening `{` is intentionally NOT required on the same line so multi-line - // forms like `const obj =\n{\n ... }` are still detected. Callers locate the `{` via - // TryFindJavaScriptTypeScriptObjectLiteralOpenBrace (lex-state aware), then hand the resulting - // (lineOfBrace, columnOfBrace) to ResolveRange(BodyStyle.Brace). Recognizes - // const/let/var/export plus CommonJS module.exports / exports.NAME assignments. - // オブジェクトリテラル宣言の binding 部分(LHS 識別子と `=`)に一致させる。右辺の `{` を同一行に - // 要求しないのは、`const obj =\n{\n ... }` のような複数行スタイルも拾うため。`{` の位置は - // TryFindJavaScriptTypeScriptObjectLiteralOpenBrace が lex 状態を引き継ぎつつ別途走査し、 - // 見つけた (lineOfBrace, columnOfBrace) を ResolveRange(BodyStyle.Brace) に渡す。const/let/var/export - // に加え、CommonJS の module.exports / exports.NAME 代入経路にも対応する。 - private static readonly Regex JavaScriptTypeScriptObjectLiteralBindingRegex = new( - $@"^\s*(?:(?export)\s+)?(?:(?const|let|var)\s+(?{JavaScriptTypeScriptIdentifierPattern})|exports\.(?{JavaScriptTypeScriptIdentifierPattern})|module\.exports\.(?{JavaScriptTypeScriptIdentifierPattern})|(?module\.exports))(?:\s*:\s*[^=]+?)?\s*=\s*", - RegexOptions.Compiled); - - // Matches `export default` at start of line. `export default { ... }` is an anonymous object - // that becomes the module's default export; its method-shorthand members are attached to a - // virtual "default" container. Uses the same lex-aware `{` scan as the binding regex. - // 行頭の `export default` に一致。`export default { ... }` は無名オブジェクトでモジュールの - // 既定エクスポートになり、そのメソッド省略記法のメンバは仮想コンテナ "default" に紐付ける。 - // 後続の `{` の位置は binding 用と同じ lex-aware 走査で特定する。 - private static readonly Regex JavaScriptTypeScriptExportDefaultObjectLiteralRegex = new( - @"^\s*export\s+default\s*", - RegexOptions.Compiled); - - private static readonly Regex JavaScriptTypeScriptStarReExportRegex = new( - $@"^\s*export\s*(?:type\s+)?\*(?:\s*as\s+(?{JavaScriptTypeScriptIdentifierPattern}))?\s*from\s*(?['""][^'""]+['""])(?:\s+(?:with|assert)\s+\{{[^}}]*\}})?\s*;?\s*$", - RegexOptions.Compiled); - - private static readonly Regex JavaScriptTypeScriptNamedReExportRegex = new( - @"^\s*export\s*(?:type\s+)?\{\s*(?[^}]+)\s*\}\s*from\s*(?['""][^'""]+['""])(?:\s+(?:with|assert)\s+\{[^}]*\})?\s*;?\s*$", - RegexOptions.Compiled); - - private static readonly Regex JavaScriptTypeScriptDestructuredNamedExportRegex = new( - @"^\s*export\s+(?:const|let|var)\s*\{", - RegexOptions.Compiled); - - private static readonly Regex JavaScriptTypeScriptExportedVariableDeclarationRegex = new( - @"^\s*export\s+(?:declare\s+)?(?:const|let|var)\b", - RegexOptions.Compiled); - - private static readonly Regex JavaScriptTypeScriptCommonJsNamedExportAssignmentRegex = new( - $@"^\s*(?:module\.exports|exports)(?:\.(?{JavaScriptTypeScriptIdentifierPattern})|\[\s*(?:['""](?[^'""]*)['""]|(?\d+(?:\.\d+)?))\s*\])(?:\s*:\s*[^=]+?)?\s*(?])=(?![=>])\s*(?.*)$", - RegexOptions.Compiled); - - private static readonly Regex JavaScriptTypeScriptCommonJsDefaultExportAssignmentRegex = new( - @"^\s*module\.exports(?:\s*:\s*[^=]+?)?\s*(?])=(?![=>])\s*(?.*)$", - RegexOptions.Compiled); - - private static readonly Regex JavaScriptTypeScriptQualifiedAssignmentRegex = new( - $@"^\s*(?[A-Z][\w$]*(?:\.[\w$]+)+)\s*(?])=(?![=>])\s*(?.*)$", - RegexOptions.Compiled); - - private static readonly Regex JavaScriptTypeScriptArrowAssignmentValueRegex = new( - $@"^(?:async\s+)?(?:\([^)]*\)|{JavaScriptTypeScriptIdentifierPattern})\s*=>", - RegexOptions.Compiled); - - private static readonly Regex SvelteReactivePropertyRegex = new( - @"^\s*\$:\s*(?\w+)\s*=", - RegexOptions.Compiled); - - private const string VbVisibilityPattern = @"(?:Public|Private|Protected|Friend)(?:\s+(?:Protected|Friend))?"; - private const string VbTypeModifierPattern = @"(?:Partial|MustInherit|NotInheritable)"; - private const string VbMemberModifierPattern = @"(?:Shared|Overrides|Overridable|NotOverridable|MustOverride|Overloads|Shadows|Async|Iterator|Partial|Declare|PtrSafe|Auto|Ansi|Unicode)"; - private const string VbOperatorModifierPattern = @"(?:Shared|Overrides|Overridable|MustOverride|Overloads|Shadows|Async|Partial|Widening|Narrowing)"; - private const string VbPropertyModifierPattern = @"(?:Shared|Overrides|Overridable|NotOverridable|MustOverride|Overloads|Shadows|Default|ReadOnly|WriteOnly)"; - private const string VbEventModifierPattern = @"(?:Shared|Overloads|Shadows|Custom)"; - private const string VbIdentifierPattern = @"(?:\[[^\]\r\n]+\]|\w+)"; - - private static readonly Dictionary> PatternCache = new() - { - ["python"] = - [ - new("function", new Regex(@"^\s*(?:async\s+)?def\s+(?\w+)\s*(?:\[[^\]]*\])?\s*(?:\(|\[)", RegexOptions.Compiled), BodyStyle.Indent), - new("lambda", new Regex(@"^\s*(?\w+)\s*=\s*lambda\b", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*class\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Indent), - new("class", new Regex(@"^\s*(?\w+)\s*=\s*(?:(?:typing|collections)\.)?(?:NamedTuple|namedtuple)\s*\(", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*(?\w+)\s*=\s*(?:dataclasses\.)?make_dataclass\s*\(", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*(?\w+)\s*=\s*(?:(?:typing|typing_extensions)\.)?TypedDict\s*\(", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*(?\w+)\s*=\s*(?:enum\.)?(?:Enum|IntEnum|Flag|IntFlag|StrEnum)\s*\(", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*(?\w+)\s*=\s*(?:pydantic\.)?create_model\s*\(", RegexOptions.Compiled), BodyStyle.None), - new("typealias", new Regex(@"^\s*type\s+(?\w+)\s*(?:\[[^\]]*\])?\s*=", RegexOptions.Compiled), BodyStyle.None), - new("typealias", new Regex(@"^\s*(?\w+)\s*:\s*(?:(?:typing|typing_extensions)\.)?TypeAlias\s*=", RegexOptions.Compiled), BodyStyle.None), - new("typealias", new Regex(@"^\s*(?\w+)\s*=\s*(?:(?:typing|typing_extensions)\.)?NewType\s*\(", RegexOptions.Compiled), BodyStyle.None), - new("type_parameter", new Regex(@"^\s*(?\w+)\s*=\s*(?:(?:typing|typing_extensions)\.)?(?:TypeVar|ParamSpec|TypeVarTuple)\s*\(", RegexOptions.Compiled), BodyStyle.None), - new("property", new Regex(@"^\s*(?\w+)\s*:\s*(?:(?:typing|typing_extensions)\.)?Final(?:\[[^\]]+\])?\s*=", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(@"^\s*(?:from\s+(?(?:\.+[\w.]*|[\w.]+))\s+import\b|import\s+(?[\w.]+))", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(@"^\s*(?:[_\p{L}]\w*\s*=\s*)?(?:importlib\.import_module|importlib\.util\.find_spec|__import__)\s*\(\s*['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.None), - ], - ["cython"] = - [ - new("import", new Regex(@"^\s*from\s+(?" + CythonDottedIdentifierPattern + @")\s+cimport\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*cimport\s+(?" + CythonDottedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*import\s+(?" + CythonDottedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*include\s+(?:'(?[^']+)'|""(?[^""]+)"")", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*cdef\s+extern\s+from\s+(?:'(?[^']+)'|""(?[^""]+)"")", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex(@"^\s*cdef\s+class\s+(?" + CythonIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), - new("class", new Regex(@"^\s*class\s+(?" + CythonIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), - new("struct", new Regex(@"^\s*(?:ctypedef\s+)?cdef\s+struct\s+(?" + CythonIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), - new("enum", new Regex(@"^\s*(?:ctypedef\s+)?cdef\s+enum\s+(?" + CythonIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), - new("typealias", new Regex(@"^\s*ctypedef\s+(?!(?:struct|enum|union)\b)(?.+?)\s+(?" + CythonIdentifierPattern + @")\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), - new("function", new Regex(@"^\s*(?:cdef|cpdef)\s+(?!(?:class|struct|enum|extern)\b)" + CythonDeclarationPrefixPattern + @"(?:(?[\w.<>*,\[\]\s]+?)\s+)?(?" + CythonIdentifierPattern + @")\s*(?:\[[^\]]+\]\s*)?\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent, ReturnTypeGroup: "returnType"), - new("function", new Regex(@"^\s*(?:async\s+)?def\s+(?" + CythonIdentifierPattern + @")\s*(?:\[[^\]]*\])?\s*(?:\(|\[)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), - new("function", new Regex(@"^\s*" + CythonNativeReturnTypePattern + @"(?" + CythonIdentifierPattern + @")\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), - new("property", new Regex(@"^\s*cdef\s+(?!(?:class|struct|enum|extern)\b)" + CythonDeclarationPrefixPattern + @"(?.+?)\s+(?" + CythonIdentifierPattern + @")\s*(?::|=|$)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), - ], - ["cobol"] = - [ - // COBOL is organized around program IDs rather than brace-scoped members. - // Keep the extraction deliberately small and conservative: one symbol per program. - // COBOL は brace ではなく program ID 単位で構成されるため、抽出は保守的に - // program ひとつにつき 1 symbol に絞る。 - new("class", new Regex(@"^\s*(?:IDENTIFICATION\s+DIVISION\.\s*)?(?:PROGRAM|CLASS)-ID\.\s*(?[A-Z0-9][A-Z0-9-]*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*METHOD-ID\.\s*(?:""(?[^""]+)""|'(?[^']+)'|(?[A-Z0-9][A-Z0-9-]*))", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["javascript"] = - [ - // Include optional `*` between `function` and name for generator functions (e.g. `function* gen()`, `async function* asyncGen()`) - // `function` と名前の間に任意の `*` を許容し、ジェネレータ関数 (`function* gen()`, `async function* asyncGen()`) にも対応 - new("function", new Regex(@"^\s*(?export)\s+(?default)\s+(?async\s+)?function(?:\s+|\s*(?\*)\s*)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("function", new Regex(@"^\s*(?:(?export)\s+(?:default\s+)?)?(?async\s+)?function(?:\s+|\s*(?\*)\s*)(?\w+)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("lambda", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("lambda", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?\w+)\s*(?::\s*.+?)?\s*=\s*(?async\s+)?function\s*(?\*)?\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("function", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?\w+)\s*(?::\s*.+?)?\s*=\s*(?async\s+)?function(?:\s+|\s*(?\*)\s*)\w+\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // HOC-wrapped / call-result component bindings such as - // `const Wrapped = React.memo(...)`, `const Box = React.forwardRef(...)`, - // `const Connected = connect(...)(Component)`, `const Styled = styled.div`...``, - // or `const WithAuth = withAuthentication(Home)`. The arrow pattern above does - // not fire for these because the RHS is a call expression, tagged template, - // or plain identifier — there is no `=>` right after the `=`. The RHS is - // restricted to a known set of HOC call shapes — `React.memo(` / - // `React.forwardRef(` / `React.lazy(`, `styled.`/`styled(`/`styled``, - // bare `connect(`/`memo(`/`forwardRef(`/`lazy(`/`observer(`, and - // `with(`. Styled factory captures (`const F = styled.div;`) and - // plain styled calls (`const F = styled(Component);`) are NOT real component - // bindings — they produce a factory / a styled-component-of-component but do - // not declare a rendered component here — so an additional post-match gate - // rejects them unless the source line carries a tagged-template backtick. - // The gate checks the raw (unmasked) line because - // StructuralLineMasker.MaskJsTsTemplateLiteralContents masks template - // delimiters to space, which would otherwise make the same regex accept the - // non-template forms too. Unlike the TypeScript row below, the JavaScript - // row deliberately does NOT accept an optional `` token - // between the HOC call name and its `(` — JavaScript has no generic - // syntax and `const Result = memo < Props > (Component);` is a chained - // comparison / call expression that must not produce a phantom HOC - // binding. The asymmetry with the TypeScript row is documented on - // TypeScriptOptionalHocTypeArgsPattern. Ordinary PascalCase constants like - // `const Config = loadConfig();` and `const Theme = React.createContext(null);` - // (non-HOC React API calls — `createContext`, hooks, etc.) and class - // expressions like `const Widget = class extends ...` do NOT produce phantom - // `function` symbols. The class-expression synthetic pass owns the `= class` - // shape on its own. BodyStyle.None because the RHS body span is not - // line-trackable from the declaration line alone; declaration-only visibility - // into the symbol is still strictly better than dropping the binding. Place - // AFTER the arrow-function pattern so a capitalized arrow binding wins that - // row via stopAfterFirstPatternMatch and is not shadowed here. Closes #240. - // React.memo / React.forwardRef / connect(...)(Component) / styled.div`...` / - // withAuthentication(Home) のような HOC ラップや呼び出し結果代入の - // コンポーネント束縛を取り込む。上の arrow パターンは `=` 直後に `=>` を - // 要求するため、RHS が呼び出し式・タグ付きテンプレート・プレーン識別子では - // 発火しない。RHS を既知の HOC 呼び出し形 — `React.memo(` / `React.forwardRef(` - // / `React.lazy(`、`styled.` / `styled(` / `styled``、素の `connect(` / - // `memo(` / `forwardRef(` / `lazy(` / `observer(`、`with(` — に - // 限定する。styled の factory 捕捉(`const F = styled.div;`)や素の呼び出し - // (`const F = styled(Component);`)は実体のあるコンポーネント束縛ではないため、 - // マッチ後のゲートでタグ付きテンプレートのバッククォートを原文行に要求し、 - // これらが phantom な function シンボルを生やさないようにする。ゲートは raw - // 行を参照する — `StructuralLineMasker.MaskJsTsTemplateLiteralContents` が - // テンプレート区切りを空白にマスクするため、同じ regex を使っても masked - // 経由では区別できないのがゲートを raw 行で行う理由。JavaScript 行は TypeScript - // 行と異なり、HOC 呼び出し名と `(` の - // 間に generic 型引数トークン `<...>` を意図的に受け付けない。JavaScript に - // generic 構文は無く、`const Result = memo < Props > (Component);` は単なる - // 比較・呼び出し連鎖式であって phantom な HOC 束縛を生やしてはならない。 - // 非対称な扱いは TypeScriptOptionalHocTypeArgsPattern のコメントで詳述する。 - // `const Config = loadConfig();` のような通常 PascalCase 定数や、 - // `const Theme = React.createContext(null);` のような非 HOC の React API 呼び出し - // (`createContext` や hooks 等)、`const Widget = class extends ...` の - // クラス式束縛で架空の `function` シンボルが生えないようにする。`= class` 形は - // class expression の合成パスが単独で処理する。RHS 本体は宣言行だけでは - // 行単位に追えないため BodyStyle.None。宣言のみでも束縛が消失するよりは実用的。 - // arrow パターンより後に置き、大文字始まりの arrow 束縛は先に一致した段階で - // stopAfterFirstPatternMatch が立ち、こちらで上書きされないようにする。 - // Closes #240. - new("function", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?[A-Z]\w*)\s*=\s*(?:React\.(?:memo|forwardRef|lazy)\s*\(|styled[.(`]|connect\s*\(|memo\s*\(|forwardRef\s*\(|lazy\s*\(|observer\s*\(|with[A-Z]\w*\s*\()", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("class", new Regex(@"^\s*(?:(?export)\s+)?(?:default\s+)?class\s+(?(?!extends\b)\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("import", new Regex(@"^\s*import\s+(?.+?)\s+from\s+", RegexOptions.Compiled), BodyStyle.None), - ], - ["typescript"] = - [ - // Include optional `*` between `function` and name for generator functions (e.g. `function* gen()`, `async function* asyncGen()`) - // `function` と名前の間に任意の `*` を許容し、ジェネレータ関数 (`function* gen()`, `async function* asyncGen()`) にも対応 - new("function", new Regex(@"^\s*(?export)\s+(?default)\s+(?async\s+)?function(?:\s+|\s*(?\*)\s*)" + TypeScriptOptionalTypeParameterListPattern + @"\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("function", new Regex(@"^\s*(?:(?export)\s+(?:default\s+)?)?(?:declare\s+)?(?async\s+)?function(?:\s+|\s*(?\*)\s*)(?\w+)\s*[\(<]", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("import", new Regex(@"^\s*(?:(?export)\s+)?(?:declare\s+)?type\s+(?\w+)" + TypeScriptOptionalTypeParameterListPattern + @"\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("property", new Regex(@"^\s*(?:(?export)\s+)?declare\s+(?:const|let|var)\s+(?\w+)(?::\s*[^;=]+)?\s*;", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("lambda", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?\w+)\s*(?::\s*.+?)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("lambda", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?\w+)\s*(?::\s*.+?)?\s*=\s*(?async\s+)?function\s*(?\*)?\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("function", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?\w+)\s*(?::\s*.+?)?\s*=\s*(?async\s+)?function(?:\s+|\s*(?\*)\s*)\w+\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // HOC-wrapped / call-result component bindings — same narrow HOC-prefix set - // as the JavaScript row above, extended with an optional TypeScript generic - // type-argument token between the HOC call name and its `(` via the shared - // TypeScriptOptionalHocTypeArgsPattern constant. The generic token balances - // up to three levels of nested angle brackets - // (`React.memo>>(Box)`) and allows - // parenthesised segments inside a generic argument - // (`React.memo<(props: Props) => JSX.Element>(Box)`) so function-type and - // conditional-type TS HOC call sites still match. The `React.` branch is - // pinned to `React.memo(` / `React.forwardRef(` / `React.lazy(` so non-HOC - // React API calls (`const Theme = React.createContext(null);`, - // `const Stable = React.useCallback(() => 1, []);`) do NOT produce phantom - // `function` rows on the TypeScript side either. The JavaScript row above - // intentionally does NOT carry the generic token because JS has no generic - // syntax and `memo < Props > (Component)` is a chained comparison / call - // expression; see the TypeScriptOptionalHocTypeArgsPattern comment for the - // ReDoS-safety reasoning behind the 3-level-plus-parens shape. TypeScript - // sources often carry a type annotation between the binding name and `=` - // (e.g. `const Connected: React.ComponentType = connect(...)(MyComponent);`). - // The optional `:` branch consumes the annotation lazily up to the first `=`; - // even when a type contains `=>` (as in `const F: () => void = fn;`), the - // lazy match back-tracks so the name group is still captured correctly. The - // arrow-function row above also accepts the same optional annotation so a - // typed arrow binding (`const Callback: (x: number) => number = (x) => - // x + 1;`) still wins with BodyStyle.Brace and is not shadowed here. - // Closes #240. - // HOC ラップや呼び出し結果代入のコンポーネント束縛 — JavaScript 行と同じ - // 狭い HOC プレフィックス集合を使い、共有定数 - // TypeScriptOptionalHocTypeArgsPattern で HOC 呼び出し名と `(` の間に - // TypeScript の generic 型引数トークンをオプションで受け入れる。この - // トークンは 3 段までのネストした山括弧 - // (`React.memo>>(Box)`)と、 - // generic 引数内の丸括弧付きセグメント - // (`React.memo<(props: Props) => JSX.Element>(Box)`)を許容するため、 - // 関数型・条件型を使う TS HOC 呼び出しもマッチする。`React.` 分岐は - // `React.memo(` / `React.forwardRef(` / `React.lazy(` に固定し、 - // `const Theme = React.createContext(null);` や - // `const Stable = React.useCallback(() => 1, []);` のような非 HOC の - // React API 呼び出しが TypeScript 側でも phantom `function` シンボルを - // 生やさないようにする。JavaScript 行は generic トークンを意図的に持たない。 - // JS に generic 構文は無く、`memo < Props > (Component)` は比較・呼び出しの - // 連鎖式だからである。3 段 + 括弧許容にした ReDoS 安全性の根拠は - // TypeScriptOptionalHocTypeArgsPattern のコメントを参照。TypeScript では - // 束縛名と `=` の間に型注釈(例: - // `const Connected: React.ComponentType = connect(...)(MyComponent);`) - // が入ることが多いため、オプションの `:` 分岐で最初の `=` まで遅延一致する。 - // 型に `=>` が含まれる場合(例: `const F: () => void = fn;`)もバックトラックで - // 名前グループは正しく取得できる。上の arrow 行も同じ型注釈を受け付けるため、 - // 型注釈付き arrow 束縛(`const Callback: (x: number) => number = (x) => - // x + 1;`)は BodyStyle.Brace 側で先勝ちし、こちらで上書きされない。 - // Closes #240. - new("function", new Regex(@"^\s*(?:(?export)\s+)?(?:const|let|var)\s+(?[A-Z]\w*)\s*(?::\s*.+?)?\s*=\s*(?:React\.(?:memo|forwardRef|lazy)\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\(|styled[.(`]|connect\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\(|memo\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\(|forwardRef\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\(|lazy\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\(|observer\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\(|with[A-Z]\w*\s*" + TypeScriptOptionalHocTypeArgsPattern + @"\()", RegexOptions.Compiled), BodyStyle.None, "visibility"), - // Abstract class, declare class / 抽象クラス、declare クラス - new("class", new Regex(@"^\s*(?:(?export)\s+)?(?:default\s+)?(?:(?:abstract|declare)\s+)*class\s+(?(?!(?:extends|implements)\b)\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // UMD namespace export / UMD 名前空間エクスポート - new("namespace", new Regex($@"^\s*export\s+as\s+namespace\s+(?{JavaScriptTypeScriptIdentifierPattern})", RegexOptions.Compiled), BodyStyle.None), - // namespace/module — supports both identifier (namespace Foo) and quoted ambient (declare module 'express') - // 名前空間・モジュール — 識別子形式と引用符付きアンビエント形式の両方に対応 - new("namespace", new Regex(@"^\s*(?:(?export)\s+)?(?:declare\s+)?(?:namespace|module)\s+['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("namespace", new Regex(@"^\s*(?:(?export)\s+)?(?:declare\s+)?(?:namespace|module)\s+(?[\w.]+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("interface", new Regex(@"^\s*(?:(?export)\s+)?(?:declare\s+)?interface\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("import", new Regex(@"^\s*(?:(?export)\s+)?(?:declare\s+)?type\s+(?\w+)(?:\s*<[^=]+>)?", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("enum", new Regex(@"^\s*(?:(?export)\s+)?(?:declare\s+)?(?:const\s+)?enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("import", new Regex(@"^\s*import\s+(?.+?)\s+from\s+", RegexOptions.Compiled), BodyStyle.None), - ], - ["csharp"] = - [ - // Verbatim and Unicode-escaped identifier segments (`@Foo.@Bar`, `\u0046oo`) are - // accepted via `CSharpNamespacePattern` / `CSharpIdentifierPattern` and later - // canonicalized by `CSharpSymbolNameNormalizer`. - // verbatim / Unicode escape 識別子の各セグメントを `CSharpNamespacePattern` / - // `CSharpIdentifierPattern` 経由で受け入れ、`CSharpSymbolNameNormalizer` で - // canonical 化する。 - new("namespace", new Regex($@"^\s*namespace\s+(?{CSharpNamespacePattern})\s*;", RegexOptions.Compiled), BodyStyle.None), // file-scoped namespace (C# 10+) - new("namespace", new Regex($@"^\s*namespace\s+(?{CSharpNamespacePattern})", RegexOptions.Compiled), BodyStyle.Brace), // block-scoped namespace - // extern alias (must precede using directives per C# spec) — captures assembly-alias reconciliation - // extern alias — C# 仕様上 using より前に置かれるファイル先頭宣言。アセンブリエイリアス用 - new("import", new Regex($@"^\s*extern\s+alias\s+(?{CSharpIdentifierPattern})\s*;", RegexOptions.Compiled), BodyStyle.None), - // using alias (using X = Y;) — must come before general using to capture alias name. - // Verbatim alias identifiers like `using @AliasAttr = A.BaseAttr;` still surface as an - // `import` row via `CSharpIdentifierPattern`; the DbWriter-side normalizer strips the - // leading `@`. - // using エイリアス — 一般 using より前に配置しエイリアス名を取得。verbatim 識別子 - // (`using @AliasAttr = A.BaseAttr;`) も `CSharpIdentifierPattern` 経由で import 行として - // 拾える。 - new("import", new Regex($@"^\s*(?:global\s+)?using\s+(?{CSharpIdentifierPattern})\s*=\s*[^;]+;", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(@"^\s*(?:global\s+)?using\s+(?:static\s+)?(?[^;=]+);", RegexOptions.Compiled), BodyStyle.None), - // Const field — must come before class/method patterns to avoid misclassification. - // Modifier order is free: visibility may appear anywhere in the modifier sequence, - // so `new public const` and `public new const` are both captured. Closes #355. - // returnType uses the shared CSharpTypePattern (same token the method / property / - // indexer / delegate / event rows already use) so tuple / named-tuple / - // nullable-tuple / generic-over-tuple / global::-qualified / tuple-array const field - // types are captured instead of silently dropped. The legacy hand-rolled char class - // had no `(`, `)`, or `\s`, so `public const (int, int) Pair = (1, 2);` failed the - // returnType group and fell through every subsequent row. Closes #346. - // const フィールド — クラス/メソッドパターンより前に配置し誤分類を防ぐ。 - // 修飾子順序は自由で、visibility は修飾子列の任意位置に現れてよい(例: `new public const` / - // `public new const`)。Closes #355. - // returnType は method / property / indexer / delegate / event 行で既に使っている共有 - // トークン CSharpTypePattern を使う。これにより tuple / 名前付き tuple / nullable tuple / - // generic-over-tuple / `global::` 修飾 / tuple-array を戻り値型とする const フィールドを - // 取りこぼさない。従来の手書き文字クラスには `(` / `)` / `\s` が無く、 - // `public const (int, int) Pair = (1, 2);` は returnType 群で失敗し、以降のどの行にも - // マッチしなかった。Closes #346. - new("function", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:new|static)\s+)*const\s+(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), - // Static readonly field / static readonly フィールド - // Modifier order is free: `static` and `readonly` may appear in any order, and `new` - // (member hiding) may appear anywhere in the modifier sequence. Visibility is also - // accepted anywhere, not just at the front, so legacy orderings like - // `readonly public static` / `static public readonly` still classify as fields - // instead of falling through to the plain-field (kind `property`) row. Closes #355. - // static/readonly の順序は自由で、`new`(メンバー隠蔽)も任意位置に置ける。visibility も - // 先頭以外の位置に現れることを許容し、`readonly public static` や `static public readonly` - // のような旧来の並びでも kind `field` で取り扱う。通常フィールド(kind `property`)の - // 正規表現に流れ落ちないようにする。Closes #355. - // Share CSharpTypePattern with const and plain fields so tuple, nullable-tuple, and - // generic-over-tuple types retain stable field kind and complete return-type metadata. - // const / 通常フィールドと CSharpTypePattern を共有し、tuple / nullable tuple / - // generic-over-tuple 型でも安定した field kind と完全な return-type metadata を保持する。 - // Closes #4616. - new("function", new Regex( - $@"^\s*" - + $@"(?=(?:(?:{CSharpVisibilityPattern}|new|static|readonly)\s+)*static\s+)" - + $@"(?=(?:(?:{CSharpVisibilityPattern}|new|static|readonly)\s+)*readonly\s+)" - + $@"(?:(?{CSharpVisibilityPattern})\s+|(?:new|static|readonly)\s+)+" - + $@"(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*[=;]", - RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), - // Plain field (instance, readonly, volatile, plain static, etc.) — kind `property`. - // Must come AFTER the `const` and `static readonly` patterns (which take priority - // with kind `function`), and BEFORE the structural declaration patterns. - // The terminator `=(?![=>])` or `;` distinguishes fields from methods (which end - // with `(`), property accessors (which end with `{`), expression-bodied members - // (which use `=>`), and comparison-operator overloads (which contain `==`). - // The negative lookahead repeats every visibility and modifier keyword so the - // regex engine cannot backtrack past an unconsumed `public static event …` - // declaration and match it as a field whose returnType is `public static event …`. - // Closes #298. - // 通常フィールド(instance / readonly / volatile / 通常 static など) — kind は `property`。 - // `const` / `static readonly` パターン(kind `function`)より後、型宣言パターンより前に置く。 - // 終端を `=(?![=>])` または `;` にすることで、メソッド(`(`)、プロパティアクセサ(`{`)、 - // 式本体メンバー(`=>`)、比較演算子オーバーロード(`==`)を除外する。 - // visibility / modifier キーワードを negative lookahead にも並べて、regex engine が - // それらを returnType として飲み込む方向に backtrack して `public static event …` - // のような宣言を field としてマッチすることを防ぐ。Closes #298. - // Modifier order is free, so visibility may appear anywhere in the modifier - // sequence (e.g. `static public int X;`). Closes #355. - // 修飾子順序は自由で、visibility を修飾子列の任意位置に置ける - // (例: `static public int X;`)。Closes #355. - new("property", new Regex( - $@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|readonly|volatile|new|unsafe|extern|required)\s+)*" - + @"(?!(?:public|private|protected|internal|static|readonly|volatile|new|unsafe|extern|required|abstract|virtual|override|sealed|async|partial|file|ref|var|class|struct|interface|enum|record|namespace|delegate\b(?!\*)|event|const|using|return|throw|yield|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await|try|do|typeof|sizeof|nameof|default|operator|this|base)\b)" - + $@"(?{CSharpTypePattern})\s+" - + @"(?" + CSharpIdentifierPattern + @")\s*(?:=(?![=>])|;)", - RegexOptions.Compiled), - BodyStyle.None, "visibility", "returnType"), - // Interface — visibility optional; modifier order is free, so visibility may appear - // anywhere in the modifier sequence (e.g. `partial public interface`, `file interface`, - // `new public interface` for nested types). Closes #355. - // インターフェース — visibility 省略可。修飾子順序は自由 - // (例: `partial public interface`、`file interface`、ネスト型向けの `new public interface`)。Closes #355. - new("interface", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:partial|unsafe|file|new)\s+)*interface\s+(?{CSharpIdentifierPattern})", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Enum — visibility optional / enum — visibility 省略可 - new("enum", CSharpEnumDeclarationRegex, BodyStyle.Brace, "visibility"), - // Struct (including record struct, ref struct, readonly struct) — visibility optional; - // modifier order is free, so visibility may appear anywhere in the modifier sequence - // (e.g. `readonly public struct`, `ref public struct`). Closes #355. - // 構造体(record struct, ref struct, readonly struct を含む)— visibility 省略可。 - // 修飾子順序は自由で、visibility は任意位置に置いてよい(例: `readonly public struct`、 - // `ref public struct`)。Closes #355. - new("struct", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|partial|readonly|file|new|ref|unsafe)\s+)*(?:record\s+)?struct\s+(?{CSharpIdentifierPattern})", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Class (including record, record class) — visibility optional (defaults to internal - // for top-level); modifier order is free, so visibility may appear anywhere in the - // modifier sequence (e.g. `abstract public class`, `sealed public class`). Closes #355. - // クラス(record, record class を含む)— visibility は省略可能(トップレベルでは internal がデフォルト)。 - // 修飾子順序は自由で、visibility は任意位置に置いてよい(例: `abstract public class`、 - // `sealed public class`)。Closes #355. - new("class", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|partial|abstract|sealed|readonly|file|new|unsafe)\s+)*(?:record\s+class\s+|record\s+|class\s+)(?{CSharpIdentifierPattern})", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Implicit/explicit conversion operator — must come before general operator pattern. - // Visibility may appear before or after `static` / `unsafe` / `extern`. Closes #355. - // Modifier slot also accepts `abstract|virtual|sealed|override|new` so C# 11 - // `static abstract` / `abstract static` interface conversion operators (generic - // math: `System.Numerics.INumber` etc.) and default-implementation / - // member-hiding forms on interfaces are not silently dropped. Closes #244. - // 暗黙的/明示的変換演算子 — 一般のoperatorパターンより先に配置。 - // visibility は `static` / `unsafe` / `extern` のどちら側にも置ける。Closes #355. - // 修飾子スロットは `abstract|virtual|sealed|override|new` も受け付ける。 - // これにより C# 11 の `static abstract` / `abstract static` interface 変換演算子 - // (generic math: `System.Numerics.INumber` など)と、interface 上の - // default implementation / member hiding 形態を黙って取りこぼさない。Closes #244. - new("operator", new Regex( - $@"^\s*" - + $@"(?=(?:(?:{CSharpVisibilityPattern}|static|abstract|virtual|sealed|override|new|unsafe|extern)\s+)*static\s+)" - + $@"(?:(?{CSharpVisibilityPattern})\s+|(?:static|abstract|virtual|sealed|override|new|unsafe|extern)\s+)+" - + @"(?(?:implicit|explicit)\s+operator\s+.+?)\s*\(", - RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Operator overload (+ - * / == != < > etc.) — must come before method pattern. - // Visibility may appear before or after `static`. Closes #355. - // Modifier slot also accepts `abstract|virtual|sealed|override|new` so C# 11 - // `static abstract` / `abstract static` interface operators (generic math: - // `IAdditionOperators`, `IComparisonOperators`, etc.) are not silently - // dropped. Closes #244. - // 演算子オーバーロード — メソッドパターンより前に配置。 - // visibility は `static` のどちら側にも置ける。Closes #355. - // 修飾子スロットは `abstract|virtual|sealed|override|new` も受け付ける。 - // これにより C# 11 の `static abstract` / `abstract static` interface 演算子 - // (generic math: `IAdditionOperators`、`IComparisonOperators` など)を - // 黙って取りこぼさない。Closes #244. - new("operator", new Regex( - $@"^\s*" - + $@"(?=(?:(?:{CSharpVisibilityPattern}|static|abstract|virtual|sealed|override|new|unsafe|extern)\s+)*static\s+)" - + $@"(?:(?{CSharpVisibilityPattern})\s+|(?:static|abstract|virtual|sealed|override|new|unsafe|extern)\s+)+" - + @".+?\s+(?operator\s+(?:checked\s+)?\S+)\s*\(", - RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Method with return type — visibility optional for explicit interface impl and nested members. - // Negative lookahead excludes call-site lines (await/return/throw/yield/var/typeof/sizeof/nameof/default/if/for/while/switch/catch/lock/using) - // and ternary continuation branches (`? Foo(...)` / `: Foo(...)`) that would otherwise resemble returnType + name. - // LINQ query-expression keywords (from/where/select/orderby/group/join/let/into/on/equals/ascending/descending/by) - // are also excluded so continuation lines like `select Mapper.Convert(x)` or `where Validator.Check(x)` do not - // fire returnType+qualifier+name phantoms. The lookahead is anchored to the line-leading token, so it only - // blocks continuation forms; ordinary method declarations whose NAME happens to be a LINQ keyword still match - // via their return type (e.g. `public void where() { }`). Closes #377. - // The `(?!(?:base|this)\b)` guard on the name capture belt-and-suspenders against constructor-chain - // initializers (`: base(...)` / `: this(...)`) leaking phantom `function base` / `function this` - // symbols if any upstream guard becomes permissive. Closes #331. - // Note: `new` is NOT excluded because `new void Hidden()` is a valid C# member-hiding declaration. - // 戻り値型付きメソッド — 明示的インターフェース実装やネストメンバー向けに visibility 省略可。 - // negative lookahead で呼び出し行(await/return/throw/yield/var/typeof 等)と ternary continuation を除外する。 - // LINQ 式キーワード (from/where/select/orderby/group/join/let/into/on/equals/ascending/descending/by) も除外し、 - // `select Mapper.Convert(x)` や `where Validator.Check(x)` のような continuation 行が returnType+qualifier+name - // phantom を生まないようにする。lookahead は行頭トークンに固定しているため、continuation 形のみを弾き、 - // LINQ キーワードと同名のメソッド(例: `public void where() { }`)は戻り値型を介して通常どおり一致する。Closes #377. - // `(?!(?:base|this)\b)` を name キャプチャに付け、上流ガードが緩んだ場合でも - // コンストラクタ初期化子 (`: base(...)` / `: this(...)`) が phantom `function base` / `function this` - // として漏れないよう二重化する。Closes #331. - // 注意: `new` は除外しない。`new void Hidden()` は C# のメンバー隠蔽宣言として有効。 - new("function", new Regex($@"^\s*(?!\[\s*(?:assembly|module|type|return|param|field|property|event|method)\s*:)(?:(?{CSharpVisibilityPattern})\s+|(?:static|sealed|partial|readonly|unsafe|extern|virtual|override|abstract|new|file|ref(?:\s+readonly)?)\s+)*async\s+(?(?=[\w@?.<>\[\],:\s]*IAsync(?:Enumerable|Enumerator)\b){CSharpTypePattern})\s+(?!(?:base|this)\b)(?{CSharpIdentifierPattern})\s*{CSharpMethodTypeParameterListPattern}\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), - new("function", new Regex($@"^\s*(?!\[\s*(?:assembly|module|type|return|param|field|property|event|method)\s*:)(?![?:])(?!(?:await|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|using|case|else|when|break|continue|goto|from|where|select|orderby|group|join|let|into|on|equals|ascending|descending|by)\b)(?!\s*(?:(?:{CSharpVisibilityPattern}|static|sealed|partial|readonly|unsafe|extern|virtual|override|abstract|async|new|file|ref(?:\s+readonly)?)\s+)*delegate\b(?!\s*\*))(?:(?{CSharpVisibilityPattern})\s+|(?:static|sealed|partial|readonly|unsafe|extern|virtual|override|abstract|async|new|file|ref(?:\s+readonly)?)\s+)*(?!{CSharpNonTypeKeywordPattern})(?{CSharpTypePattern})\s+(?!(?:base|this)\b)(?{CSharpIdentifierPattern})\s*{CSharpMethodTypeParameterListPattern}\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), - new("lambda", new Regex($@"^\s*(?:var|{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*=\s*(?:async\s+)?(?:\([^)]*\)|{CSharpIdentifierPattern})\s*=>", RegexOptions.Compiled), BodyStyle.None), - // Constructor (no return type, name followed by parenthesis) — needs visibility. - // `unsafe` / `extern` can appear before or after visibility, and C# 14 partial - // constructors place `partial` after visibility, so declarations like - // `unsafe public S(int* p) {}`, `extern public S(int x);`, and `public partial S();` - // are still captured with visibility populated. Closes #355. - // The negative lookahead after the opening paren rejects lines where the matching - // `)` is followed by an identifier + `{` / `(` / `;` / `=>` / `=` (with optional - // tuple-type suffixes `?` / `[]` / `[,]` / `[,,]` and whitespaced variants like - // `) []` / `) ?` in between via CSharpTupleSuffixPattern), which is the shape of a - // property with a modifier + tuple return type (`public required (int, int) R1 - // { get; init; }`, `public required (int, int) [] R4 { get; init; }`), an - // expression-bodied method with a modifier (`public readonly (int, int)? M() => - // null;`, `public readonly (int, int) ? M3() => default;`), or a plain field with a - // modifier + tuple type — both the uninitialized form (`public readonly (int, int) ? - // F5;`, terminated by `;`) and the initialized form (`public readonly (int, int) ? - // F4 = null;`, terminated by `=` excluding `==` / `=>`). A plain ctor signature - // cannot match because there is no identifier between the closing `)` and the body - // opener. The plain-field shapes are covered because #400's same-line plain-field - // advance no longer sets stopAfterFirstPatternMatch, so the ctor regex now runs on - // lines the plain-field pattern already claimed and would otherwise re-emit a phantom - // `function readonly` ctor row. Using a positional check (not a keyword deny-list) - // preserves support for legal (though unusual) type names that collide with - // contextual keywords. Multi-line ctor signatures where the closing `)` is on a - // later line are unaffected because the lookahead only triggers when a `)` is - // visible on the current line. Sharing CSharpTupleSuffixPattern with CSharpTypePattern - // keeps the ctor lookahead and the upstream property / method / plain-field rows in - // sync on which formatting variants count as a tuple-suffix return type. Closes #349. - // コンストラクタ(戻り値なし、名前の後に括弧)— visibility 必須。 - // `unsafe` / `extern` は visibility の前後どちらにも置け、C# 14 の partial - // constructor は visibility の後ろに `partial` を置くため、 - // `unsafe public S(int* p) {}`、`extern public S(int x);`、`public partial S();` - // でも visibility を拾える。Closes #355. - // 開き括弧の直後に置いた否定先読みは、「対応する `)` のあとに識別子 + `{` / `(` / `;` / - // `=>` / `=`(間に `?` / `[]` / `[,]` / `[,,]` の tuple サフィックス、および - // CSharpTupleSuffixPattern によって `) []` / `) ?` のような空白を挟んだ整形バリエーションも - // 許す)」形の行を弾く。これは `public required (int, int) R1 { get; init; }` や - // `public required (int, int) [] R4 { get; init; }` のような modifier 付き property、 - // `public readonly (int, int)? M() => null;` や `public readonly (int, int) ? M3() => default;` - // のような modifier 付き式形式メソッド、および modifier 付き tuple 型の plain field — - // `public readonly (int, int) ? F5;` のような未初期化(`;` 終端)形、 - // `public readonly (int, int) ? F4 = null;` のような初期化(`=` 終端、`==` / `=>` は除外)形 — - // であり、従来はいずれも `required` / `readonly` を ctor 名として greedy に喰っていた。 - // 通常の ctor シグネチャでは閉じ括弧と本体開始の間に識別子が入らないためマッチし続ける。 - // plain field 形が対象に入ったのは、#400 の同一行 plain-field 前進が - // stopAfterFirstPatternMatch をセットしなくなったため、ctor 正規表現が plain-field - // パターン既取得の行にも再走して phantom `function readonly` を再発する経路ができたため。 - // キーワード deny-list ではなく位置検査なので、contextual keyword と綴りが衝突する合法な - // 型名のコンストラクタも弾かない。複数行にまたがる ctor シグネチャ(閉じ括弧が次行以降にある場合)は、 - // 現在行に `)` が出ないため lookahead が発動せずそのままマッチする。 - // CSharpTupleSuffixPattern を CSharpTypePattern と共有することで、ctor 否定先読みと上流の - // property / method / plain-field 行が tuple サフィックス戻り値の受理形について常に一致する。Closes #349. - new("function", new Regex($@"^\s*(?:(?:unsafe|extern)\s+)*(?{CSharpVisibilityPattern})\s+(?:(?:unsafe|extern|partial)\s+)*(?{CSharpIdentifierPattern})\s*\((?!.*\){CSharpTupleSuffixPattern}\s*{CSharpIdentifierPattern}\s*(?:[{{(;]|=>|=(?![=>])))", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Partial method declaration with an omitted return type. Older partial-method - // syntax can omit the accessibility modifier and still mean `void`; keep this - // after the constructor row so `public partial Widget();` remains a constructor. - // 戻り値型を省略した partial method 宣言。旧来の partial method 構文では - // accessibility を省略し、戻り値型は `void` とみなされる。`public partial Widget();` - // は constructor のまま扱うため、この行は constructor 行の後ろに置く。 - new("function", new Regex($@"^\s*(?:(?:static|sealed|readonly|unsafe|extern|virtual|override|abstract|async|new|file|ref(?:\s+readonly)?)\s+)*(?partial)\s+(?{CSharpIdentifierPattern})\s*{CSharpMethodTypeParameterListPattern}\(", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - // Static constructor / 静的コンストラクタ - // Keep this ahead of the property rows so same-line compact bodies such as - // `class C { static C() { } public int P { get; set; } }` emit the static ctor - // before the later property match short-circuits the pattern scan. The shape is - // specific enough that it does not overlap with normal methods (no return type, - // empty parameter list, optional `unsafe` around `static`). Closes #478. - // 同一行のコンパクトな型本体 - // (`class C { static C() { } public int P { get; set; } }`) では、後続 property が - // pattern scan を打ち切る前に static ctor を先に拾う必要があるため、property 行より前に置く。 - // この形は「戻り値型なし・引数なし・`static` 前後の任意 `unsafe`」に限定されるため、 - // 通常メソッドとは重ならない。Closes #478. - new("function", new Regex($@"^\s*(?:unsafe\s+)?static\s+(?:unsafe\s+)?(?{CSharpIdentifierPattern})\s*\(\s*\)\s*\{{?", RegexOptions.Compiled), BodyStyle.Brace), - // Property with get/set/init — visibility optional - // Reject statement keywords (return/throw/switch/...) as the return type so that - // multi-line statement fragments merged by BuildCSharpPropertyMatchLine — e.g. - // `return o switch` combined with an opening `{` on the next line — are not - // misclassified as a property. Closes #233. - // プロパティ(get/set/init)— visibility 省略可 - // `return o switch` のような複数行にまたがる文断片が `BuildCSharpPropertyMatchLine` - // で結合された結果、property として誤判定されるのを防ぐため、戻り値型として - // ステートメントキーワードを拒否する。Closes #233. - new("property", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|virtual|override|abstract|sealed|new|required|partial|readonly|unsafe|extern|ref(?:\s+readonly)?)\s+)*(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*\{{", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), - // Expression-bodied property (public int X => ...) — must come before delegate. - // Uses BodyStyle.Brace so FindCSharpBraceRange detects '=>' and assigns a body - // range covering the declaration line through the terminating ';', which - // ReferenceExtractor.FindInnermostContainer needs to attribute accessor-internal - // calls to the property rather than the enclosing class. - // Closes #233. - // 式本体プロパティ (public int X => ...) — delegate の前に配置。 - // `BodyStyle.Brace` にして `FindCSharpBraceRange` の '=>' 検出で宣言行から - // 終端 ';' までを本体範囲として扱えるようにする。 - // ReferenceExtractor.FindInnermostContainer が accessor 内呼び出しを外側 - // クラスではなく property に帰属させるために必要。 - // Closes #233. - new("property", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|virtual|override|abstract|sealed|new|required|partial|readonly|unsafe|extern|ref(?:\s+readonly)?)\s+)*(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*=>\s*", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), - // Delegate — visibility optional; modifier order is free. Accepts `static` / `unsafe` / - // `file` (file-scoped delegate) / `new` (nested delegate hiding). Closes #355. - // デリゲート — visibility 省略可。修飾子順序は自由。`static` / `unsafe` / - // `file`(file スコープ delegate)/ `new`(ネスト delegate の隠蔽)を受け付ける。Closes #355. - new("delegate", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|unsafe|file|new)\s+)*delegate\s+(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*[\(<]", RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), - // Event — visibility optional; modifier order is free. Accepts `static` / `unsafe` / - // `extern` plus inheritance modifiers (`virtual` / `override` / `abstract` / `sealed` / `new`) - // which are all legal on event declarations per the C# spec. `partial` is also legal on - // events (C# 14 field-like partial events, and extended partial member support on accessor - // events), so accept it as well — otherwise every `partial event` declaration would be - // silently dropped from symbols / definition / outline. Closes #350. - // イベント — visibility 省略可。修飾子順序は自由。`static` / `unsafe` / `extern` に加え、 - // C# 仕様で event 宣言に有効な継承修飾子 (`virtual` / `override` / `abstract` / `sealed` / `new`) - // も受け付ける。event には `partial` も合法 (C# 14 field-like partial event、およびアクセサ - // ベースの partial member 拡張) なので、ここでも受け付けないと `partial event` 宣言が - // symbols / definition / outline から無言で欠落する。Closes #350. - new("event", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|unsafe|extern|virtual|override|abstract|sealed|new|partial)\s+)*event\s+(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*(?:[;=]|\{{)", RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), - // Explicit interface event implementation (e.g. event EventHandler IFoo.Changed) - // must capture the trailing member name rather than dropping the declaration or - // inventing the qualifier as the event name. BodyStyle.Brace lets accessor blocks - // on the same line or following lines share the normal brace-range path. - // 明示的インターフェース event 実装 (例: event EventHandler IFoo.Changed) は、 - // qualifier 側ではなく末尾のメンバー名を event 名として捕捉しなければならない。 - // BodyStyle.Brace を使い、同一行/次行どちらの accessor block も通常の brace-range - // 経路で扱う。 - new("event", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|unsafe|extern|virtual|override|abstract|sealed|new|partial)\s+)*event\s+(?{CSharpTypePattern})\s+{CSharpExplicitInterfaceQualifierPattern}\s*\.\s*(?{CSharpIdentifierPattern})\b", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), - // Explicit interface implementation (e.g. void IDisposable.Dispose()) - // Requires a valid return type (not a statement keyword) and interface name before the dot. - // Reject named-argument labels only when they are followed by a qualified call site, - // so alias-qualified types like `global::System.String` and `Alias::Type` still match. - // LINQ query-expression keywords are also excluded from the negative lookahead so that - // continuation lines like `where Validator.Check(x)` / `select Mapper.Convert(x)` / - // `orderby Math.Abs(x)` do not match as `returnType + interface.member`. `new` is also - // excluded so expression statements like `new System.Text.StringBuilder().Append(...)` - // or `new Outer.Inner().Consume()` do not masquerade as an explicit interface method - // (returnType=`new`, interface=the dot-chain qualifier preceding the constructed type - // — which may be a namespace prefix like `System.Text`, an enclosing-type chain like - // `Outer` in `new Outer.Inner()` where `Outer` is an outer class, or a mix of both - // like `MyApp.Outer` in `new MyApp.Outer.Inner()` where `MyApp` is a namespace and - // `Outer` is an enclosing type; the regex does not distinguish which segments are - // namespaces and which are enclosing types at this position — and name=the - // identifier right before the first `(`, i.e. the type being constructed: - // `StringBuilder` / `Inner`; the trailing `.Append(...)` / `.Consume()` chain is - // never part of the capture because the regex stops at the first `(`). - // Closes #362, #377. - // 明示的インターフェース実装 (例: void IDisposable.Dispose()) - // 有効な戻り値型(ステートメントキーワードではない)とドット前のインターフェース名を要求。 - // qualified call site を伴う named-argument label のみ除外し、 - // `global::System.String` や `Alias::Type` のような alias-qualified 型は許可する。 - // `new` も除外して、`new System.Text.StringBuilder().Append(...)` や - // `new Outer.Inner().Consume()` のような式文が、returnType=`new` / - // interface=構築型の手前のドット連鎖修飾子(namespace `System.Text` / 外側クラス - // `Outer` のみ / namespace と外側型の混在 `MyApp.Outer`(`MyApp` が namespace、 - // `Outer` が外側型)のいずれでもよく、正規表現はこの位置で namespace と外側型を - // 区別しない)/ name=構築される型(最初の `(` の直前の識別子、例: `StringBuilder` - // / `Inner`。正規表現は最初の `(` で止まるので、末尾の `.Append(...)` / - // `.Consume()` チェーンはキャプチャされない)として - // 明示的インターフェースメソッドに化けないようにする。 - new("function", new Regex($@"^\s*(?![?:])(?!(?:await|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|using|case|else|when|break|continue|goto|new|from|where|select|orderby|group|join|let|into|on|equals|ascending|descending|by)\b)(?!\w+\s*:\s*(?:global::)?[\w@.<>:]+\.\w+\s*{CSharpMethodTypeParameterListPattern}[\(\[])(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+{CSharpExplicitInterfaceQualifierPattern}\.(?{CSharpIdentifierPattern})\s*{CSharpMethodTypeParameterListPattern}[\(\[]", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - // Explicit interface property implementation (brace body), e.g. int IThing.Value { get; set; } - // Mirrors the explicit-interface method row above: the qualifier is non-capturing so the - // short property name (Value) is recorded as name, consistent with how the method row - // exposes Dispose/CompareTo instead of the qualified form. Closes #333. - // 明示的インターフェースプロパティ実装(ブレース本体)。例: int IThing.Value { get; set; } - // 上の明示的インターフェースメソッド行と同じ構造で、修飾子は非キャプチャにしてショート名 - // (Value) のみを name として記録する。メソッド側が Dispose / CompareTo を返すのと揃える。 - // Closes #333. - new("property", new Regex($@"^\s*(?![?:])(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+{CSharpExplicitInterfaceQualifierPattern}\.(?{CSharpIdentifierPattern})\s*\{{", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - // Explicit interface property implementation (expression body), e.g. string IThing.Name => "x"; - // 明示的インターフェースプロパティ実装(式本体)。例: string IThing.Name => "x"; - new("property", new Regex($@"^\s*(?![?:])(?!(?:class|struct|interface|enum|record|namespace|delegate|event|const|using|return|throw|yield|var|typeof|sizeof|nameof|default|if|for|foreach|while|switch|catch|lock|case|else|when|break|continue|goto|await)\b)(?:(?ref(?:\s+readonly)?)\s+)?(?{CSharpTypePattern})\s+{CSharpExplicitInterfaceQualifierPattern}\.(?{CSharpIdentifierPattern})\s*=>\s*", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - // Indexer (this[...]) — `partial` is legal on indexers since C# 13 (extended partial - // member support), so accept it alongside the other modifiers. Otherwise every - // `partial` indexer declaration would be silently dropped from symbols / definition / - // outline. Closes #350. - // インデクサ (this[...]) — C# 13 で indexer に対しても `partial` が使える (partial - // member 拡張) ため、他の修飾子と並べて受け付ける。そうしないと `partial` indexer 宣言 - // が symbols / definition / outline から無言で欠落する。Closes #350. - new("function", new Regex($@"^\s*(?:(?{CSharpVisibilityPattern})\s+|(?:static|virtual|override|abstract|sealed|new|readonly|unsafe|extern|partial|ref(?:\s+readonly)?)\s+)*(?{CSharpTypePattern})\s+(?this)\s*\[", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), - // Finalizer (destructor) / ファイナライザ(デストラクタ) - new("function", new Regex($@"^\s*~(?{CSharpIdentifierPattern})\s*\(\s*\)", RegexOptions.Compiled), BodyStyle.Brace), - // Enum member (e.g. Red, Green = 1,) — requires 4+ spaces indent, name only, - // and optional = with numeric/hex/identifier value. Does NOT match string/object assignments. - // enum メンバー(例: Red, Green = 1,)— 4+スペースインデント必須、名前のみ、 - // 数値/16進/識別子の値指定はオプション。文字列/オブジェクト代入にはマッチしない。 - new("enum", CSharpEnumMemberRegex, BodyStyle.None), - // #region for navigation / ナビゲーション用 #region - new("namespace", new Regex(@"^\s*#region\s+(?.+)$", RegexOptions.Compiled), BodyStyle.None), - ], - ["go"] = - [ - new("namespace", new Regex(@"^\s*package\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^func\s+(?:\([^)]+\)\s+)?(?\w+)(?:\[[^\]\r\n]+\])?\s*[\(\[]", RegexOptions.Compiled), BodyStyle.Brace), - new("lambda", new Regex(@"^\s*(?\w+)\s*(?::=|=)\s*func\s*\(", RegexOptions.Compiled), BodyStyle.Brace), - new("struct", new Regex(@"^type\s+(?\w+)(?:\[[^\]]+\])?\s+struct\b", RegexOptions.Compiled), BodyStyle.Brace), - new("protocol", new Regex(@"^type\s+(?\w+)(?:\[[^\]]+\])?\s+interface\b", RegexOptions.Compiled), BodyStyle.Brace), - // Type alias (type Name = OtherType or type Name OtherType) / 型エイリアス - new("import", new Regex(@"^type\s+(?\w+)(?:\[[^\]]+\])?\s+[=\w]", RegexOptions.Compiled), BodyStyle.None), - // Top-level const declarations / トップレベル const 宣言 - new("property", new Regex(@"^const\s+(?\w+)(?:\s+\w[\w.*\[\]]*)?\s*=", RegexOptions.Compiled), BodyStyle.None), - // Const declaration inside const block / const ブロック内の定数宣言 - new("property", new Regex(@"^\s+(?[A-Z]\w*)\s*=\s*", RegexOptions.Compiled), BodyStyle.None), - // Package-level var / パッケージレベル変数 - new("property", new Regex(@"^var\s+(?\w+)\s", RegexOptions.Compiled), BodyStyle.None), - ], - ["fortran"] = - [ - // Named interfaces / 名前付き interface - new("namespace", new Regex(@"^\s*interface\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), - // Fortran modules / モジュール - new("namespace", new Regex(@"^\s*module\s+(?!(?:procedure|subroutine|function)\b)(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), - // Fortran submodules / サブモジュール - new("namespace", new Regex(@"^\s*submodule\s*\(\s*[^)]*\)\s*(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), - // Program units / プログラム本体 - new("class", new Regex(@"^\s*program\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), - // Block data program units / block data プログラム単位 - new("class", new Regex(@"^\s*block\s+data\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), - // Derived types / 派生型 - new("class", new Regex(@"^\s*type(?!\s*\()\b(?:\s*,\s*(?:abstract|public|private|sequence|bind\s*\([^)]+\)|extends\s*\([^)]+\)))*\s*(?:::)?\s*(?!(?:is|default)\b)(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), - // Enumerators / enumerator 定数 - new("property", new Regex(@"^\s*enumerator(?:\s*::)?\s*(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Parameter constants / parameter 定数 - new("property", new Regex(@"^\s*(?:(?:integer|real|logical|complex)(?:\s*\([^)]+\))?|character(?:\s*\([^)]+\))?|double\s+precision|type\s*\([^)]+\)|class\s*\([^)]+\))\s*,[^:\r\n]*\bparameter\b[^:\r\n]*::\s*(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Old-style parameter constants / 旧形式 parameter 定数 - new("property", new Regex(@"^\s*parameter\s*\(\s*(?[A-Za-z_]\w*)(?.*)\)\s*(?:!.*)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Typed variables and components / 型付き変数・component - new("property", new Regex(@"^\s*(?(?:(?:integer|real|logical|complex)(?:\s*\([^)]+\))?|character(?:\s*\([^)]+\))?|double\s+precision|type\s*\([^)]+\)|class\s*\([^)]+\)))\s*(?:,\s*(?![^:\r\n]*\bparameter\b)[^:\r\n]*)?::\s*(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), - // Old-style typed variables without :: / :: なしの旧形式型付き変数 - new("property", new Regex(@"^\s*(?(?:(?:integer|real|logical|complex)(?:\s*\([^)]+\))?|character(?:\s*\([^)]+\))?|double\s+precision|type\s*\([^)]+\)|class\s*\([^)]+\)))\s+(?!(?:function|subroutine)\b)(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), - // Attribute-only variables / 属性のみの変数宣言 - new("property", new Regex(@"^\s*(?:(?:allocatable|pointer|target|optional|save|dimension\s*\([^)]+\)|intent\s*\([^)]+\))\s*,\s*)*(?:allocatable|pointer|target|optional|save|dimension\s*\([^)]+\)|intent\s*\([^)]+\))\s*(?:::)?\s*(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Common block members / common block メンバー - new("property", new Regex(@"^\s*common\s+(?:/\s*[A-Za-z_]\w*\s*/\s*)?(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Namelist members / namelist メンバー - new("property", new Regex(@"^\s*namelist\s+/\s*[A-Za-z_]\w*\s*/\s*(?[A-Za-z_]\w*)(?.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Subroutines / サブルーチン - new("function", new Regex(@"^\s*(?:(?:pure|elemental|recursive|module|impure)\s+)*subroutine\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), - // Entry points / entry 手続き - new("function", new Regex(@"^\s*entry\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Module procedure implementations / module procedure 実装 - new("function", new Regex(@"^\s*module\s+procedure\s+(?[A-Za-z_]\w*)\s*(?:!.*)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), - // Procedure declarations in interfaces / interface 内の手続き宣言 - new("function", new Regex(@"^\s*(?:(?:pure|elemental|recursive|impure)\s+)*(?:(?:module\s+)?procedure)(?:\s*\([^)]+\))?(?:\s*,\s*[A-Za-z_]\w*)*\s*(?:::\s*)?(?[A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Typed or untyped functions / 型付き・型なし関数 - new("function", new Regex(@"^\s*(?:(?:pure|elemental|recursive|module|impure)\s+)*(?:(?:(?:integer|real|logical|complex)(?:\s*\([^)]+\))?|character(?:\s*\([^)]+\))?|double\s+precision|type\s*\([^)]+\)|class\s*\([^)]+\)|procedure\s*\([^)]+\))\s+)?function\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.FortranEnd), - ], - ["rust"] = - [ - // macro_rules! / マクロ定義 - new("function", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?macro_rules!\s+(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // const/static items / 定数・静的変数 - new("function", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?(?:const|static)\s+(?(?:r#)?\w+)\s*:", RegexOptions.Compiled), BodyStyle.None, "visibility"), - // fn with expanded modifiers: async, const, unsafe, default, extern (ABI optional) / - // 拡張修飾子: async, const, unsafe, default, extern(ABI は省略可) - new("function", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?(?:(?:async|const|unsafe|default|extern(?:\s+""[^""]+"")?)\s+)*fn\s+(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("class", new Regex(@"\b(?unsafe)\s*\{", RegexOptions.Compiled), BodyStyle.Brace), - new("struct", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?(?:struct|union)\s+(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("enum", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?enum\s+(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Enum variants / `Red`, `Ok(T)`, `Circle { radius: f64 }`, `Point` - new("property", new Regex(@"^\s{4,}(?[A-Z][A-Za-z0-9_]*)\s*(?:\([^()\r\n]*\)|\{[^{}\r\n]*\})?\s*,?\s*$", RegexOptions.Compiled), BodyStyle.None), - new("protocol", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?trait\s+(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // impl Trait for Type / `unsafe impl Trait for Type` should attach to the type being extended. - // `impl Trait for Type` / `unsafe impl Trait for Type` は、拡張先の型に紐づける。 - new("class", new Regex(@"^\s*(?:unsafe\s+)?impl(?:<[^>]+>)?\s+.+?\s+for\s+(?:(?:r#)?\w+::)*(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*(?:unsafe\s+)?impl(?:<[^>]+>)?\s+(?:(?:r#)?\w+::)*(?(?:r#)?\w+)(?!\s+for\b)", RegexOptions.Compiled), BodyStyle.Brace), - // file module declarations and inline modules / ファイルモジュール宣言とインラインモジュール - new("file_module", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?mod\s+(?(?:r#)?\w+)\s*;", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("namespace", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?mod\s+(?(?:r#)?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Trait associated type defaults / trait 関連型のデフォルト - new("property", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?type\s+(?(?:r#)?\w+)(?:\s*<[^=>]+>)?(?:\s*:\s*[^=;]+)?\s*=\s*(?[^;]+)", RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), - // type alias / 型エイリアス - new("import", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?type\s+(?(?:r#)?\w+)(?:\s*<[^=]+>)?", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("import", new Regex(@"^\s*(?:(?pub(?:\([^)]*\))?)\s+)?use\s+(?.+);", RegexOptions.Compiled), BodyStyle.None, "visibility"), - ], - ["java"] = - [ - // Package declaration / package 宣言 - new("namespace", new Regex($@"^\s*package\s+(?{JavaQualifiedIdentifierPattern})\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - // Module declaration (Java 9+ module-info.java) / モジュール宣言(Java 9+ の module-info.java) - new("namespace", new Regex($@"^\s*(?:open\s+)?module\s+(?{JavaQualifiedIdentifierPattern})\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - // Annotation type (@interface) / アノテーション型 - new("class", new Regex($@"^\s*(?public|private|protected)?\s*@interface\s+(?{JavaIdentifierPattern})", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, "visibility"), - // record (Java 16+) — must come before general class pattern / record は一般クラスパターンの前に配置 - new("class", new Regex($@"^\s*(?public|private|protected)?\s*(?:(?:static|final|abstract|sealed|non-sealed|strictfp)\s+)*record\s+(?{JavaIdentifierPattern})", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, "visibility"), - // Interface / インターフェース - new("interface", new Regex($@"^\s*(?public|private|protected)?\s*(?:(?:static|abstract|sealed|non-sealed|strictfp)\s+)*interface\s+(?{JavaIdentifierPattern})", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, "visibility"), - // Enum / enum - new("enum", new Regex($@"^\s*(?public|private|protected)?\s*(?:(?:static|strictfp)\s+)*enum\s+(?{JavaIdentifierPattern})", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, "visibility"), - // Class — with extended modifiers (final, sealed, static, abstract, strictfp) - // クラス — 拡張修飾子対応(final, sealed, static, abstract, strictfp) - new("class", new Regex($@"^\s*(?public|private|protected)?\s*(?:(?:static|final|abstract|sealed|non-sealed|strictfp)\s+)*class\s+(?{JavaIdentifierPattern})", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, "visibility"), - // Static final field (Java equivalent of C# const) — order-flexible and annotation-friendly. - // static final フィールド — 語順柔軟かつアノテーション併用にも対応。 - new("function", new Regex($@"^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?public|private|protected)?\s*(?=(?:(?:static|final|transient|volatile)\s+)*static\b)(?=(?:(?:static|final|transient|volatile)\s+)*final\b)(?:(?:static|final|transient|volatile)\s+)*(?{JavaReturnTypePattern})\s+(?[A-Z_]\w*)\s*=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, "visibility", "returnType"), - // Method with return type — expanded modifiers (default, native, synchronized, final) - // 戻り値型付きメソッド — 拡張修飾子対応(default, native, synchronized, final) - new("function", new Regex($@"^\s*(?!(?:return|throw|new|if|for|while|switch|do|case|else|try|catch|finally|synchronized|break|continue|yield|assert)\b)(?:@\w+(?:\([^)]*\))?\s+)*(?public|private|protected)?\s*(?:(?:static|abstract|synchronized|final|default|native|strictfp)\s+)*(?!(?:record)\b){JavaMethodTypeParameterPattern}(?{JavaReturnTypePattern})\s+(?{JavaIdentifierPattern})\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, "visibility", "returnType"), - // Enum members are extracted by ExtractJavaEnumMembers using a body-scoped scanner, - // which handles any indent style (tab, 2-space, 4-space) and skips member-like lines - // outside the enum body (e.g. `\tRED();` method calls inside a class body). - // enum メンバーは ExtractJavaEnumMembers の body-scoped scanner で抽出する。 - // 任意のインデントスタイル(タブ、2スペース、4スペース)に対応しつつ、enum 本体外の - // メンバー風の行(例: クラス本体内の `\tRED();` メソッド呼び出し)を誤検出しない。 - new("import", new Regex(@"^\s*import\s+(?.+);", RegexOptions.Compiled), BodyStyle.None), - ], - ["kotlin"] = - [ - // Companion object / コンパニオンオブジェクト - new("class", new Regex($@"^\s*companion\s+object(?:\s+(?{KotlinIdentifierPattern}))?", RegexOptions.Compiled), BodyStyle.Brace), - // Interface / インターフェース - // Kotlin fun interface / Kotlin の fun interface も interface として扱う。 - new("interface", new Regex($@"^\s*(?public|private|protected|internal)?\s*(?:(?:sealed|expect|actual)\s+)*(?:fun\s+)?interface\s+(?{KotlinIdentifierPattern})", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Enum class / enum クラス - new("enum", new Regex($@"^\s*(?public|private|protected|internal)?\s*(?:(?:expect|actual)\s+)*enum\s+class\s+(?{KotlinIdentifierPattern})", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Class/object with expanded modifiers: data, sealed, value, inline, inner, annotation, expect, actual - // クラス/オブジェクト — 拡張修飾子対応: data, sealed, value, inline, inner, annotation, expect, actual - new("class", new Regex($@"^\s*(?public|private|protected|internal)?\s*(?:(?:abstract|data|sealed|open|inner|value|inline|annotation|expect|actual)\s+)*(?:class|object)\s+(?{KotlinIdentifierPattern})", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Function / 関数 (including extension, secondary constructor, override, and abstract forms) - // 関数 — 拡張・セカンダリコンストラクタ・override・abstract 形を含む - new("function", new Regex($@"^\s*(?public|private|protected|internal)?\s*(?:(?:suspend|inline|infix|operator|tailrec|external|expect|actual|abstract|override|open|final)\s+)*fun\s+(?:<[^>]+>\s+)?(?:{KotlinIdentifierPattern}(?:<[^>]+>)?\.)?(?{KotlinIdentifierPattern})\s*[\(<](?:.*?\))?(?::\s*(?[^ {{=]+))?", RegexOptions.Compiled), BodyStyle.Brace, "visibility", "returnType"), - // Secondary constructor / セカンダリコンストラクタ - new("function", new Regex(@"^\s*(?public|private|protected|internal)?\s*constructor\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Enum entry / enum エントリ - new("property", new Regex($@"^\s{{2,}}(?(?:[A-Z][A-Z0-9_]*|`[^`\r\n]+`))\s*(?:\((?[^)]*)\))?\s*(?:,|\{{|;)?\s*$", RegexOptions.Compiled), BodyStyle.Brace, "returnType"), - // Top-level val/var property / トップレベルプロパティ - new("property", new Regex($@"^\s*(?public|private|protected|internal)?\s*(?:(?:const|lateinit|override)\s+)?(?:val|var)\s+(?{KotlinIdentifierPattern})\s*[=:]", RegexOptions.Compiled), BodyStyle.None, "visibility"), - // Type alias / 型エイリアス - new("import", new Regex($@"^\s*(?public|private|protected|internal)?\s*typealias\s+(?{KotlinIdentifierPattern})(?:\s*<[^=]+>)?\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("import", new Regex(@"^\s*import\s+(?.+)", RegexOptions.Compiled), BodyStyle.None), - ], - ["ruby"] = - [ - // attr_accessor/attr_reader/attr_writer as property declarations / プロパティ宣言 - new("property", new Regex(@"^\s*attr_(?:accessor|reader|writer)\s+:(?\w+)", RegexOptions.Compiled), BodyStyle.None), - // alias_method / alias — capture the introduced method name for navigation - new("function", new Regex(@"^\s*alias_method\b\s+:?(?\w+[?!=]?)\s*,\s*:?\w+[?!=]?", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*alias\b\s+:?(?\w+[?!=]?)\s+:?\w+[?!=]?", RegexOptions.Compiled), BodyStyle.None), - // scope/has_many/belongs_to (Rails DSL) — extracted as function for navigation - new("function", new Regex(@"^\s*(?:scope|has_many|has_one|belongs_to)\s+:(?\w+)", RegexOptions.Compiled), BodyStyle.None), - new("property", new Regex(@"^\s*enum\s+:(?\w+)", RegexOptions.Compiled), BodyStyle.None), - new("property", new Regex(@"^\s*attribute\s+:(?\w+)", RegexOptions.Compiled), BodyStyle.None), - new("property", new Regex(@"^\s*store_accessor\s+:\w+\s*,\s*:(?\w+)", RegexOptions.Compiled), BodyStyle.None), - new("namespace", new Regex(@"^\s*namespace\s+:(?\w+)\b.*\bdo\b", RegexOptions.Compiled), BodyStyle.RubyEnd), - new("function", new Regex(@"^\s*factory\s+:(?\w+)\b.*\bdo\b", RegexOptions.Compiled), BodyStyle.RubyEnd), - new("function", new Regex(@"^\s*shared_examples(?:_for)?\s+(?['""])(?[^'""]+)\k\s*do\b", RegexOptions.Compiled), BodyStyle.RubyEnd), - new("property", new Regex(@"^\s*subject\s*\(\s*:(?\w+)\s*\)\s*do\b", RegexOptions.Compiled), BodyStyle.RubyEnd), - new("property", new Regex(@"^\s*let!?\s*\(\s*:(?\w+)\s*\)\s*do\b", RegexOptions.Compiled), BodyStyle.RubyEnd), - new("function", new Regex(@"^\s*task\s+(?::(?\w+)|(?\w+)\s*:)", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*(?[A-Z][A-Za-z0-9_]*)\s*=\s*Class\.new\b.*\bdo\b", RegexOptions.Compiled), BodyStyle.RubyEnd), - new("class", new Regex(@"^\s*(?[A-Z][A-Za-z0-9_]*)\s*=\s*Struct\.new\b.*\bdo\b", RegexOptions.Compiled), BodyStyle.RubyEnd), - new("property", new Regex(@"^\s*(?[A-Z][A-Za-z0-9_]*)\s*=", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*(?:(?:private|protected|public)\s+)?def\s+(?:(?:self|[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\.)?(?\[\]=?|\*\*|<<|>>|<=>|===|==|!=|!~|=~|<=|>=|[+\-*/%&|^~<>]=?|[+\-]@|!)", RegexOptions.Compiled), BodyStyle.RubyEnd), - new("function", new Regex(@"^\s*(?:(?:private|protected|public)\s+)?def\s+(?:(?:self|[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\.)?(?\w+[?!=]?)", RegexOptions.Compiled), BodyStyle.RubyEnd), - new("class", new Regex(@"^\s*class\s+(?[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)", RegexOptions.Compiled), BodyStyle.RubyEnd), - new("class", new Regex(@"^\s*module\s+(?[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)", RegexOptions.Compiled), BodyStyle.RubyEnd), - new("import", new Regex(@"^\s*require(?:_relative)?\s+(?.+)", RegexOptions.Compiled), BodyStyle.None), - ], - ["crystal"] = - [ - new("namespace", new Regex(@"^\s*module\s+(?[A-Z]\w*(?:::[A-Z]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), - new("class", new Regex(@"^\s*(?:abstract\s+)?class\s+(?[A-Z]\w*(?:::[A-Z]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), - new("struct", new Regex(@"^\s*struct\s+(?[A-Z]\w*(?:::[A-Z]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), - new("enum", new Regex(@"^\s*enum\s+(?[A-Z]\w*(?:::[A-Z]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), - new("function", new Regex(@"^\s*(?:(?:private|protected)\s+)*abstract\s+def\s+(?:self\.)?(?[A-Za-z_]\w*[?!=]?)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?:private\s+|protected\s+)?def\s+(?:self\.)?(?[A-Za-z_]\w*[?!=]?)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), - new("function", new Regex(@"^\s*macro\s+(?[A-Za-z_]\w*[?!=]?)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), - new("typealias", new Regex(@"^\s*alias\s+(?[A-Z]\w*)\s*=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*require\s+(?.+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["groovy"] = - [ - new("namespace", new Regex(@"^\s*package\s+(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("interface", new Regex(@"^\s*(?:(?:public|private|protected|static|abstract)\s+)*(?:interface|trait)\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("enum", new Regex(@"^\s*(?:(?:public|private|protected|static)\s+)*enum\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("class", new Regex(@"^\s*(?:(?:public|private|protected|static|abstract|final)\s+)*class\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("function", new Regex(@"^\s*(?:@[A-Za-z_$][\w.$]*(?:\s*\([^)\r\n]*\))?\s+)*(?!(?:if|for|while|switch|catch|return|throw|new)\b)(?:(?:public|private|protected|static|final|abstract|synchronized|native|strictfp)\s+)*(?:<[^(){}\r\n]+>\s+)?(?def|void|boolean|byte|char|short|int|long|float|double|BigDecimal|BigInteger|String|[A-Za-z_$][\w.$]*(?:\s*<[^(){}\r\n]+>)?(?:\s*\[\])*)\s+(?[A-Za-z_]\w*)\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - new("lambda", new Regex(@"^\s*(?:def\s+)?(?[A-Za-z_]\w*)\s*=\s*\{", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("import", new Regex(@"^\s*import\s+(?:static\s+)?(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\.\*)?)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["julia"] = - [ - new("namespace", new Regex(@"^\s*(?:baremodule|module)\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), - new("struct", new Regex(@"^\s*(?:mutable\s+)?struct\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), - new("type", new Regex(@"^\s*(?:abstract|primitive)\s+type\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), - new("function", new Regex(@"^\s*function\s+(?" + JuliaQualifiedCallableIdentifierPattern + @")\s*(?:\(|\{)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), - new("function", new Regex(@"^\s*macro\s+(?[A-Za-z_]\w*)\s*(?:\(|$)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), - new("function", new Regex(@"^\s*(?" + JuliaQualifiedCallableIdentifierPattern + @")\s*\([^)\r\n]*\)\s*(?:where\s*(?:\{[^}\r\n]*\}|[A-Za-z_]\w*)\s*)?=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.JuliaShortFunction), - new("property", new Regex(@"^\s*const\s+(?[A-Z_]\w*)\s*=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*(?:using|import)\s+(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["tcl"] = - [ - new("namespace", new Regex(@"^\s*namespace\s+eval\s+(?[A-Za-z_:][\w:.-]*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex(@"^\s*oo::class\s+create\s+(?[A-Za-z_:][\w:.-]*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*proc\s+(?[A-Za-z_:][\w:.-]*)\s+", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*(?:variable|set)\s+(?[A-Za-z_:][\w:.-]*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*package\s+(?:require|provide)\s+(?[A-Za-z_:][\w:.-]*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["ada"] = - [ - new("namespace", new Regex(@"^\s*package\s+(?:body\s+)?(?[A-Za-z]\w*(?:\.[A-Za-z]\w*)*)\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.AdaEnd), - new("type", new Regex(@"^\s*(?:subtype|type)\s+(?[A-Za-z]\w*)\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("type", new Regex(@"^\s*(?:task|protected)\s+(?:type\s+)?(?[A-Za-z]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.AdaEnd), - new("function", new Regex(@"^\s*(?:(?:overriding|not\s+overriding)\s+)?(?:function|procedure)\s+(?:(?:[A-Za-z]\w*)\.)*(?[A-Za-z]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.AdaEnd), - new("import", new Regex(@"^\s*with\s+(?[A-Za-z]\w*(?:\.[A-Za-z]\w*)*)\s*;", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["d"] = - [ - new("namespace", new Regex(@"^\s*module\s+(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("interface", new Regex(@"^\s*(?:(?:public|private|protected|package|static|abstract|extern)\s+)*interface\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("class", new Regex(@"^\s*(?:(?:public|private|protected|package|static|abstract|final|extern)\s+)*class\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("struct", new Regex(@"^\s*(?:(?:public|private|protected|package|static|extern)\s+)*struct\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("union", new Regex(@"^\s*(?:(?:public|private|protected|package|static|extern)\s+)*union\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("enum", new Regex(@"^\s*(?:(?:public|private|protected|package|static)\s+)*enum\s+(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("typealias", new Regex(@"^\s*(?:alias|typedef)\s+(?[A-Za-z_]\w*)\s*=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?!(?:if|for|while|switch|catch|return|throw|new|assert|version|debug)\b)(?:(?:public|private|protected|package|static|extern|export|final|abstract|override|synchronized|pure|nothrow|@safe|@trusted|@system)\s+)*(?(?:auto|void|bool|byte|ubyte|short|ushort|int|uint|long|ulong|cent|ucent|float|double|real|char|wchar|dchar|string|[A-Za-z_][\w.]*)(?:\s*[*\[\]])*)\s+(?[A-Za-z_]\w*)\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - new("import", new Regex(@"^\s*import\s+(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["nim"] = - [ - new("type", new Regex(@"^\s*type\s+(?[A-Za-z_]\w*)\*?\s*=\s*(?:ref\s+)?(?:object|enum|tuple|distinct)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), - new("type", new Regex(@"^\s+(?[A-Za-z_]\w*)\*?\s*=\s*(?:ref\s+)?(?:object|enum|tuple|distinct)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), - new("function", new Regex(@"^\s*(?:proc|func|method|iterator|template|macro|converter)\s+(?`[^`\r\n]+`|[A-Za-z_]\w*)\*?", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Indent), - new("property", new Regex(@"^\s*(?:const|let|var)\s+(?[A-Za-z_]\w*)\*?\s*(?::|=)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*(?:import|include)\s+(?[A-Za-z_][\w./]*(?:\s*,\s*[A-Za-z_][\w./]*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*from\s+(?[A-Za-z_][\w./]*)\s+import\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["perl"] = - [ - // Perl package declarations / Perl の package 宣言 - new("namespace", new Regex(@"^\s*package\s+(?" + PerlQualifiedIdentifierPattern + @")\b(?:\s+v?[\d._]+)?\s*\{", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("namespace", new Regex(@"^\s*package\s+(?" + PerlQualifiedIdentifierPattern + @")\b(?:\s+v?[\d._]+)?\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - // Perl class feature declarations / Perl class feature の宣言 - new("class", new Regex(@"^\s*class\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("interface", new Regex(@"^\s*role\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - // Perl constants are compile-time subroutines, so expose them as functions for navigation. - // Perl constant はコンパイル時 subroutine なので、ナビゲーション用に function として出す。 - new("function", new Regex(@"^\s*use\s+constant\s+(?" + PerlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - // Perl module imports / Perl の module import - new("import", new Regex(@"^\s*use\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*require\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - // Moose/Moo attributes / Moose/Moo の属性 - new("property", new Regex(@"^\s*has\s+(?['""]?)\+?(?" + PerlIdentifierPattern + @")\k\s*=>", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - // Package variables / package 変数 - new("property", new Regex(@"^\s*our\s+[$@%](?" + PerlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - // Perl class feature fields / Perl class feature の field - new("property", new Regex(@"^\s*field\s+[$@%](?" + PerlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - // Perl subroutines / Perl の subroutine - new("function", new Regex(@"^\s*(?:(?:my|state)\s+)?sub\s+(?" + PerlQualifiedIdentifierPattern + @")\b(?:\s*:[^{;]+)?", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("function", new Regex(@"^\s*(?:method|fun)\s+(?" + PerlIdentifierPattern + @")\b(?:\s*:[^{;]+)?", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - ], - ["matlab"] = - [ - new("class", new Regex(@"^\s*classdef\s*(?:\([^)]*\)\s*)?(?[A-Za-z]\w*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), - new("function", new Regex(@"^\s*function\s+(?:(?:\[[^\]]+\]|[A-Za-z]\w*)\s*=\s*)?(?[A-Za-z]\w*)\s*(?:\(|$)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.ScientificEnd), - new("import", new Regex(@"^\s*import\s+(?[A-Za-z]\w*(?:\.[A-Za-z*]\w*)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["prolog"] = - [ - new("namespace", new Regex(@"^\s*:-\s*module\s*\(\s*(?[a-z][A-Za-z0-9_]*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*:-\s*use_module\s*\(\s*(?[a-z][A-Za-z0-9_]*(?:\([^)]*\))?)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?[a-z][A-Za-z0-9_]*)\s*(?:\([^\r\n]*\))?\s*(?::-|-->|\.(?=\s*(?:$|:-|[a-z][A-Za-z0-9_]*\s*\(\s*$|[a-z][A-Za-z0-9_]*(?:\s*\([^)]*\))?\s*(?::-|-->|\.))))", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["ambiguous_pl"] = - [ - // Keep ambiguous .pl files structured without choosing Perl or Prolog prematurely. - // .pl の判定が曖昧でも Perl / Prolog のどちらかへ早計に固定せず、構造を保持する。 - new("namespace", new Regex(@"^\s*package\s+(?" + PerlQualifiedIdentifierPattern + @")\b(?:\s+v?[\d._]+)?\s*\{", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("namespace", new Regex(@"^\s*package\s+(?" + PerlQualifiedIdentifierPattern + @")\b(?:\s+v?[\d._]+)?\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex(@"^\s*class\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("interface", new Regex(@"^\s*role\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("function", new Regex(@"^\s*use\s+constant\s+(?" + PerlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*use\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*require\s+(?" + PerlQualifiedIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*our\s+[$@%](?" + PerlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?:(?:my|state)\s+)?sub\s+(?" + PerlQualifiedIdentifierPattern + @")\b(?:\s*:[^{;]+)?", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("function", new Regex(@"^\s*(?:method|fun)\s+(?" + PerlIdentifierPattern + @")\b(?:\s*:[^{;]+)?", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("namespace", new Regex(@"^\s*:-\s*module\s*\(\s*(?[a-z][A-Za-z0-9_]*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*:-\s*use_module\s*\(\s*(?[a-z][A-Za-z0-9_]*(?:\([^)]*\))?)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?[a-z][A-Za-z0-9_]*)\s*(?:\([^\r\n]*\))?\s*(?::-|-->|\.(?=\s*(?:$|:-|[a-z][A-Za-z0-9_]*\s*\(\s*$|[a-z][A-Za-z0-9_]*(?:\s*\([^)]*\))?\s*(?::-|-->|\.))))", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["c"] = - [ - new("function", new Regex(CFunctionStartBlacklistPattern + CFunctionReturnTypePattern + CFunctionNameBlacklistPattern + @"(?\w+)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - // #define macros / #define マクロ - new("function", new Regex(@"^\s*#\s*define\s+(?[A-Za-z_]\w*)\(", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*#\s*define\s+(?[A-Za-z_]\w*)(?=\s|$)", RegexOptions.Compiled), BodyStyle.None), - new("struct", new Regex(@"^\s*typedef\s+struct\s+(?:\w+\s*)?(?:\*+\s*)?(?\w+)\s*;", RegexOptions.Compiled), BodyStyle.None), - new("struct", new Regex(@"^\s*(?:typedef\s+)?struct\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("union", new Regex(@"^\s*typedef\s+union\s+(?:\w+\s*)?(?:\*+\s*)?(?\w+)\s*;", RegexOptions.Compiled), BodyStyle.None), - new("union", new Regex(@"^\s*(?:typedef\s+)?union\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("enum", new Regex(@"^\s*typedef\s+enum\s+(?:\w+\s+)?(?\w+)\s*;", RegexOptions.Compiled), BodyStyle.None), - new("enum", new Regex(@"^\s*(?:typedef\s+)?enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("import", new Regex(@"^\s*#\s*(?:include(?:_next)?|import)\s+(?:<(?[^>]+)>|""(?[^""]+)""|(?[^\s]+))", RegexOptions.Compiled), BodyStyle.None), - ], - ["cpp"] = - [ - new("namespace", new Regex(CppFunctionStartBlacklistPattern + @"(?:export\s+)?module\s+(?[\w.]+(?::[\w.]+)?)\b", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(CppFunctionStartBlacklistPattern + @"(?:export\s+)?import\s+(?:<(?[^>\r\n]+)>|""(?[^""\r\n]+)""|(?:?[A-Za-z_]\w*(?:[.:][A-Za-z_]\w*)*))\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("namespace", new Regex(CppFunctionStartBlacklistPattern + @"inline\s+namespace\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("interface", new Regex(CppFunctionStartBlacklistPattern + CppTemplatePrefixPattern + @"(?:export\s+)?concept\s+(?\w+)\b", RegexOptions.Compiled), BodyStyle.None), - new("specialization", new Regex(CppFunctionStartBlacklistPattern + @"\s*template\s*<[^>]*>\s*(?:class|struct|union)\s+(?(?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)\s*<[^;{}]+>", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("specialization", new Regex(CppFunctionStartBlacklistPattern + @"\s*template\s*<>\s*" + CppAttributePrefixPattern + @"(?:extern\s+""(?:C|C\+\+)""\s*)?" + CppAttributePrefixPattern + @"(?:(?(?:(?:" + CppFunctionReturnTypeAtomPattern + @")[\s*&]+)+))?(?:(?:[\w:<>]+\s*::\s*)+)?" + CFunctionNameBlacklistPattern + @"(?~?\w+|operator(?:\s*\(\)|\s*\[\]|\s*[^\s(]+(?:\s+[^\s(]+)?))\s*<[^>\r\n]+>\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - new("function", new Regex(CppFunctionStartBlacklistPattern + CppTemplatePrefixPattern + CppAttributePrefixPattern + @"(?:extern\s+""(?:C|C\+\+)""\s*)?" + CppAttributePrefixPattern + @"(?:(?(?:(?:" + CppFunctionReturnTypeAtomPattern + @")[\s*&]+)+))?(?:(?:[\w:<>]+\s*::\s*)+)?" + CFunctionNameBlacklistPattern + @"(?~?\w+|operator(?:\s*\(\)|\s*\[\]|\s*[^\s(]+(?:\s+[^\s(]+)?))(?:\s*<[^>]+>)?\s*\(", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - // Type alias / 型エイリアス - new("import", new Regex(CppFunctionStartBlacklistPattern + @"using\s+enum\s+(?(?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(CppFunctionStartBlacklistPattern + @"template\s*<[^>]+>\s*(?:export\s+)?using\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(CppFunctionStartBlacklistPattern + CppTemplatePrefixPattern + @"(?:export\s+)?using\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(CppFunctionStartBlacklistPattern + @"template\s*<[^>]+>\s*(?:export\s+)?using\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.Brace), - new("import", new Regex(CppFunctionStartBlacklistPattern + CppTemplatePrefixPattern + @"(?:export\s+)?using\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.Brace), - new("import", new Regex(@"^\s*typedef\s+(?![^;]*\().*\b(?\w+)\s*;", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(@"^\s*typedef\s+(?![^;]*\().*\b(?\w+)\s*;", RegexOptions.Compiled), BodyStyle.Brace), - // #define macros / #define マクロ - new("function", new Regex(@"^\s*#\s*define\s+(?[A-Za-z_]\w*)\(", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*#\s*define\s+(?[A-Za-z_]\w*)(?=\s|$)", RegexOptions.Compiled), BodyStyle.None), - new("property", new Regex(@"^(?:export\s+)?(?:(?:inline|static)\s+)*constexpr\s+(?(?:[\w:<>~]+(?:\s*[*&])?\s+)+)(?(?:[A-Z_]\w*|k[A-Z]\w*))\s*=", RegexOptions.Compiled), BodyStyle.None, ReturnTypeGroup: "returnType"), - new("property", new Regex(CppFunctionStartBlacklistPattern + CppTemplatePrefixPattern + @"(?(?:[\w:<>~]+[\s*&]+)+)(?:(?:[\w:<>]+\s*::\s*)+)(?\w+)\s*=\s*[^;]+;", RegexOptions.Compiled), BodyStyle.None, ReturnTypeGroup: "returnType"), - new("class", new Regex(CppFunctionStartBlacklistPattern + @"\s*(?:export\s+)?" + CppTemplatePrefixPattern + @"class\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("struct", new Regex(CppFunctionStartBlacklistPattern + @"\s*(?:export\s+)?" + CppTemplatePrefixPattern + @"struct\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("union", new Regex(CppFunctionStartBlacklistPattern + @"\s*(?:export\s+)?" + CppTemplatePrefixPattern + @"union\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("namespace", new Regex(@"^\s*(?:export\s+)?namespace\s+(?!\w+\s*=)(?\w+(?:::\w+)*)", RegexOptions.Compiled), BodyStyle.Brace), - new("enum", new Regex(@"^\s*(?:export\s+)?(?:typedef\s+)?enum\s+(?:class\s+)?(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("import", new Regex(@"^\s*#\s*(?:include|import)\s+(?:<(?[^>]+)>|""(?[^""]+)""|(?[^\s]+))", RegexOptions.Compiled), BodyStyle.None), - ], - ["php"] = - [ - // Variable-bound closures / 変数に束縛されたクロージャ - new("function", new Regex(@"^\s*\$(?\w+)\s*=\s*(?:static\s+)?function\s*\(", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*\$(?\w+)\s*=\s*(?:static\s+)?fn\s*\(", RegexOptions.Compiled), BodyStyle.None), - // Const declaration / 定数宣言 - new("function", new Regex(@"^\s*define\s*\(\s*['""](?[A-Za-z_]\w*)['""]\s*,", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?:(?public|private|protected)\s+)?const\s+(?\??[A-Za-z_\\][\w\\]*(?:\s*[|&]\s*\??[A-Za-z_\\][\w\\]*)*)\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility", ReturnTypeGroup: "returnType"), - new("function", new Regex(@"^\s*(?:(?public|private|protected)\s+)?const\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility"), - // Class property declarations / クラスプロパティ宣言 - new("property", new Regex(@"^\s*(?:(?public|private|protected|var)\s+)(?:(?:static|readonly)\s+)*(?:(?\??[A-Za-z_\\][\w\\]*(?:\s*[|&]\s*\??[A-Za-z_\\][\w\\]*)*)\s+)?\$(?\w+)\b", RegexOptions.Compiled), BodyStyle.None, "visibility", ReturnTypeGroup: "returnType"), - new("function", new Regex(@"^\s*(?:(?:(?public|private|protected)|static|abstract|final)\s+)*function\s+(?\w+)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Class with expanded modifiers: abstract, final, readonly (PHP 8.2+) - // 拡張修飾子対応: abstract, final, readonly (PHP 8.2+) - new("class", new Regex(@"^\s*(?:(?:abstract|final|readonly)\s+)*class\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("interface", new Regex(@"^\s*interface\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("trait", new Regex(@"^\s*trait\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("enum", new Regex(@"^\s*enum\s+(?\w+)(?:\s*:\s*(?[A-Za-z_\\][\w\\]*))?", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - new("property", new Regex(@"^\s*case\s+(?\w+)(?:\s*=\s*(?[^;]+?))?\s*;", RegexOptions.Compiled), BodyStyle.None, ReturnTypeGroup: "returnType"), - // Namespace / 名前空間 - new("namespace", new Regex(@"^\s*namespace\s+(?[\w\\]+)", RegexOptions.Compiled), BodyStyle.Brace), - ], - ["swift"] = - [ - // Swift function names may be ordinary identifiers or escaped identifiers - // wrapped in backticks (e.g. `func `repeat`() {}`). - // Swift の関数名は通常識別子に加えて、バッククォートでエスケープした識別子 - // (例: `func `repeat`() {}`)も取りうる。 - new("function", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:static|class|nonisolated|mutating|nonmutating|prefix|infix|postfix)\s+)*(?:override\s+)?func\s+(?`[^`]+`|\w+|[~!%^&*+\-=|/?<>.]+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("function", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:required|convenience|nonisolated|mutating|nonmutating|override)\s+)*(?init)(?:\?)?\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("function", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:nonisolated)\s+)*(?deinit)\s*(?:\{|$)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("function", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:static|class|nonisolated|mutating|nonmutating|override)\s+)*(?subscript)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("struct", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:final)\s+)*struct\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("enum", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:indirect\s+)?enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("property", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:indirect\s+)?case\s+(?\w+)(?(?:\s*(?:\([^:\r\n]*\))?(?:\s*=\s*(?(?:""(?:\\.|[^""\\])*""|[^,\r\n])+))?\s*(?:,\s*\w+(?:\s*\([^:\r\n]*\))?(?:\s*=\s*(?:""(?:\\.|[^""\\])*""|[^,\r\n])+)?)*)\s*)$", RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), - new("property", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:indirect\s+)?case\s+(?\w+)(?:\s*\([^)]*\))?(?:\s*=\s*(?.+?))?\s*$", RegexOptions.Compiled), BodyStyle.None, "visibility", ReturnTypeGroup: "returnType"), - new("protocol", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"protocol\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("associatedtype", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"associatedtype\s+(?\w+)", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("typealias", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"typealias\s+(?\w+)(?:\s*<[^=]+>)?\s*=", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("property", new Regex(@"^\s*" + SwiftAttributePattern + @"(?(?:public|private|internal|open|fileprivate|package)(?:\s*\(\s*set\s*\))?)?\s*" + SwiftAttributePattern + @"(?:(?:lazy|weak|unowned|final|static|class|nonisolated)\s+)*(?:let|var)\s+(?`[^`]+`|\w+)(?=\s*(?:[:=]|$))", RegexOptions.Compiled), BodyStyle.None, "visibility"), - // Extension declarations are important search anchors in Swift-heavy codebases. - // A dedicated parser keeps nested generic targets searchable even when the - // extension also carries protocol conformances or `where` clauses. - // extension 宣言は Swift コード検索における重要なアンカー。 - // 専用パーサにより、protocol conformance や `where` 句が付く場合でも - // ネストした generic target を検索対象として維持する。 - new("class", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:final)\s+)?extension\s+(?[^\r\n{]+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // actor (Swift 5.5+) / アクター - new("class", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:(?:final|distributed)\s+)*(?:class|actor)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Type alias / 型エイリアス: backtick-escaped names and generic/where clauses. - new("typealias", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"typealias\s+(?`[^`]+`|\w+)(?=\s*(?:<|=|where\b|$))", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("function", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"macro\s+(?\w+)", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("interface", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"precedencegroup\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("function", new Regex(@"^\s*" + SwiftAttributePattern + @"(?public|private|internal|open|fileprivate|package)?\s*" + SwiftAttributePattern + @"(?:prefix|infix|postfix)\s+operator\s+(?\S+)", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("import", new Regex(@"^\s*" + SwiftAttributePattern + @"(?:(?:public|private|internal|open|fileprivate|package)\s+)?import\s+(?.+)", RegexOptions.Compiled), BodyStyle.None), - ], - ["objc"] = - [ - new("class", new Regex(@"^\s*@interface\s+(?\w+)\b", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*@implementation\s+(?\w+)\b", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*@(?:interface|implementation)\s+(?\w+\s*\(\s*[^)]+?\s*\))\b", RegexOptions.Compiled), BodyStyle.Brace), - new("interface", new Regex(@"^\s*@protocol\s+(?\w+)\b", RegexOptions.Compiled), BodyStyle.Brace), - // Apple enum macros / Apple の enum マクロ - new("enum", new Regex(@"^\s*typedef\s+(?:NS_(?:CLOSED_)?ENUM|NS_EXTENSIBLE_ENUM)\s*\([^,]+,\s*(?\w+)\s*\)", RegexOptions.Compiled), BodyStyle.Brace), - new("enum", new Regex(@"^\s*typedef\s+NS_OPTIONS\s*\([^,]+,\s*(?\w+)\s*\)", RegexOptions.Compiled), BodyStyle.Brace), - new("enum", new Regex(@"^\s*typedef\s+NS_ERROR_ENUM\s*\([^,]+,\s*(?\w+)\s*\)", RegexOptions.Compiled), BodyStyle.Brace), - new("enum", new Regex(@"^\s*typedef\s+(?:CF_ENUM|CF_OPTIONS)\s*\([^,]+,\s*(?\w+)\s*\)", RegexOptions.Compiled), BodyStyle.Brace), - new("property", new Regex(@"^\s*@property\b(?:\s*\([^)]*\))?.*?(?\w+)\s*;", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*[+-]\s*\([^)]*\)\s*(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("import", new Regex(@"^\s*#(?:import|include)\s+[<""](?[^"">]+)[>""]", RegexOptions.Compiled), BodyStyle.None), - ], - ["fsharp"] = - [ - new("function", new Regex(@"^\s*let!?\s+(?:(?:rec|mutable|inline|private|internal|public)\s+)*(?(?:``[^`]+``|\w+))(?:\s+(?:\w+|\())?", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*use!?\s+(?(?:``[^`]+``|\w+))\s*(?:=|:)", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*and\s+(?(?:``[^`]+``|\w+))\s+(?:``[^`]+``|\w+|\()", RegexOptions.Compiled), BodyStyle.None), - new("struct", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^>]+>)?\s*=\s*\{", RegexOptions.Compiled), BodyStyle.Brace), - new("interface", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^>]+>)?\s*=\s*interface\b", RegexOptions.Compiled), BodyStyle.None), - new("struct", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^>]+>)?\s*=\s*struct\b", RegexOptions.Compiled), BodyStyle.None), - new("delegate", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^>]+>)?\s*=\s*delegate\b", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^=]+?>)?(?:\s+when\b[^=]+)?\s*=\s*class\b", RegexOptions.Compiled), BodyStyle.None), - // Generic abbreviations such as `type Result<'T> = Choice<'T, string>` should not be - // mistaken for union cases just because the right-hand side starts with a capitalized - // type name. - // `type Result<'T> = Choice<'T, string>` のような generic abbreviation は、 - // 右辺が大文字始まりの型名でも union case と誤認しない。 - new("typealias", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))\s*<[^=]+?>\s*(?:when\b[^=]+)?\s*=\s*(?![^\r\n]*\|)(?!(?:class|delegate|interface|struct|enum|exception)\b)(?!\{)(?!\|)(?!\()", RegexOptions.Compiled), BodyStyle.None), - new("enum", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^>]+>)?\s*=\s*(?:\|?\s*[A-Z][\w']*\b(?:\s*\|[^=].*)?)", RegexOptions.Compiled), BodyStyle.Brace), - // Simple aliases without generic parameters stay searchable as `typealias`. - // generic 引数なしの単純な alias も `typealias` として検索可能にする。 - new("typealias", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))\s*=\s*(?![^\r\n]*\|)(?!(?:class|delegate|interface|struct|enum|exception)\b)(?!\{)(?!\|)(?!\()", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))(?:\s*<[^>]+>)?(?:\s+when\b[^=]+)?\s*(?:\([^)]*\))\s*=", RegexOptions.Compiled), BodyStyle.None), - new("struct", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))\s*=\s*\{", RegexOptions.Compiled), BodyStyle.None), - new("enum", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))\s*=\s*(?:\|\s*)?\w+(?:\s*\|\s*\w+)+", RegexOptions.Compiled), BodyStyle.None), - new("exception", new Regex(@"^\s*exception\s+(?:(?:private|internal)\s+)?(?(?:``[^`]+``|\w+))", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(@"^\s*type\s+(?:(?:rec|private|internal|public)\s+)?(?(?:``[^`]+``|\w+))\s*=\s*(?!\{)(?!\|)(?!class\b)(?!delegate\b)(?!struct\b)(?!interface\b)(?!enum\b).+", RegexOptions.Compiled), BodyStyle.None), - new("namespace", new Regex(@"^\s*namespace\s+(?:(?:rec|global)\s+)*(?[\w.]+)", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(@"^\s*module\s+(?:(?:(?:private|internal)\s+|rec\s+))*(?:``[^`]+``|[\w.]+)\s*=\s*(?(?:``[^`]+``|[\w.]+))", RegexOptions.Compiled), BodyStyle.None), - new("namespace", new Regex(@"^\s*module\s+(?:(?:(?:private|internal)\s+|rec\s+))*(?(?:``[^`]+``|[\w.]+))", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*(?:(?private|internal|public)\s+)?override\s+(?:(?:this|_|\w+)\.)?(?(?:``[^`]+``|\w+))\s*(?:\(|=|:)", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("function", new Regex(@"^\s*(?:(?private|internal|public)\s+)?abstract\s+(?!member\b)(?(?:``[^`]+``|\w+))\s*:", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("property", new Regex(@"^\s*(?:(?private|internal|public)\s+)?(?:static\s+)?val\s+(?:mutable\s+)?(?(?:``[^`]+``|\w+))\s*:", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("property", new Regex(@"^\s*(?:(?private|internal|public)\s+)?(?:(?:static|abstract|override|default)\s+)*member\s+(?:(?:private|internal)\s+)?val\s+(?(?:``[^`]+``|\w+))(?=\s|\(|=|:|$)", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("function", new Regex(@"^\s*(?:(?private|internal|public)\s+)?(?:(?:static|abstract|override|default)\s+)*member\s+(?:(?:private|internal)\s+)?(?:(?:inline)\s+)?(?:(?:this|_|\w+)\.)?(?!val\b)(?(?:``[^`]+``|\w+))(?=\s|\(|=|:|$)", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("import", new Regex(@"^\s*open\s+(?:type\s+)?(?(?:``[^`]+``|[\w.]+))", RegexOptions.Compiled), BodyStyle.None), - ], - ["vb"] = - [ - new("namespace", new Regex(@"^\s*Namespace\s+(?(?:Global\.)?" + VbIdentifierPattern + @"(?:\." + VbIdentifierPattern + @")*)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.VisualBasicEnd), - new("delegate", new Regex(@$"^\s*(?:(?:{VbMemberModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?Delegate\s+(?:Sub|Function)\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, "visibility"), - new("function", new Regex(@$"^\s*(?:(?:{VbMemberModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:{VbMemberModifierPattern})\s+)*(?:Sub|Function)\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, "visibility"), - new("operator", new Regex(@$"^\s*(?:(?:{VbOperatorModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:{VbOperatorModifierPattern})\s+)*(?Operator\s+[^\s(]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.VisualBasicEnd, "visibility"), - new("property", new Regex(@$"^\s*(?:(?:Shared|Shadows)\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:Shared|Shadows)\s+)*Const\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, "visibility"), - new("property", new Regex(@$"^\s*(?:(?:Shared|Shadows|ReadOnly|WithEvents)\s+)*(?{VbVisibilityPattern})\s+(?:(?:Shared|Shadows|ReadOnly|WithEvents)\s+)*(?{VbIdentifierPattern})\s+As\s+", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, "visibility"), - new("property", new Regex(@$"^\s*(?:(?:{VbPropertyModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:{VbPropertyModifierPattern})\s+)*Property\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, "visibility"), - new("event", new Regex(@$"^\s*(?:(?:{VbEventModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:{VbEventModifierPattern})\s+)*Event\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None, "visibility"), - new("interface", new Regex(@$"^\s*(?:(?:{VbTypeModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:{VbTypeModifierPattern})\s+)*Interface\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.VisualBasicEnd, "visibility"), - new("enum", new Regex(@$"^\s*(?:(?{VbVisibilityPattern})\s+)?Enum\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.VisualBasicEnd, "visibility"), - new("struct", new Regex(@$"^\s*(?:(?:Partial)\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:Partial)\s+)*Structure\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.VisualBasicEnd, "visibility"), - new("class", new Regex(@$"^\s*(?:(?:{VbTypeModifierPattern})\s+)*(?:(?{VbVisibilityPattern})\s+)?(?:(?:{VbTypeModifierPattern})\s+)*(?:Class|Module)\s+(?{VbIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.VisualBasicEnd, "visibility"), - new("import", new Regex(@"^\s*Imports\s+<\s*xmlns:(?[A-Za-z_][\w.-]*)\s*=", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@$"^\s*Imports\s+(?{VbIdentifierPattern})\s*=", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*Imports\s+(?.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["scala"] = - [ - new("implicit", new Regex(@"^\s*(?private|protected)?\s*implicit\s+(?:override\s+)?(?:def|val|var|class)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("given", new Regex(@"^\s*(?private|protected)?\s*given\s+(?:(?\w+)\s*(?::|as)|(?[A-Z]\w*)\b)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("function", new Regex(@"^\s*(?private|protected)?\s*(?:override\s+)?def\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("interface", new Regex(@"^\s*(?private|protected)?\s*(?:sealed\s+)?trait\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("enum", new Regex(@"^\s*(?private|protected)?\s*enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("class", new Regex(@"^\s*(?private|protected)?\s*(?:abstract\s+|sealed\s+|final\s+)?(?:case\s+)?class\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("object", new Regex(@"^\s*(?private|protected)?\s*(?:sealed\s+|final\s+)?(?:case\s+)?object\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("import", new Regex(@"^\s*type\s+(?\w+)\s*=", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(@"^\s*import\s+(?.+)", RegexOptions.Compiled), BodyStyle.None), - ], - ["haskell"] = - [ - new("function", new Regex(@"^(?:>\s+|\s*)(?[a-z_]\w*)\s+::", RegexOptions.Compiled), BodyStyle.None), - new("interface", new Regex(@"^\s*class\s+(?[A-Z]\w*)", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*(?:data|newtype|type)\s+(?[A-Z]\w*)", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(@"^\s*import\s+(?:qualified\s+)?(?[\w.]+)", RegexOptions.Compiled), BodyStyle.None), - ], - ["r"] = - [ - new("function", new Regex(@"^\s*`(?[^`]+)`\s*<[\w.]+)\s*<[^`]+)`\s*=\s*(?:function\s*\(|\\\s*\()", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*(?[\w.]+)\s*=\s*(?:function\s*\(|\\\s*\()", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*assign\s*\(\s*(?:x\s*=\s*)?['""](?[^'""]+)['""]\s*,\s*(?:value\s*=\s*)?(?:function\s*\(|\\\s*\()", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*(?:function\s*\(|\\\s*\()[^\r\n#]*(?:->>|->)\s*`(?[^`]+)`", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*(?:function\s*\(|\\\s*\()[^\r\n#]*(?:->>|->)\s*(?[\w.]+)", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*(?:(?:[\w.]+)::)?test_that\s*\(\s*['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*(?:(?:[\w.]+)::)?(?:describe|it)\s*\(\s*['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*output\$(?[\w.]+)\s*<[^'""]+)['""]\s*\]\s*\]\s*<[^`]+)`\s*(?:<[\w.]+)\s*(?:<[^'""]+)['""]|(?[\w.]+))", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*(?:(?:[\w.]+)::)?setIs\s*\(.*?\b(?:class2|to)\s*=\s*(?:['""](?[^'""]+)['""]|(?[\w.]+))", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*inherit\s*=\s*(?:c\(\s*)?(?:['""](?[^'""]+)['""]|(?[A-Z][\w.]*))", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*(?:(?:[\w.]+)::)?setValidity\s*\(\s*(?:(?:Class|class|classes|name)\s*=\s*)?(?:['""](?[^'""]+)['""]|(?[\w.]+))", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*(?:(?:[\w.]+)::)?(?:setGeneric|setGroupGeneric)\s*\(\s*(?:(?:f|generic|name)\s*=\s*)?['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*(?:(?:[\w.]+)::)?setMethod\s*\(\s*(?:(?:f|generic|name)\s*=\s*)?(?:['""](?[^'""]+)['""]|(?[\w.]+))\s*,", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*(?public|private|active)\s*=\s*list\(\s*(?[\w.]+)\s*=\s*function\s*\(", RegexOptions.Compiled), BodyStyle.None, "visibility"), - new("import", new Regex(@"^\s*(?:(?:[\w.]+)::)?(?:library|require)\s*\(\s*help\s*=\s*(?:['""](?[^'""]+)['""]|(?[\w.]+))", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(@"^\s*(?:(?:[\w.]+)::)?(?:library|require|requireNamespace)\s*\(\s*(?:(?:package|pkg)\s*=\s*)?(?:['""](?[^'""]+)['""]|(?[\w.]+))", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(@"^\s*(?:(?:[\w.]+)::)?(?:source|sys\.source)\s*\(\s*(?:file\s*=\s*)?['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.None), - ], - ["lua"] = - [ - new("function", new Regex(@"^\s*(?:local\s+)?function\s+(?[\w.:]+)\s*\(", RegexOptions.Compiled), BodyStyle.ElixirEnd), - new("function", new Regex(@"^\s*local\s+(?[\w]+)\s*=\s*function\s*\(", RegexOptions.Compiled), BodyStyle.ElixirEnd), - new("function", new Regex(@"^\s*(?[\w]+(?:[.:][\w]+)+)\s*=\s*function\s*\(", RegexOptions.Compiled), BodyStyle.ElixirEnd), - new("import", new Regex(@"^\s*(?:local\s+\w+\s*=\s*)?require\s*\(?['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.None), - ], - ["elixir"] = - [ - new("function", new Regex(@"^\s*(?:def|defp|defmacro|defguardp?)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.ElixirEnd), - new("class", new Regex(@"^\s*defmodule\s+(?[\w.]+)", RegexOptions.Compiled), BodyStyle.ElixirEnd), - new("interface", new Regex(@"^\s*defprotocol\s+(?[\w.]+)", RegexOptions.Compiled), BodyStyle.ElixirEnd), - new("protocol_impl", new Regex(@"^\s*defimpl\s+(?[\w.]+(?:\s*,\s*for:\s*(?:\[[^\]]+\]|[\w.{}]+))?)", RegexOptions.Compiled), BodyStyle.ElixirEnd), - new("import", new Regex(@"^\s*(?:import|alias|use|require)\s+(?[\w.]+)", RegexOptions.Compiled), BodyStyle.None), - ], - ["clojure"] = - [ - // Clojure forms are parenthesized, so use conservative line anchors. - // Clojure の form は括弧ベースなので、保守的な行アンカーだけを拾う。 - new("namespace", new Regex(@"^\s*\(\s*ns\s+(?[^\s\)\[\{]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex(@"^\s*\(\s*(?:defrecord|deftype)\s+(?[^\s\)\[\{]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("protocol", new Regex(@"^\s*\(\s*defprotocol\s+(?[^\s\)\[\{]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*\(\s*(?:defn-?|defmacro|defmulti|defmethod)\s+(?[^\s\)\[\{]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*\(\s*(?:def|defonce)\s+(?[^\s\)\[\{]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["erlang"] = - [ - new("namespace", new Regex(@"^\s*-module\s*\(\s*(?[a-z][\w@]*|'[^'\r\n]+')\s*\)\s*\.", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("struct", new Regex(@"^\s*-record\s*\(\s*(?[a-z][\w@]*|'[^'\r\n]+')\s*,", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("type", new Regex(@"^\s*-(?:type|opaque)\s+(?[a-z][\w@]*|'[^'\r\n]+')\s*(?:\(|::)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?[a-z][\w@]*|'[^'\r\n]+')\s*\([^)\r\n]*\)\s*(?:when\b[^-\r\n]*)?->", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*-(?:import|include(?:_lib)?)\s*\(\s*(?[^)\r\n]+)\)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["ocaml"] = - [ - new("namespace", new Regex(@"^\s*module\s+(?:type\s+)?(?[A-Z][A-Za-z0-9_']*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex(@"^\s*class(?:\s+type)?\s+(?[A-Za-z_][A-Za-z0-9_']*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("type", new Regex(@"^\s*type\s+(?:nonrec\s+)?(?:'[\w]+\s+)*(?[A-Za-z_][A-Za-z0-9_']*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*let\s+(?:rec\s+)?(?[A-Za-z_][A-Za-z0-9_']*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*val\s+(?[A-Za-z_][A-Za-z0-9_']*)\s*:", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*open\s+(?[A-Z][\w.']*)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["raku"] = - [ - new("namespace", new Regex(@"^\s*(?:unit\s+)?(?:module|package)\s+(?[\w:.-]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("interface", new Regex(@"^\s*(?:unit\s+)?role\s+(?[\w:.-]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), - new("class", new Regex(@"^\s*(?:unit\s+)?(?:class|grammar)\s+(?[\w:.-]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), - new("enum", new Regex(@"^\s*enum\s+(?[\w:.-]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?:(?:my|our|multi|proto|only)\s+)*(?:sub|method|submethod|macro)\s+(?[\w:!?.-]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.RubyEnd), - new("property", new Regex(@"^\s*(?:(?:my|our|state|constant)\s+)*(?[$@%&]\w[\w-]*)\s*=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["dart"] = - [ - new("function", new Regex(@"^\s*(?!return\b|await\b|const\b|new\b|throw\b|yield\b|if\b|else\b|for\b|while\b|switch\b|case\b|catch\b|do\b|try\b|finally\b|class\b|enum\b|mixin\b|extension\b|typedef\b|library\b|part\b|import\b|export\b)(?:(?:static|abstract|override|external)\s+)*(?\w[\w<>,\s\?]*?)\s+(?(?!if\b|else\b|for\b|while\b|switch\b|case\b|class\b|enum\b|mixin\b|extension\b|typedef\b|library\b|part\b|import\b|export\b|abstract\b|void\b|var\b|final\b|late\b|const\b|new\b|return\b|throw\b|yield\b|await\b|extends\b|implements\b|with\b|on\b|is\b|as\b|in\b|of\b|super\b|this\b)\w+)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, ReturnTypeGroup: "rt"), - new("function", new Regex(@"^\s*factory\s+(?[A-Z_]\w*(?:\.\w+)?)\s*\(", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*const\s+(?[A-Z_]\w*(?:\.\w+)?)\s*\((?=[^)]*(?:\bthis\b|\bsuper\b))", RegexOptions.Compiled), BodyStyle.None), - new("function", DartBareConstConstructorRegex, BodyStyle.None), - new("function", new Regex(@"^\s*(?[A-Z_]\w*(?:\.\w+)?)\s*\(", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*typedef\s+(?\w+)(?:<[^>]*>)?\s*=", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*typedef\s+(?:[\w<>,\[\]\?\.\s]+\s+)+(?\w+)\s*\(", RegexOptions.Compiled), BodyStyle.None), - new("enum", new Regex(@"^\s*enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*(?:abstract\s+)?(?:class|mixin)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*extension\s+(?\w+)\s+on\s+", RegexOptions.Compiled), BodyStyle.Brace), - new("import", new Regex(@"^\s*import\s+'(?[^']+)'", RegexOptions.Compiled), BodyStyle.None), - ], - ["pascal"] = - [ - new("namespace", new Regex(@"^\s*(?:unit|program|library|package)\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex(@"^\s*(?[A-Za-z_]\w*)\s*=\s*(?:class|object)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("struct", new Regex(@"^\s*(?[A-Za-z_]\w*)\s*=\s*record\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("interface", new Regex(@"^\s*(?[A-Za-z_]\w*)\s*=\s*interface\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("enum", new Regex(@"^\s*(?[A-Za-z_]\w*)\s*=\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?:(?:class|static)\s+)?(?:procedure|function|constructor|destructor)\s+(?:(?:[A-Za-z_]\w*)\.)?(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.PascalEnd), - new("property", new Regex(@"^\s*property\s+(?[A-Za-z_]\w*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*uses\s+(?.+?)(?:;|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["smalltalk"] = - [ - new("class", new Regex(@"^\s*(?:[A-Za-z_]\w*)\s+subclass:\s*#(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex(@"^\s*(?:Class\s+named:|Object\s+subclass:)\s*#(?[A-Za-z_]\w*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?:[A-Za-z_]\w*)(?:\s+class)?\s*>>\s*(?[A-Za-z_]\w*:?(?:\s+[A-Za-z_]\w+\s+[A-Za-z_]\w*:)*)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.SmalltalkMethod), - ], - ["graphql"] = - [ - new("interface", new Regex(@"^\s*interface\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("enum", new Regex(@"^\s*enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*(?:type|union|scalar|input)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*(?:query|mutation|subscription)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*fragment\s+(?\w+)\s+on\s+\w+", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*directive\s+@(?\w+)", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*extend\s+(?:type|interface|input|enum)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*extend\s+(?:union|scalar)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*schema\s*\{", RegexOptions.Compiled), BodyStyle.Brace), - ], - ["gradle"] = - [ - new("function", new Regex(@"^\s*(?:task|def)\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("import", new Regex(@"^\s*(?:apply\s+plugin\s*:\s*|id\s*[\s(]\s*)['""](?[^'""]+)['""]", RegexOptions.Compiled), BodyStyle.None), - ], - ["makefile"] = - [ - new("property", new Regex(@"^(?[\w.-]+)\s*(?::=|::=|=|\?=|\+=)", RegexOptions.Compiled), BodyStyle.None), // Makefile variable assignments / Makefile変数代入 - new("rule", new Regex(@"^(?\.PHONY)\s*:(?!=|:=)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), // Makefile special-target metadata / Makefile特殊ターゲットメタデータ - new("function", new Regex(@"^(?!\.PHONY\s*:)(?[\w.%-]+)\s*:(?!=|:=)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), // Makefile targets / Makefileターゲット - ], - ["cmake"] = - [ - new("function", new Regex(@"^\s*(?:function|macro)\s*\(\s*(?[A-Za-z_][\w.-]*)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?:add_executable|add_library|add_custom_target)\s*\(\s*(?[A-Za-z_][\w.-]*)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*(?:set|option)\s*\(\s*(?[A-Za-z_][\w.-]*)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*(?:include|find_package)\s*\(\s*(?[A-Za-z_][\w.:+-]*)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["justfile"] = - [ - new("import", new Regex(@"^\s*(?:import|mod)\s+[""'](?[^""']+)[""']", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^(?[A-Za-z_][\w.-]*)\s*(?::=|=|\+=)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^(?[A-Za-z_][\w.-]*)(?:\s+[^:#\r\n]+)?\s*:(?![:=])", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["msbuild"] = [], - ["dockerfile"] = - [ - new("build_arg", new Regex(@"^\s*ARG\s+(?[A-Za-z_][A-Za-z0-9_]*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("environment", new Regex(@"^\s*ENV\s+(?[A-Za-z_][A-Za-z0-9_]*)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("label", new Regex(@"^\s*LABEL\s+(?[A-Za-z0-9_.-]+)\s*=", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("label", new Regex(@"^\s*LABEL\s+(?[A-Za-z0-9_.-]+)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("expose", new Regex(@"^\s*EXPOSE\s+(?\d+(?:/(?:tcp|udp))?)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("user", new Regex(@"^\s*USER\s+(?[A-Za-z0-9_][A-Za-z0-9_.-]*(?::[A-Za-z0-9_][A-Za-z0-9_.-]*)?)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("workdir", new Regex(@"^\s*WORKDIR\s+(?\S+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("volume", new Regex(@"^\s*VOLUME\s+(?(?!\[)\S+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("stopsignal", new Regex(@"^\s*STOPSIGNAL\s+(?\S+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("stage", new Regex(@"^\s*FROM\s+(?:--platform=\S+\s+)?\S+\s+(?:AS|as)\s+(?[A-Za-z0-9_.-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), // Named stage / 名前付きステージ - new("base_image", new Regex(@"^\s*FROM\s+(?:--platform=\S+\s+)?(?\S+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), // Base image / ベースイメージ - ], - ["protobuf"] = - [ - new("class", new Regex(@"^\s*message\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("enum", new Regex(@"^\s*enum\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("namespace", new Regex(@"^\s*package\s+(?[\w.]+)\s*;", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*oneof\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*extend\s+(?[\w.]+)", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*service\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*rpc\s+(?\w+)", RegexOptions.Compiled), BodyStyle.None), - new("import", new Regex(@"^\s*import\s+""(?[^""]+)"";", RegexOptions.Compiled), BodyStyle.None), - ], - ["verilog"] = - [ - new("module", new Regex(@"^\s*(?:module|macromodule|primitive)\s+(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*function\s+(?:automatic\s+|static\s+)?(?:(?[\w$:\[\]\s]+?)\s+)?(?" + HdlIdentifierPattern + @")\s*(?:\(|;)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), - new("function", new Regex(@"^\s*task\s+(?:automatic\s+|static\s+)?(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*(?:parameter|localparam)\s+(?:type\s+)?" + HdlDeclaratorPrefixPattern + @"(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*(?:input|output|inout|wire|reg|logic)\s+" + HdlDeclaratorPrefixPattern + @"(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*`include\s+""(?[^""]+)""", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["systemverilog"] = - [ - new("module", new Regex(@"^\s*(?:module|macromodule|primitive|program)\s+(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("interface", new Regex(@"^\s*interface\s+(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("package", new Regex(@"^\s*package\s+(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex(@"^\s*(?:virtual\s+)?class\s+(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("enum", new Regex(@"^\s*typedef\s+enum\b[^\r\n]*\s+(?" + HdlIdentifierPattern + @")\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("struct", new Regex(@"^\s*typedef\s+struct\b[^\r\n]*\s+(?" + HdlIdentifierPattern + @")\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("typealias", new Regex(@"^\s*typedef\s+(?!(?:enum|struct|union)\b)[^\r\n]*\s+(?" + HdlIdentifierPattern + @")\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*function\s+(?:automatic\s+|static\s+|virtual\s+)?(?:(?[\w$:\[\]\s]+?)\s+)?(?" + HdlIdentifierPattern + @")\s*(?:\(|;)", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), - new("function", new Regex(@"^\s*task\s+(?:automatic\s+|static\s+|virtual\s+)?(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*(?:parameter|localparam)\s+(?:type\s+)?" + HdlDeclaratorPrefixPattern + @"(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*(?:input|output|inout|wire|reg|logic|rand|randc)\s+" + HdlDeclaratorPrefixPattern + @"(?" + HdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*import\s+(?" + HdlIdentifierPattern + @"(?:::(?:" + HdlIdentifierPattern + @"|\*))?)\s*;", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*`include\s+""(?[^""]+)""", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["vhdl"] = - [ - new("import", new Regex(@"^\s*library\s+(?" + VhdlIdentifierPattern + @")\s*;", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex(@"^\s*use\s+(?" + VhdlIdentifierPattern + @"(?:\." + VhdlIdentifierPattern + @")*)\s*;", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("module", new Regex(@"^\s*entity\s+(?" + VhdlIdentifierPattern + @")\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("module", new Regex(@"^\s*architecture\s+(?" + VhdlIdentifierPattern + @")\s+of\s+" + VhdlIdentifierPattern + @"\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("package", new Regex(@"^\s*package\s+body\s+(?" + VhdlIdentifierPattern + @")\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("package", new Regex(@"^\s*package\s+(?" + VhdlIdentifierPattern + @")\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("module", new Regex(@"^\s*component\s+(?" + VhdlIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("module", new Regex(@"^\s*configuration\s+(?" + VhdlIdentifierPattern + @")\s+of\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*function\s+(?" + VhdlIdentifierPattern + @")\s*(?:\(|return\b)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*procedure\s+(?" + VhdlIdentifierPattern + @")\s*(?:\(|is\b)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?" + VhdlIdentifierPattern + @")\s*:\s*process\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("typealias", new Regex(@"^\s*(?:type|subtype)\s+(?" + VhdlIdentifierPattern + @")\s+is\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*(?:signal|constant|variable|generic|port)\s+(?" + VhdlIdentifierPattern + @")\s*:", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["glsl"] = - [ - new("struct", new Regex(@"^\s*struct\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("property", new Regex(@"^\s*" + ShaderAttributePrefixPattern + @"(?:uniform|buffer)\s+(?:(?" + ShaderTypePattern + @")\s+)?(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - new("property", new Regex(@"^\s*" + ShaderAttributePrefixPattern + @"(?:in|out|attribute|varying)\s+(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), - new("function", new Regex(ShaderFunctionStartBlacklistPattern + @"\s*" + ShaderAttributePrefixPattern + @"(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - ], - ["hlsl"] = - [ - new("struct", new Regex(@"^\s*struct\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("property", new Regex(@"^\s*(?:cbuffer|tbuffer)\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("property", new Regex(@"^\s*(?:globallycoherent\s+)?(?:RW)?(?:Texture\w*|Buffer|StructuredBuffer|RWStructuredBuffer|ByteAddressBuffer|RWByteAddressBuffer|Sampler\w*)\s*(?:<[^>\r\n]+>)?\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*(?:groupshared|static|uniform)\s+(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), - new("function", new Regex(ShaderFunctionStartBlacklistPattern + @"\s*" + ShaderAttributePrefixPattern + @"(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - ], - ["metal"] = - [ - new("struct", new Regex(@"^\s*struct\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("property", new Regex(@"^\s*(?:constant|device|threadgroup)\s+(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None, ReturnTypeGroup: "returnType"), - new("function", new Regex(ShaderFunctionStartBlacklistPattern + @"\s*(?:kernel|vertex|fragment)\s+(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - new("function", new Regex(ShaderFunctionStartBlacklistPattern + @"\s*(?" + ShaderTypePattern + @")\s+(?" + ShaderIdentifierPattern + @")\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace, ReturnTypeGroup: "returnType"), - ], - ["wgsl"] = - [ - new("struct", new Regex(@"^\s*struct\s+(?" + ShaderIdentifierPattern + @")\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("typealias", new Regex(@"^\s*alias\s+(?" + ShaderIdentifierPattern + @")\s*=", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*(?:@\w+(?:\([^)]*\))?\s*)*(?:var(?:<[^>\r\n]+>)?|let|const|override)\s+(?" + ShaderIdentifierPattern + @")\s*:", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex(@"^\s*(?:@\w+(?:\([^)]*\))?\s*)*fn\s+(?" + ShaderIdentifierPattern + @")\s*\(", RegexOptions.Compiled | RegexOptions.CultureInvariant), BodyStyle.Brace), - ], - ["shell"] = - [ - // Bash/Zsh function declarations / Bash/Zsh 関数宣言 - new("function", new Regex(@"^\s*(?:function\s+)?(?\w+)\s*\(\s*\)\s*\{?", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*function\s+(?\w+)", RegexOptions.Compiled), BodyStyle.Brace), - // Alias definitions / エイリアス定義 - new("alias", new Regex(@"^\s*alias(?:\s+-[^\s=]+)*\s+(?[A-Za-z_][A-Za-z0-9_-]*)\s*=", RegexOptions.Compiled), BodyStyle.None), - ], - ["sql"] = - [ - // Identifier shape accepts PG double-quoted ("name"), T-SQL bracketed ([name]), or bare - // ([\w$#]+) to cover Oracle identifiers such as SYS$LINK / USER#1, optionally qualified - // with dots (schema.name, [dbo].[sp_X], "s"."n"). - // 識別子形式は PG の "name"、T-SQL の [name]、裸 ([\w$#]+) を受け入れる。裸 ID は - // SYS$LINK / USER#1 のような Oracle 識別子も拾える。ドットで修飾可能 - //(schema.name、[dbo].[sp_X]、"s"."n")。 - // CREATE TABLE / VIEW — Postgres TEMP/UNLOGGED + MATERIALIZED VIEW, T-SQL `CREATE OR ALTER` (2016+) - // CREATE TABLE / VIEW — Postgres の TEMP/UNLOGGED や MATERIALIZED VIEW、T-SQL の `CREATE OR ALTER`(2016+)に対応 - new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?(?:(?:(?:GLOBAL|LOCAL)\s+)?(?:TEMP|TEMPORARY)\s+|UNLOGGED\s+)?(?:TABLE|(?:MATERIALIZED\s+)?VIEW)\s+(?:IF\s+NOT\s+EXISTS\s+)?(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // CREATE PROCEDURE / PROC / FUNCTION / TRIGGER — Postgres `OR REPLACE` and T-SQL `OR ALTER` / `PROC` short form - // Uses BodyStyle.SqlProcBody so the body range covers the BEGIN...END / dollar-quoted body, - // letting ReferenceExtractor.ResolveContainerForCall attribute calls inside the body to the - // enclosing procedure (see issue #429). - // CREATE PROCEDURE / PROC / FUNCTION / TRIGGER — Postgres の `OR REPLACE` と T-SQL の `OR ALTER` / 短縮形 `PROC` に対応 - // BodyStyle.SqlProcBody により BEGIN...END / dollar-quoted の本体範囲を求め、ReferenceExtractor の - // ResolveContainerForCall が本体内の呼び出しを外側のプロシージャに帰属させられるようにする(issue #429)。 - new("function", new Regex($@"^\s*CREATE\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?(?:PROCEDURE|PROC|FUNCTION|TRIGGER)\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.SqlProcBody), - // SQL Server aggregate definitions are callable search anchors too, but they do not have - // a statement body to scan, so they stay on the BodyStyle.None path. - // SQL Server の aggregate 定義も検索アンカーとして有用だが、走査すべき statement body は - // 持たないため BodyStyle.None のまま扱う。 - new("function", new Regex($@"^\s*CREATE\s+AGGREGATE\b\s+(?{SqlQualifiedIdentifierPattern})\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("enum", new Regex($@"^\s*CREATE\s+TYPE\s+(?{SqlQualifiedIdentifierPattern})\s+AS\s+ENUM\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Oracle: CREATE [OR REPLACE] TYPE BODY and CREATE [OR REPLACE] PACKAGE [BODY] . - // These must precede the bare CREATE TYPE / CREATE PACKAGE rows so the `BODY` keyword is - // not absorbed as the object name. - // Oracle: CREATE [OR REPLACE] TYPE BODY と CREATE [OR REPLACE] PACKAGE [BODY] 。 - // 裸の CREATE TYPE / CREATE PACKAGE 行より前に置き、`BODY` キーワードを name として - // 飲み込まないようにする。 - new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?TYPE\s+BODY\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:EDITIONABLE\s+|NONEDITIONABLE\s+)?PACKAGE\s+BODY\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:EDITIONABLE\s+|NONEDITIONABLE\s+)?PACKAGE\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?TYPE\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // SQL Server legacy scalar-object definitions still appear in older T-SQL codebases. - // The `AS ` tail is part of the definition, not a body to track. - // SQL Server の legacy な scalar-object 定義は古い T-SQL コードベースに残っている。 - // 末尾の `AS ` は定義の一部であり、追跡すべき body ではない。 - new("class", new Regex($@"^\s*CREATE\s+(?:RULE|DEFAULT)\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("namespace", new Regex($@"^\s*CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:(?(?!AUTHORIZATION\b){SqlQualifiedIdentifierPattern})|AUTHORIZATION\s+(?{SqlQualifiedIdentifierPattern}))", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex($@"^\s*CREATE\s+(?:SEQUENCE|DOMAIN)\s+(?:IF\s+NOT\s+EXISTS\s+)?(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex($@"^\s*CREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // T-SQL SYNONYM (also Oracle / DB2) - new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:PUBLIC\s+)?SYNONYM\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Oracle: CREATE [SHARED] [PUBLIC] DATABASE LINK — must precede the bare CREATE DATABASE row - // so the `LINK` token is not taken as a name. SHARED and PUBLIC may appear together in that order. - // Oracle: CREATE [SHARED] [PUBLIC] DATABASE LINK — 裸の CREATE DATABASE 行より前に置き、 - // `LINK` を name として飲み込まないようにする。SHARED と PUBLIC はこの順で 2 語並ぶことがある。 - new("class", new Regex($@"^\s*CREATE\s+(?:SHARED\s+)?(?:PUBLIC\s+)?DATABASE\s+LINK\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // T-SQL server-level / database-level principals and objects, plus Oracle-only DIRECTORY / CONTEXT / PROFILE. - // Include T-SQL SECURITY POLICY so row-level-security policy definitions are discoverable. - // T-SQL のサーバ/データベースレベルのプリンシパル・オブジェクトと、Oracle 固有の DIRECTORY / CONTEXT / PROFILE。 - // T-SQL の SECURITY POLICY も含め、行レベルセキュリティポリシー定義を検索可能にする。 - new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:DATABASE|LOGIN|USER|ROLE|CERTIFICATE|DIRECTORY|CONTEXT|PROFILE|ASSEMBLY|XML\s+SCHEMA\s+COLLECTION|SECURITY\s+POLICY)\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // T-SQL partitioning and full-text catalogs - // T-SQL のパーティション関連と全文検索カタログ - new("function", new Regex($@"^\s*CREATE\s+PARTITION\s+FUNCTION\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex($@"^\s*CREATE\s+PARTITION\s+SCHEME\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex($@"^\s*CREATE\s+FULLTEXT\s+CATALOG\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex($@"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?(?!ON\b)(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // ALTER covers the same object kinds we create above, so migration scripts remain visible. - // Kinds are split to match the CREATE side (procedure-like → function, schema → namespace, - // extension → import, everything else → class) so `symbols --kind` / `definition` / `inspect` - // stay consistent across a CREATE + ALTER pair on the same object. - // ALTER も上記の CREATE と同じ種類をカバーし、マイグレーションスクリプトが可視になるようにする。 - // CREATE 側に合わせて kind を分割し(プロシージャ類 → function、SCHEMA → namespace、 - // EXTENSION → import、その他 → class)、同じオブジェクトに対する CREATE と ALTER で - // `symbols --kind` / `definition` / `inspect` の種別が揃うようにする。 - // ALTER PROCEDURE / PROC / FUNCTION / TRIGGER share the body shape with CREATE so they - // also get BodyStyle.SqlProcBody. ALTER PARTITION FUNCTION is body-less (it modifies the - // partition boundary, not code), so it keeps BodyStyle.None via a separate pattern below. - // ALTER PROCEDURE / PROC / FUNCTION / TRIGGER は CREATE と同じ本体形状を持つため - // BodyStyle.SqlProcBody を使う。ALTER PARTITION FUNCTION は本体を持たない - // (パーティション境界の変更のみ)ため、下の別パターンで BodyStyle.None のままにする。 - new("function", new Regex($@"^\s*ALTER\s+(?:PROCEDURE|PROC|FUNCTION|TRIGGER)\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.SqlProcBody), - new("function", new Regex($@"^\s*ALTER\s+AGGREGATE\b\s+(?{SqlQualifiedIdentifierPattern})\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("function", new Regex($@"^\s*ALTER\s+PARTITION\s+FUNCTION\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("namespace", new Regex($@"^\s*ALTER\s+SCHEMA\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("import", new Regex($@"^\s*ALTER\s+EXTENSION\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Oracle: ALTER DATABASE LINK — must precede the bare ALTER DATABASE row so `LINK` - // is not absorbed as the object name. Real Oracle body compilation is expressed as - // `ALTER PACKAGE COMPILE BODY` / `ALTER TYPE COMPILE BODY` and falls through - // to the generic ALTER row below; there is no `ALTER PACKAGE BODY ` syntax in Oracle. - // Oracle: ALTER DATABASE LINK — 裸の ALTER DATABASE 行より前に置き `LINK` を name - // として飲み込まないようにする。Oracle の body コンパイルは実際には - // `ALTER PACKAGE COMPILE BODY` / `ALTER TYPE COMPILE BODY` の形で、下の - // generic ALTER 行で拾う。`ALTER PACKAGE BODY ` のような構文は Oracle に存在しない。 - new("class", new Regex($@"^\s*ALTER\s+DATABASE\s+LINK\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("class", new Regex($@"^\s*ALTER\s+(?:TABLE|(?:MATERIALIZED\s+)?VIEW|SEQUENCE|SYNONYM|LOGIN|USER|ROLE|DATABASE|CERTIFICATE|INDEX|PACKAGE|TYPE|DOMAIN|DIRECTORY|PROFILE|ASSEMBLY|XML\s+SCHEMA\s+COLLECTION|PARTITION\s+SCHEME|FULLTEXT\s+CATALOG|SECURITY\s+POLICY)\b\s+(?{SqlQualifiedIdentifierPattern})", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["terraform"] = - [ - // Terraform resource/data: capture the logical name (second quoted token), not the type - // Terraform resource/data: 型ではなく論理名(第2引用トークン)をキャプチャ - new("class", new Regex(@"^\s*resource\s+""[^""]+""\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*data\s+""[^""]+""\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*module\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*provider\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*(?terraform)\s*\{", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*(?import|moved|removed)\s*\{", RegexOptions.Compiled), BodyStyle.Brace), - new("class", new Regex(@"^\s*check\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*variable\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*output\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), - new("function", new Regex(@"^\s*(?locals)\s*\{", RegexOptions.Compiled), BodyStyle.Brace), - ], - ["css"] = - [ - // @import / @use (SCSS) / インポート - new("import", new Regex(@"^\s*@(?:import|use|forward)\s+(?.+?)\s*;", RegexOptions.Compiled), BodyStyle.None), - // @counter-style / カウンタースタイル - new("function", new Regex(@"^\s*@counter-style\s+(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), - // @function (SCSS) / 関数 - new("function", new Regex(@"^\s*@function\s+(?[\w-]+)", RegexOptions.Compiled), BodyStyle.Brace), - // @mixin (SCSS) / ミックスイン - new("function", new Regex(@"^\s*@mixin\s+(?[\w-]+)", RegexOptions.Compiled), BodyStyle.Brace), - // @keyframes / キーフレーム - new("function", new Regex(@"^\s*@keyframes\s+(?[\w-]+)", RegexOptions.Compiled), BodyStyle.Brace), - // @font-face / フォントフェイス - new("function", new Regex(@"^\s*@font-face\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), - // @property / カスタムプロパティ登録 - new("property", new Regex(@"^\s*@property\s+(?--[\w-]+)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), - // @page / ページ規則 - new("namespace", new Regex(@"^\s*@page(?:\s+(?:[\w-]+))?", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), - // @namespace / 名前空間 - new("namespace", new Regex(@"^\s*@namespace(?:\s+(?[\w-]+))?", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // @layer reset, base, theme; / レイヤー順序宣言 - new("namespace", new Regex(@"^\s*@layer\s+(?[\w-]+)(?:\s*,\s*[\w-]+)*\s*;", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Grouping at-rules / grouping at-rule - new("namespace", new Regex(@"^\s*@(?layer|container|supports|media)\b[^{]*\{", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), - // :root selector / :root セレクタ - new("class", new Regex(@"^\s*(?:root)\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), - // Standalone attribute selector / 単独属性セレクタ - new("class", new Regex(@"^\s*(?\[[^\]]+\](?:(?:::?[\w-]+)|(?:\[[^\]]+\]))*)\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), - // Pseudo-class / pseudo-element / attribute selectors / 疑似クラス・疑似要素・属性セレクタ - new("class", new Regex(@"^\s*(?(?:[#.]?[\w-]+|\*)(?:(?:::?[\w-]+)|(?:\[[^\]]+\]))+)\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), - // CSS class selector at top level (not nested) / トップレベルのCSSクラスセレクタ - new("class", new Regex(@"^\s*(?\.[\w-]+)(?=[\s\.,:>+~\[\{])", RegexOptions.Compiled), BodyStyle.Brace), - // CSS ID selector at top level / トップレベルのIDセレクタ - new("class", new Regex(@"^\s*(?#[\w-]+)\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), - // Native CSS nesting selectors / ネイティブ CSS nesting セレクタ - new("property", new Regex(@"^\s*&(?:(?::(?[\w-]+))|(?:\s*(?:[>+~]\s*)?(?:\.|#)?(?[\w-]+)))(?:(?:::?[\w-]+)|(?:\[[^\]]+\]))*\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), - // CSS custom property declaration / CSS カスタムプロパティ宣言 - new("property", new Regex(@"^\s*(?--[\w-]+)\s*:", RegexOptions.Compiled), BodyStyle.None), - // SCSS $variable declaration / SCSS 変数宣言 - new("property", new Regex(@"^\$(?[\w-]+)\s*:", RegexOptions.Compiled), BodyStyle.None), - // SCSS placeholder selector / SCSS プレースホルダーセレクタ - new("class", new Regex(@"^\s*(?%[\w-]+)\s*[,{]", RegexOptions.Compiled), BodyStyle.Brace), - ], - ["sass"] = - [ - // Sass indented syntax has no braces, so keep these as line-level anchors. - // Sass インデント構文は波括弧を持たないため、行単位のアンカーとして扱う。 - new("import", new Regex(@"^\s*@(?:import|use|forward)\s+(?.+?)(?:\s*!default)?\s*$", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*(?:@mixin\s+|=)(?[\w-]+)", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*@function\s+(?[\w-]+)", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*@keyframes\s+(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*\$(?[\w-]+)\s*:", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*(?[.#%][\w-]+)(?=[\s\.,:>+~\[]|$)", RegexOptions.Compiled), BodyStyle.None), - new("property", new Regex(@"^\s*&(?:(?::(?[\w-]+))|(?:\s*(?:[>+~]\s*)?(?:\.|#)?(?[\w-]+)))", RegexOptions.Compiled), BodyStyle.None), - ], - ["stylus"] = - [ - // Stylus supports optional punctuation, so only capture conservative declaration shapes. - // Stylus は句読点を省略できるため、保守的な宣言形だけを捕捉する。 - new("import", new Regex(@"^\s*@(?:import|require|use)\s+(?.+?)\s*$", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^(?[A-Za-z_][\w-]*)\s*\([^)\r\n]*\)\s*$", RegexOptions.Compiled), BodyStyle.None), - new("function", new Regex(@"^\s*@keyframes\s+(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - new("property", new Regex(@"^\s*\$?(?[A-Za-z_][\w-]*)\s*(?:=|:=)\s*", RegexOptions.Compiled), BodyStyle.None), - new("class", new Regex(@"^\s*(?[.#%][\w-]+)(?=[\s\.,:>+~\[]|$)", RegexOptions.Compiled), BodyStyle.None), - new("property", new Regex(@"^\s*&(?:(?::(?[\w-]+))|(?:\s*(?:[>+~]\s*)?(?:\.|#)?(?[\w-]+)))", RegexOptions.Compiled), BodyStyle.None), - ], - // HTML does not use the regex pattern loop — it needs true tag-structure - // awareness (attribute enumeration, quoted-value handling, custom-element - // detection) that regex alone can't express without losing outer-tag - // context. `Extract` dispatches to `ExtractHtmlSymbols`, which drives a - // character state machine. The empty list here keeps "html" listed as a - // supported language via `GetSupportedLanguages()` without pretending to - // offer regex-based extraction. - // HTML は汎用の regex パターンループではなく、タグ構造を理解した走査(属性列挙、 - // 引用符付き値の処理、カスタム要素検出)を必要とするため、`Extract` は - // `ExtractHtmlSymbols` に分岐して文字単位の state machine で抽出する。空リストは - // `GetSupportedLanguages()` で "html" を対応言語として残すための置き場であり、 - // regex 抽出を模したものではない。 - ["html"] = [], - ["powershell"] = - [ - // DSC configuration / workflow declarations / DSC 構成・workflow 宣言 - new("function", new Regex(@"^\s*configuration\s+(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), - new("function", new Regex(@"^\s*workflow\s+(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), - // Function/filter declarations with optional scope prefixes / scope プレフィックス付き関数・フィルタ宣言 - new("function", new Regex(@"^\s*(?:function|filter)\s+(?:(?:script|global|local|private):)?(?[\w-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), - // PowerShell class members / PowerShell クラスメンバー - // Return-typed methods and modifiers such as `static` / `hidden` / `static hidden` - // stay on the function path. - // 戻り値付き method と `static` / `hidden` / `static hidden` のような修飾子は - // function パスで扱う。 - new("function", new Regex(@"^\s*(?:(?:static|hidden)\s+)*(?:\[[^\]]+\]\s+)+(?[\w-]+)\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), - // Constructors are bare class-name declarations inside a class body, so the - // PascalCase gate keeps most cmdlet-style calls out while still catching the - // canonical PS5+ shape. - // コンストラクタは class 本体内に置かれる bare な class-name 宣言なので、 - // PascalCase の条件で cmdlet 風の呼び出しを大半弾きつつ、PS5+ の標準形を拾う。 - new("function", new Regex(@"^\s*(?[A-Z]\w*)\s*\(", RegexOptions.Compiled), BodyStyle.Brace), - // Alias definitions / エイリアス定義 - new("alias", new Regex(@"^\s*(?:Set-Alias|New-Alias)\s+(?:-Name\s+)?(?[\w-]+)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Attributes and typed properties / 属性付きプロパティと型付きプロパティ - new("property", new Regex(@"^\s*(?:(?:static|hidden)\s+)*(?:\[[^\]]+\]\s*)+\$(?\w+)\s*(?:=|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Class (PowerShell 5+) / クラス (PowerShell 5+) - new("class", new Regex(@"^\s*class\s+(?\w+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), - // Enum (PowerShell 5+) / enum (PowerShell 5+) - new("enum", new Regex(@"^\s*enum\s+(?\w+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.Brace), - // Enum values / enum 値 - new("enum", new Regex(@"^\s{2,}(?[\w-]+)\s*(?:=\s*[^#\r\n]+)?\s*$", RegexOptions.Compiled), BodyStyle.None), - // Import-Module / using module / using namespace / using assembly / モジュールインポート - new("import", new Regex(@"^\s*(?:Import-Module|using\s+(?:module|namespace|assembly))\s+(?\S+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - ], - ["batch"] = - [ - // Labels — goto :X / call :X targets, the only navigation anchors in a batch script. - // `::` comment form has no label name, so the name character class naturally rejects it. - // Dotted labels like `:build.release` are real batch label names, so accept `.` too. - // `:EOF` is a reserved batch target used by `goto :EOF` / `call :EOF`, not a user-defined - // label, so exclude it — but only the literal full-name `eof`. Labels that merely begin - // with `eof` such as `:eof2` / `:eofish` / `:end-of-file` / `:eof.x` must still surface, - // which is why the negative lookahead checks for name-terminating characters instead of `\b`. - // ラベル — goto :X / call :X の着地点であり、batch スクリプト内で唯一のナビゲーションアンカー。 - // `::` コメント形式はラベル名を持たないため名前文字クラスが自然に弾く。 - // `:build.release` のようなドット付きラベルも正規のラベル名として受け入れる。 - // `:EOF` は `goto :EOF` / `call :EOF` 用の予約ターゲットであってユーザー定義ラベルではないため除外するが、 - // 除外するのは名前全体が `eof` のときだけ。`:eof2` / `:eofish` / `:end-of-file` / `:eof.x` のように - // 単に `eof` で始まるだけのラベルは通す必要があるため、`\b` ではなく名前終端文字を見る negative lookahead を使う。 - new("function", new Regex(@"^\s*:(?!eof(?![\w.-]))(?[\w.\-]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - // Variable assignment — set VAR=value, set /a VAR=expr, set /p VAR=prompt, set "VAR=value". - // Also handles `@set VAR=...` (echo suppression prefix), `set /a VAR+=1` (compound - // assignment operators), `if ... set VAR=...` (inline assignment inside a one-line - // control statement), and same-line multi-statement forms `set A=1 & set B=2`, - // `( set X=1 )`, `if ... ( set P=1 ) else set Q=2`, `for ... do set LOOPVAR=...`. - // Boundary alternation: line-leading `^`, or after `&` / `(` / `\belse` / `\bdo` so - // the regex (paired with the batch multi-match advance in the extractor loop) can - // emit one symbol per `set` occurrence on the same line instead of dropping every - // assignment after the first match. `rem` / `@rem` / `::` comment lines can also - // contain those boundary tokens (e.g. `REM & set FAKE=1`), so they are short- - // circuited by `IsBatchCommentLine` before this pattern ever runs — the boundary - // alternation alone is not enough to keep comment bodies out of the capture. - // 変数代入 — set VAR=value、set /a VAR=expr、set /p VAR=prompt、set "VAR=value" に対応。 - // 併せて `@set VAR=...` (echo 抑止プレフィクス) 、`set /a VAR+=1` (複合代入演算子) 、 - // `if ... set VAR=...` (1 行制御文内の代入) 、および `set A=1 & set B=2` / `( set X=1 )` / - // `if ... ( set P=1 ) else set Q=2` / `for ... do set LOOPVAR=...` のような同一行複数ステートメント形も拾う。 - // 境界は `^` / `&` / `(` / `\belse` / `\bdo` のいずれかで、extractor 側の batch 専用 - // multi-match advance と組み合わせて 1 行中の `set` ごとに 1 シンボルを出す。 - // `rem` / `@rem` / `::` コメント行にもこれらの境界トークンが入りうる - // (`REM & set FAKE=1` 等) ため、この正規表現が走る前に `IsBatchCommentLine` で - // 行ごと早期スキップしている — 境界 alternation だけではコメント本文を弾ききれない。 - new("property", new Regex(@"(?:(?:^|&|\()\s*|(?:\belse|\bdo)\s+)(?:@\s*)?(?:if\s+.+?\s+)?set\s+(?:/[aApP]\s+)?""?(?[A-Za-z_][\w]*)\s*(?:[+\-*/%&^|]|<<|>>)?=", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), BodyStyle.None), - ], - // Assembly uses a dedicated line scanner because label body ranges extend until - // the next label/section rather than a brace or indentation boundary. - // assembly は label の body range が次の label / section まで続くため、 - // brace / indent 境界ではなく専用の行走査で抽出する。 - ["assembly"] = [], - ["zig"] = - [ - // Public and private function declarations / 公開・非公開の関数宣言 - new("function", new Regex(@"^\s*(?:(?pub)\s+)?(?:inline\s+)?fn\s+(?\w+)\s*\(", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Struct/union/enum defined via const / const による struct/union/enum 定義 - new("struct", new Regex(@"^\s*(?:(?pub)\s+)?const\s+(?\w+)\s*=\s*(?:extern\s+|packed\s+)?struct\b", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("enum", new Regex(@"^\s*(?:(?pub)\s+)?const\s+(?\w+)\s*=\s*(?:extern\s+)?enum\b", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - new("class", new Regex(@"^\s*(?:(?pub)\s+)?const\s+(?\w+)\s*=\s*(?:extern\s+|packed\s+)?union\b", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Error set / エラーセット - new("class", new Regex(@"^\s*(?:(?pub)\s+)?const\s+(?\w+)\s*=\s*error\b", RegexOptions.Compiled), BodyStyle.Brace, "visibility"), - // Test declarations / テスト宣言 - new("function", new Regex(@"^\s*test\s+""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.Brace), - // @import / インポート - new("import", new Regex(@"^\s*(?:(?:pub)\s+)?const\s+\w+\s*=\s*@import\s*\(\s*""(?[^""]+)""", RegexOptions.Compiled), BodyStyle.None), - ], - }; - - private static readonly string[] BuiltInSymbolLanguages = PatternCache.Keys.ToArray(); /// /// Return the set of languages that have symbol-extraction patterns. From ba00d9eb3a75a3ff1694bd696b59661384573ad1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 15:19:12 +0900 Subject: [PATCH 035/101] Unify cross-language pattern symbol emission --- .../Symbols/SymbolExtractor.ExtractCore.cs | 373 ++---------------- .../SymbolExtractor.PatternEmission.cs | 321 +++++++++++++++ 2 files changed, 346 insertions(+), 348 deletions(-) create mode 100644 src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs index 499f778cc..608b84eb2 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs @@ -707,354 +707,31 @@ private static List ExtractCore( var sameLineEndColumn = signatureResult.Bounds.SameLineEndColumn; var sameLineEndUsesRawColumns = signatureResult.Bounds.SameLineEndUsesRawColumns; - List? fortranProcedureNames = null; - if (lang == "fortran" - && pattern.Kind == "function" - && name.Contains(',') - && signature.Contains("procedure", StringComparison.OrdinalIgnoreCase)) - { - var names = name.AsSpan(); - var nameStart = 0; - while (nameStart <= names.Length) - { - var separator = names[nameStart..].IndexOf(','); - var nameEnd = separator >= 0 ? nameStart + separator : names.Length; - var candidate = names[nameStart..nameEnd].Trim(); - if (candidate.Length > 0) - { - fortranProcedureNames ??= new List(); - fortranProcedureNames.Add(candidate.ToString()); - } - - if (separator < 0) - break; - nameStart = nameEnd + 1; - } - } - - if (lang == "cpp" - && IsCppTemplateSpecializationSymbol(kind, name, signature, lines, i)) - { - kind = "specialization"; - } - - var suppressJavaStatementSymbol = false; - if (lang == "java" && pattern.Kind == "function") - { - var trimmedSignature = signature.TrimStart(); - suppressJavaStatementSymbol = name == "switch" - || trimmedSignature.StartsWith("return ", StringComparison.Ordinal) - || trimmedSignature.StartsWith("switch ", StringComparison.Ordinal) - || trimmedSignature.StartsWith("case ", StringComparison.Ordinal); - } - - if (!suppressJavaStatementSymbol) - { - if (lang == "csharp" - && pattern.Kind == "function" - && IsCSharpTestMethod(lines, i)) - { - kind = "test.method"; - } - - var pythonImportEntries = lang == "python" && pattern.Kind == "import" - ? TryExpandPythonImportSymbols(lines, i, absoluteStartColumn, pythonModulePrefix) - : null; - var declaratorEntries = lang == "csharp" - && pattern.Kind == "property" - && pattern.BodyStyle == BodyStyle.None - ? TryExpandCSharpFieldDeclaratorList(patternMatchLine, absoluteStartColumn, match, pattern.ReturnTypeGroup, name) - : null; - var swiftEnumCaseEntries = lang == "swift" - && pattern.Kind == "property" - && pattern.BodyStyle == BodyStyle.None - ? TryExpandSwiftEnumCaseDeclaratorList(patternMatchLine, absoluteStartColumn, match) - : null; - var fortranEnumeratorEntries = lang == "fortran" - && pattern.Kind == "property" - && pattern.BodyStyle == BodyStyle.None - ? TryExpandFortranEnumeratorDeclaratorList(patternMatchLine, match) - : null; - var fortranParameterEntries = lang == "fortran" - && pattern.Kind == "property" - && pattern.BodyStyle == BodyStyle.None - ? TryExpandFortranParameterDeclaratorList(patternMatchLine, match) - : null; - var csharpMetadataTarget = TryClassifyCSharpExtractorMetadataTarget(lang, pattern.Kind, signature); - - if (pythonImportEntries != null) - { - foreach (var entry in pythonImportEntries) - { - AddSymbolRecord( - symbols, - extractionState, - cssSeenSymbols, - startLine, - new SymbolRecord - { - FileId = fileId, - Kind = kind, - Name = entry.Name, - Line = startLine, - StartLine = startLine, - StartColumn = entry.StartColumn, - EndLine = Math.Max(startLine, endLine), - BodyStartLine = bodyStartLine, - BodyEndLine = bodyEndLine, - Signature = signature, - Visibility = TryGetGroup(match, pattern.VisibilityGroup), - ReturnType = NormalizeMetadata(rawReturnType), - }, - line); - } - } - else if (declaratorEntries != null) - { - foreach (var entry in declaratorEntries) - { - AddSymbolRecord( - symbols, - extractionState, - cssSeenSymbols, - startLine, - new SymbolRecord - { - FileId = fileId, - Kind = kind, - Name = entry.Name, - Line = startLine, - StartLine = startLine, - StartColumn = csharpSingleLineCollapsedMatch - ? csharpSignatureRawStartColumn - : absoluteStartColumn, - EndLine = Math.Max(startLine, endLine), - BodyStartLine = bodyStartLine, - BodyEndLine = bodyEndLine, - Signature = signature, - Visibility = TryGetGroup(match, pattern.VisibilityGroup), - ReturnType = NormalizeMetadata(entry.ReturnType), - }, - line); - } - } - else if (swiftEnumCaseEntries != null) - { - foreach (var entry in swiftEnumCaseEntries) - { - AddSymbolRecord( - symbols, - extractionState, - cssSeenSymbols, - startLine, - new SymbolRecord - { - FileId = fileId, - Kind = kind, - Name = entry.Name, - Line = startLine, - StartLine = startLine, - StartColumn = entry.StartColumn, - EndLine = Math.Max(startLine, endLine), - BodyStartLine = bodyStartLine, - BodyEndLine = bodyEndLine, - Signature = signature, - Visibility = TryGetGroup(match, pattern.VisibilityGroup), - ReturnType = NormalizeMetadata(entry.ReturnType), - }, - line); - } - } - else if (fortranEnumeratorEntries != null) - { - foreach (var entry in fortranEnumeratorEntries) - { - AddSymbolRecord( - symbols, - extractionState, - cssSeenSymbols, - startLine, - new SymbolRecord - { - FileId = fileId, - Kind = kind, - Name = entry.Name, - Line = startLine, - StartLine = startLine, - StartColumn = entry.StartColumn, - EndLine = Math.Max(startLine, endLine), - BodyStartLine = bodyStartLine, - BodyEndLine = bodyEndLine, - Signature = signature, - Visibility = TryGetGroup(match, pattern.VisibilityGroup), - ReturnType = NormalizeMetadata(rawReturnType), - }, - line); - } - } - else if (fortranParameterEntries != null) - { - foreach (var entry in fortranParameterEntries) - { - AddSymbolRecord( - symbols, - extractionState, - cssSeenSymbols, - startLine, - new SymbolRecord - { - FileId = fileId, - Kind = kind, - Name = entry.Name, - Line = startLine, - StartLine = startLine, - StartColumn = entry.StartColumn, - EndLine = Math.Max(startLine, endLine), - BodyStartLine = bodyStartLine, - BodyEndLine = bodyEndLine, - Signature = signature, - Visibility = TryGetGroup(match, pattern.VisibilityGroup), - ReturnType = NormalizeMetadata(rawReturnType), - }, - line); - } - } - else if (fortranProcedureNames != null) - { - foreach (var procedureName in fortranProcedureNames) - { - AddSymbolRecord( - symbols, - extractionState, - cssSeenSymbols, - startLine, - new SymbolRecord - { - FileId = fileId, - Kind = kind, - Name = procedureName, - Line = startLine, - StartLine = startLine, - StartColumn = csharpSingleLineCollapsedMatch - ? csharpSignatureRawStartColumn - : absoluteStartColumn, - EndLine = Math.Max(startLine, endLine), - BodyStartLine = bodyStartLine, - BodyEndLine = bodyEndLine, - Signature = signature, - Visibility = TryGetGroup(match, pattern.VisibilityGroup), - ReturnType = NormalizeMetadata(rawReturnType), - }, - line); - } - } - else if (rubyAttrNames != null) - { - var rubyAttrSearchStart = absoluteStartColumn; - foreach (var rubyAttrName in rubyAttrNames) - { - var rubyAttrStartColumn = rubyAttrSearchStart; - if (!string.Equals(rubyAttrName, name, StringComparison.Ordinal)) - { - var foundRubyAttrStart = patternMatchLine.IndexOf(rubyAttrName, rubyAttrSearchStart, StringComparison.Ordinal); - if (foundRubyAttrStart >= 0) - rubyAttrStartColumn = foundRubyAttrStart; - } - - AddSymbolRecord( - symbols, - extractionState, - cssSeenSymbols, - startLine, - new SymbolRecord - { - FileId = fileId, - Kind = kind, - Name = rubyAttrName, - Line = startLine, - StartLine = startLine, - StartColumn = rubyAttrStartColumn, - EndLine = Math.Max(startLine, endLine), - BodyStartLine = bodyStartLine, - BodyEndLine = bodyEndLine, - Signature = signature, - Visibility = TryGetGroup(match, pattern.VisibilityGroup), - ReturnType = NormalizeMetadata(rawReturnType), - }, - line); - - rubyAttrSearchStart = rubyAttrStartColumn + Math.Max(1, rubyAttrName.Length); - } - } - else - { - AddSymbolRecord( - symbols, - extractionState, - cssSeenSymbols, - startLine, - new SymbolRecord - { - FileId = fileId, - Kind = kind, - Name = name, - Line = startLine, - StartLine = startLine, - StartColumn = lang is "ada" or "cython" or "d" or "julia" or "matlab" or "nim" - ? lineOffset + match.Groups["name"].Index - : lang == "rust" && pattern.Kind == "function" - ? match.Groups["name"].Index - : (csharpSingleLineCollapsedMatch - ? csharpSignatureRawStartColumn - : absoluteStartColumn), - EndLine = Math.Max(startLine, endLine), - BodyStartLine = bodyStartLine, - BodyEndLine = bodyEndLine, - Signature = signature, - FamilyKey = lang == "cpp" && kind == "specialization" ? name : null, - SubKind = pythonSubKind ?? ResolveLanguageSubKind(lang, kind, signature, patternMatchLine), - Visibility = TryGetGroup(match, pattern.VisibilityGroup), - ReturnType = NormalizeMetadata(rawReturnType), - IsMetadataTarget = csharpMetadataTarget, - MetadataTargetSource = csharpMetadataTarget == true - ? SymbolRecord.MetadataTargetSourceExtractor - : null, - }, - line); - - if (dockerfileStageNames != null && kind == "stage") - dockerfileStageNames.Add(name); - - if (lang == "objc" - && pattern.Kind == "class" - && TryGetObjCCategoryDisplayName(patternMatchLine[absoluteStartColumn..], name, out var categoryDisplayName)) - { - AddSymbolRecord( - symbols, - extractionState, - cssSeenSymbols, - startLine, - new SymbolRecord - { - FileId = fileId, - Kind = "class", - Name = categoryDisplayName, - Line = startLine, - StartLine = startLine, - StartColumn = csharpSingleLineCollapsedMatch - ? csharpSignatureRawStartColumn - : absoluteStartColumn, - EndLine = Math.Max(startLine, endLine), - BodyStartLine = bodyStartLine, - BodyEndLine = bodyEndLine, - Signature = signature, - Visibility = TryGetGroup(match, pattern.VisibilityGroup), - ReturnType = NormalizeMetadata(rawReturnType), - }, - line); - } - } - } + kind = EmitPatternSymbols( + new PatternSymbolEmissionContext( + fileId, + lang, + pattern, + lines, + i, + lineOffset, + absoluteStartColumn, + line, + patternMatchLine, + match, + name, + kind, + signature, + rawReturnType, + pythonSubKind, + pythonModulePrefix, + rubyAttrNames, + new PatternSymbolRange(endLine, bodyStartLine, bodyEndLine), + signatureResult.Bounds, + symbols, + extractionState, + cssSeenSymbols, + dockerfileStageNames)); if (lang == "css" && pattern.Kind == "namespace" diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs new file mode 100644 index 000000000..d700e94a0 --- /dev/null +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs @@ -0,0 +1,321 @@ +using System.Text.RegularExpressions; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class SymbolExtractor +{ + private readonly record struct PatternSymbolRange( + int EndLine, + int? BodyStartLine, + int? BodyEndLine); + + private readonly record struct PatternSymbolEmissionContext( + long FileId, + string Language, + SymbolPattern Pattern, + string[] Lines, + int LineIndex, + int LineOffset, + int AbsoluteStartColumn, + string SourceLine, + string PatternMatchLine, + Match Match, + string Name, + string Kind, + string Signature, + string? RawReturnType, + string? PythonSubKind, + string? PythonModulePrefix, + List? RubyAttrNames, + PatternSymbolRange Range, + PatternSignatureBounds SignatureBounds, + List Symbols, + SymbolExtractionState ExtractionState, + HashSet? CssSeenSymbols, + HashSet? DockerfileStageNames); + + private static string EmitPatternSymbols(PatternSymbolEmissionContext context) + { + var kind = context.Kind; + if (context.Language == "cpp" + && IsCppTemplateSpecializationSymbol( + kind, + context.Name, + context.Signature, + context.Lines, + context.LineIndex)) + { + kind = "specialization"; + } + + if (ShouldSuppressJavaStatementSymbol(context)) + return kind; + + if (context.Language == "csharp" + && context.Pattern.Kind == "function" + && IsCSharpTestMethod(context.Lines, context.LineIndex)) + { + kind = "test.method"; + } + + var pythonImportEntries = context.Language == "python" && context.Pattern.Kind == "import" + ? TryExpandPythonImportSymbols( + context.Lines, + context.LineIndex, + context.AbsoluteStartColumn, + context.PythonModulePrefix) + : null; + var csharpDeclaratorEntries = context.Language == "csharp" + && context.Pattern.Kind == "property" + && context.Pattern.BodyStyle == BodyStyle.None + ? TryExpandCSharpFieldDeclaratorList( + context.PatternMatchLine, + context.AbsoluteStartColumn, + context.Match, + context.Pattern.ReturnTypeGroup, + context.Name) + : null; + var swiftEnumCaseEntries = context.Language == "swift" + && context.Pattern.Kind == "property" + && context.Pattern.BodyStyle == BodyStyle.None + ? TryExpandSwiftEnumCaseDeclaratorList( + context.PatternMatchLine, + context.AbsoluteStartColumn, + context.Match) + : null; + var fortranEnumeratorEntries = context.Language == "fortran" + && context.Pattern.Kind == "property" + && context.Pattern.BodyStyle == BodyStyle.None + ? TryExpandFortranEnumeratorDeclaratorList(context.PatternMatchLine, context.Match) + : null; + var fortranParameterEntries = context.Language == "fortran" + && context.Pattern.Kind == "property" + && context.Pattern.BodyStyle == BodyStyle.None + ? TryExpandFortranParameterDeclaratorList(context.PatternMatchLine, context.Match) + : null; + var fortranProcedureNames = ExpandFortranProcedureNames(context); + + if (pythonImportEntries != null) + { + foreach (var entry in pythonImportEntries) + AddEmittedPatternSymbol(context, kind, entry.Name, entry.StartColumn, context.RawReturnType); + } + else if (csharpDeclaratorEntries != null) + { + foreach (var entry in csharpDeclaratorEntries) + { + AddEmittedPatternSymbol( + context, + kind, + entry.Name, + ResolveDefaultPatternStartColumn(context), + entry.ReturnType); + } + } + else if (swiftEnumCaseEntries != null) + { + foreach (var entry in swiftEnumCaseEntries) + AddEmittedPatternSymbol(context, kind, entry.Name, entry.StartColumn, entry.ReturnType); + } + else if (fortranEnumeratorEntries != null) + { + foreach (var entry in fortranEnumeratorEntries) + AddEmittedPatternSymbol(context, kind, entry.Name, entry.StartColumn, context.RawReturnType); + } + else if (fortranParameterEntries != null) + { + foreach (var entry in fortranParameterEntries) + AddEmittedPatternSymbol(context, kind, entry.Name, entry.StartColumn, context.RawReturnType); + } + else if (fortranProcedureNames != null) + { + foreach (var procedureName in fortranProcedureNames) + { + AddEmittedPatternSymbol( + context, + kind, + procedureName, + ResolveDefaultPatternStartColumn(context), + context.RawReturnType); + } + } + else if (context.RubyAttrNames != null) + { + AddRubyAttributeSymbols(context, kind); + } + else + { + AddDefaultPatternSymbol(context, kind); + } + + return kind; + } + + private static bool ShouldSuppressJavaStatementSymbol(PatternSymbolEmissionContext context) + { + if (context.Language != "java" || context.Pattern.Kind != "function") + return false; + + var trimmedSignature = context.Signature.TrimStart(); + return context.Name == "switch" + || trimmedSignature.StartsWith("return ", StringComparison.Ordinal) + || trimmedSignature.StartsWith("switch ", StringComparison.Ordinal) + || trimmedSignature.StartsWith("case ", StringComparison.Ordinal); + } + + private static List? ExpandFortranProcedureNames(PatternSymbolEmissionContext context) + { + if (context.Language != "fortran" + || context.Pattern.Kind != "function" + || !context.Name.Contains(',') + || !context.Signature.Contains("procedure", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + List? procedureNames = null; + var names = context.Name.AsSpan(); + var nameStart = 0; + while (nameStart <= names.Length) + { + var separator = names[nameStart..].IndexOf(','); + var nameEnd = separator >= 0 ? nameStart + separator : names.Length; + var candidate = names[nameStart..nameEnd].Trim(); + if (candidate.Length > 0) + (procedureNames ??= []).Add(candidate.ToString()); + + if (separator < 0) + break; + nameStart = nameEnd + 1; + } + + return procedureNames; + } + + private static void AddRubyAttributeSymbols( + PatternSymbolEmissionContext context, + string kind) + { + var rubyAttrSearchStart = context.AbsoluteStartColumn; + foreach (var rubyAttrName in context.RubyAttrNames!) + { + var rubyAttrStartColumn = rubyAttrSearchStart; + if (!string.Equals(rubyAttrName, context.Name, StringComparison.Ordinal)) + { + var foundRubyAttrStart = context.PatternMatchLine.IndexOf( + rubyAttrName, + rubyAttrSearchStart, + StringComparison.Ordinal); + if (foundRubyAttrStart >= 0) + rubyAttrStartColumn = foundRubyAttrStart; + } + + AddEmittedPatternSymbol( + context, + kind, + rubyAttrName, + rubyAttrStartColumn, + context.RawReturnType); + rubyAttrSearchStart = rubyAttrStartColumn + Math.Max(1, rubyAttrName.Length); + } + } + + private static void AddDefaultPatternSymbol( + PatternSymbolEmissionContext context, + string kind) + { + var csharpMetadataTarget = TryClassifyCSharpExtractorMetadataTarget( + context.Language, + context.Pattern.Kind, + context.Signature); + AddEmittedPatternSymbol( + context, + kind, + context.Name, + ResolveLanguagePatternStartColumn(context), + context.RawReturnType, + context.Language == "cpp" && kind == "specialization" ? context.Name : null, + context.PythonSubKind + ?? ResolveLanguageSubKind( + context.Language, + kind, + context.Signature, + context.PatternMatchLine), + csharpMetadataTarget); + + if (context.DockerfileStageNames != null && kind == "stage") + context.DockerfileStageNames.Add(context.Name); + + if (context.Language == "objc" + && context.Pattern.Kind == "class" + && TryGetObjCCategoryDisplayName( + context.PatternMatchLine[context.AbsoluteStartColumn..], + context.Name, + out var categoryDisplayName)) + { + AddEmittedPatternSymbol( + context, + "class", + categoryDisplayName, + ResolveDefaultPatternStartColumn(context), + context.RawReturnType); + } + } + + private static int ResolveLanguagePatternStartColumn(PatternSymbolEmissionContext context) + { + if (context.Language is "ada" or "cython" or "d" or "julia" or "matlab" or "nim") + return context.LineOffset + context.Match.Groups["name"].Index; + + if (context.Language == "rust" && context.Pattern.Kind == "function") + return context.Match.Groups["name"].Index; + + return ResolveDefaultPatternStartColumn(context); + } + + private static int ResolveDefaultPatternStartColumn(PatternSymbolEmissionContext context) => + context.SignatureBounds.CSharpSingleLineCollapsedMatch + ? context.SignatureBounds.CSharpSignatureRawStartColumn + : context.AbsoluteStartColumn; + + private static void AddEmittedPatternSymbol( + PatternSymbolEmissionContext context, + string kind, + string name, + int startColumn, + string? returnType, + string? familyKey = null, + string? subKind = null, + bool? isMetadataTarget = null) + { + var startLine = context.LineIndex + 1; + AddSymbolRecord( + context.Symbols, + context.ExtractionState, + context.CssSeenSymbols, + startLine, + new SymbolRecord + { + FileId = context.FileId, + Kind = kind, + Name = name, + Line = startLine, + StartLine = startLine, + StartColumn = startColumn, + EndLine = Math.Max(startLine, context.Range.EndLine), + BodyStartLine = context.Range.BodyStartLine, + BodyEndLine = context.Range.BodyEndLine, + Signature = context.Signature, + FamilyKey = familyKey, + SubKind = subKind, + Visibility = TryGetGroup(context.Match, context.Pattern.VisibilityGroup), + ReturnType = NormalizeMetadata(returnType), + IsMetadataTarget = isMetadataTarget, + MetadataTargetSource = isMetadataTarget == true + ? SymbolRecord.MetadataTargetSourceExtractor + : null, + }, + context.SourceLine); + } +} From 810258eac3d8446389033ba862769ffe02c2a54f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 15:42:30 +0900 Subject: [PATCH 036/101] Format specialized extraction switch blocks --- .../SymbolExtractor.ExtractionPhases.cs | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs index 9ae81ea6a..72a336901 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs @@ -523,11 +523,11 @@ private static bool TryExtractSpecializedSymbols( switch (lang) { case "xml": - { - var lines = SplitContentLines(content); - symbols = ExtractXmlSymbols(fileId, content, lines); - return true; - } + { + var lines = SplitContentLines(content); + symbols = ExtractXmlSymbols(fileId, content, lines); + return true; + } case "json": symbols = ExtractJsonSymbols(fileId, content, SplitContentLines(content)); return true; @@ -564,48 +564,48 @@ private static bool TryExtractSpecializedSymbols( lang); return true; case "ambiguous_m": - { - var matlabContent = AmbiguousMContentMasker.MaskComments( - content, - maskMatlabComments: true, - maskObjectiveCComments: true); - var objectiveCContent = AmbiguousMContentMasker.MaskComments( - content, - maskMatlabComments: true, - maskObjectiveCComments: true, - preserveObjectiveCModuloExpressions: true); - symbols = ExtractCore( - fileId, - "matlab", - matlabContent, - contentIsNormalized: true, - hasOversizeLine: false, - conflictMarkerLine: 0, - filePath, - projectRoot, - patternConfigsAlreadyLoaded: true, - cancellationToken); - symbols.AddRange(ExtractCore( - fileId, - "objc", - objectiveCContent, - contentIsNormalized: true, - hasOversizeLine: false, - conflictMarkerLine: 0, - filePath, - projectRoot, - patternConfigsAlreadyLoaded: true, - cancellationToken)); - return true; - } + { + var matlabContent = AmbiguousMContentMasker.MaskComments( + content, + maskMatlabComments: true, + maskObjectiveCComments: true); + var objectiveCContent = AmbiguousMContentMasker.MaskComments( + content, + maskMatlabComments: true, + maskObjectiveCComments: true, + preserveObjectiveCModuloExpressions: true); + symbols = ExtractCore( + fileId, + "matlab", + matlabContent, + contentIsNormalized: true, + hasOversizeLine: false, + conflictMarkerLine: 0, + filePath, + projectRoot, + patternConfigsAlreadyLoaded: true, + cancellationToken); + symbols.AddRange(ExtractCore( + fileId, + "objc", + objectiveCContent, + contentIsNormalized: true, + hasOversizeLine: false, + conflictMarkerLine: 0, + filePath, + projectRoot, + patternConfigsAlreadyLoaded: true, + cancellationToken)); + return true; + } case "markdown": - { - var lines = SplitContentLines(content); - symbols = ExtractMarkdownSymbols(fileId, lines); - AssignContainers(symbols, lines, null); - PopulateDeclaredContainerQualifiedNames(symbols); - return true; - } + { + var lines = SplitContentLines(content); + symbols = ExtractMarkdownSymbols(fileId, lines); + AssignContainers(symbols, lines, null); + PopulateDeclaredContainerQualifiedNames(symbols); + return true; + } default: symbols = null!; return false; From ac2f2c29686273504f43b54cf2983b08cd99bd13 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 16:13:03 +0900 Subject: [PATCH 037/101] Restore MCP cancellation method guidance --- src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs | 2 +- tests/CodeIndex.Tests/McpServerTests.cs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs index 433915d36..536c5a8fd 100644 --- a/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs +++ b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs @@ -314,7 +314,7 @@ private Task DispatchRequestMethodAsync( code: -32601, message: $"Method not found: {method}", category: McpErrorEnvelope.CategoryMethodNotFound, - suggestion: "Supported methods: initialize, tools/list, tools/call, resources/list, resources/templates/list, resources/read, prompts/list, prompts/get, logging/setLevel, ping, notifications/initialized, notifications/roots/list_changed, notifications/shutdown.", + suggestion: "Supported methods: initialize, tools/list, tools/call, resources/list, resources/templates/list, resources/read, prompts/list, prompts/get, logging/setLevel, ping, notifications/initialized, notifications/cancelled, notifications/roots/list_changed, notifications/shutdown.", retrySafe: false)), }; } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 5b6686712..f73e9d34b 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -7925,6 +7925,9 @@ public void UnknownMethod_ReturnsMethodNotFound() Assert.Equal(-32601, response["error"]!["code"]!.GetValue()); Assert.Contains("Method not found", response["error"]!["message"]!.GetValue()); + var suggestion = response["error"]!["data"]!["suggestion"]!.GetValue(); + Assert.Contains("notifications/cancelled", suggestion, StringComparison.Ordinal); + Assert.Contains("notifications/roots/list_changed", suggestion, StringComparison.Ordinal); } [Fact] From 2251fa536abb139b2b325239b940003c121dd490 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:09:57 +0900 Subject: [PATCH 038/101] Split LSP server responsibilities --- .../Lsp/LspServer.NavigationFeatures.cs | 382 +++ .../Lsp/LspServer.PositionResolution.cs | 898 ++++++ src/CodeIndex/Lsp/LspServer.Protocol.cs | 371 +++ src/CodeIndex/Lsp/LspServer.SemanticTokens.cs | 372 +++ .../Lsp/LspServer.SymbolLocations.cs | 257 ++ src/CodeIndex/Lsp/LspServer.SymbolRequests.cs | 536 ++++ src/CodeIndex/Lsp/LspServer.cs | 2704 +---------------- 7 files changed, 2817 insertions(+), 2703 deletions(-) create mode 100644 src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs create mode 100644 src/CodeIndex/Lsp/LspServer.PositionResolution.cs create mode 100644 src/CodeIndex/Lsp/LspServer.Protocol.cs create mode 100644 src/CodeIndex/Lsp/LspServer.SemanticTokens.cs create mode 100644 src/CodeIndex/Lsp/LspServer.SymbolLocations.cs create mode 100644 src/CodeIndex/Lsp/LspServer.SymbolRequests.cs diff --git a/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs b/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs new file mode 100644 index 000000000..a8222def1 --- /dev/null +++ b/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs @@ -0,0 +1,382 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Mcp; +using CodeIndex.Models; +using CodeIndex.Security; + +namespace CodeIndex.Lsp; + +internal sealed partial class LspServer : IDisposable +{ + private JsonArray Definition(JsonElement root, string method) + { + if (!TryExtractPositionToken(root, out var context, out var failureReason)) + { + RecordLookupFailure(method, failureReason); + return []; + } + + var definitions = ResolveLspDefinitions(context); + var array = new JsonArray(); + foreach (var definition in definitions) + array.Add((JsonNode)ToSymbolLocation(definition, context)); + return array; + } + + private JsonArray References(JsonElement root, string method) + { + if (!TryExtractPositionToken(root, out var context, out var failureReason)) + { + RecordLookupFailure(method, failureReason); + return []; + } + + var includeDeclaration = GetBool(root, "params", "context", "includeDeclaration") == true; + var references = ResolveLspReferences(context); + var array = new JsonArray(); + var seenLocations = new HashSet(StringComparer.Ordinal); + if (includeDeclaration) + { + foreach (var definition in ResolveLspDefinitions(context)) + AddSymbolLocation(array, seenLocations, definition, context); + } + + foreach (var reference in references) + AddLocation( + array, + seenLocations, + reference.Path, + reference.Line, + Math.Max(reference.Column, 1), + reference.Line, + Math.Max(reference.Column, 1) + Math.Max(context.Token.Length, 1), + context); + return array; + } + + private JsonNode? Hover(JsonElement root, string method) + { + if (!TryExtractPositionToken(root, out var context, out var failureReason)) + { + RecordLookupFailure(method, failureReason); + return null; + } + + var definition = ResolveLspDefinitions(context).FirstOrDefault(); + if (definition == null) + return null; + + return new JsonObject + { + ["contents"] = new JsonObject + { + ["kind"] = "plaintext", + ["value"] = FormatHoverText(definition), + }, + ["range"] = ToRange(context.Line + 1, context.StartCharacter + 1, context.Line + 1, context.EndCharacter + 1), + }; + } + + private JsonObject Completion(JsonElement root, string method) + { + if (!TryExtractPositionToken(root, out var context, out var failureReason)) + { + RecordLookupFailure(method, failureReason); + return CompletionList([]); + } + + var symbols = _reader.SearchSymbols(context.Token, MaxCompletionItems, pathPatterns: [context.IndexedPath]) + .Concat(_reader.SearchSymbols(context.Token, MaxCompletionItems)) + .DistinctBy(BuildCompletionIdentity) + .Take(MaxCompletionItems) + .ToList(); + var items = new JsonArray(); + for (var i = 0; i < symbols.Count; i++) + items.Add((JsonNode)ToCompletionItem(symbols[i], i)); + return CompletionList(items); + } + + private JsonArray DocumentHighlight(JsonElement root, string method) + { + if (!TryExtractPositionToken(root, out var context, out var failureReason)) + { + RecordLookupFailure(method, failureReason); + return []; + } + + var array = new JsonArray(); + var seenRanges = new HashSet(StringComparer.Ordinal); + foreach (var definition in ResolveLspDefinitions(context).Where(definition => string.Equals(definition.Path, context.IndexedPath, StringComparison.Ordinal))) + { + var identifier = GetSymbolIdentifierPosition(definition, context.ResolvedPath); + AddDocumentHighlight(array, seenRanges, identifier.Line, identifier.StartColumn, identifier.Line, identifier.EndColumn); + } + + foreach (var reference in ResolveLspReferences(context).Where(reference => string.Equals(reference.Path, context.IndexedPath, StringComparison.Ordinal))) + { + var startColumn = Math.Max(reference.Column, 1); + AddDocumentHighlight(array, seenRanges, reference.Line, startColumn, reference.Line, startColumn + Math.Max(context.Token.Length, 1)); + } + + if (array.Count == 0) + AddDocumentHighlight(array, seenRanges, context.Line + 1, context.StartCharacter + 1, context.Line + 1, context.EndCharacter + 1); + return array; + } + + private JsonObject SemanticTokensFull(JsonElement root) + { + if (!TryResolveIndexedDocument(root, out var document)) + return new JsonObject { ["data"] = new JsonArray() }; + + var lineCache = new Dictionary(); + var symbols = GetDocumentSymbols(document.IndexedPath, MaxSemanticTokenItems) + .Where(symbol => !string.IsNullOrWhiteSpace(symbol.Name)) + .Take(MaxSemanticTokenItems) + .Select(symbol => BuildSemanticToken(document, symbol, lineCache)) + .Where(token => token.HasValue) + .Select(token => token!.Value) + .OrderBy(token => token.Line) + .ThenBy(token => token.StartCharacter) + .ToList(); + IEnumerable lexicalTokens = string.Equals(Path.GetExtension(document.ResolvedPath), ".cs", StringComparison.OrdinalIgnoreCase) + ? BuildCSharpLexicalSemanticTokens(document, lineCache) + : []; + symbols = RemoveOverlappingSemanticTokens(lexicalTokens.Concat(symbols)) + .OrderBy(token => token.Line) + .ThenBy(token => token.StartCharacter) + .ToList(); + var data = new JsonArray(); + var previousLine = 0; + var previousStart = 0; + foreach (var token in symbols) + { + var deltaLine = token.Line - previousLine; + var deltaStart = deltaLine == 0 ? token.StartCharacter - previousStart : token.StartCharacter; + data.Add(deltaLine); + data.Add(deltaStart); + data.Add(token.Length); + data.Add(token.TokenType); + data.Add(token.TokenModifiers); + previousLine = token.Line; + previousStart = token.StartCharacter; + } + + return new JsonObject { ["data"] = data }; + } + + private JsonArray InlayHint(JsonElement root) + { + if (!TryResolveIndexedDocument(root, out var document)) + return []; + + var array = new JsonArray(); + var lineCache = new Dictionary(); + var hasRange = TryReadInlayHintRange(root, out var startLine, out _, out var endLine, out _); + foreach (var symbol in GetDocumentSymbols( + document.IndexedPath, + MaxDocumentSymbols, + hasRange ? startLine + 1 : null, + hasRange ? endLine + 1 : null) + .Where(symbol => !string.IsNullOrWhiteSpace(symbol.ReturnType)) + .Where(symbol => IsInlayHintInRequestedRange(root, document, symbol, lineCache)) + .Where(symbol => !HasExplicitTypeBeforeSymbol(document, symbol, lineCache)) + .Take(MaxInlayHintItems)) + { + array.Add((JsonNode)ToInlayHint(document, symbol, lineCache)); + } + return array; + } + + private bool IsInlayHintInRequestedRange( + JsonElement root, + IndexedDocumentContext document, + SymbolResult symbol, + Dictionary lineCache) + { + if (!TryReadInlayHintRange(root, out var startLine, out var startCharacter, out var endLine, out var endCharacter)) + return true; + + var line = Math.Max(symbol.Line, 1) - 1; + var character = FindSymbolStartCharacter(document.ResolvedPath, symbol, lineCache) + symbol.Name.Length; + return IsPositionInRange(line, character, startLine, startCharacter, endLine, endCharacter); + } + + private bool HasExplicitTypeBeforeSymbol( + IndexedDocumentContext document, + SymbolResult symbol, + Dictionary lineCache) + { + var line = Math.Max(symbol.Line, symbol.StartLine); + if (string.IsNullOrWhiteSpace(symbol.ReturnType) || + line <= 0 || + !TryReadPositionLineCached(document.ResolvedPath, line - 1, lineCache, out var sourceLine)) + { + return false; + } + + var symbolStart = FindSymbolStartCharacter(document.ResolvedPath, symbol, lineCache); + if (symbolStart <= 0 || sourceLine.Length == 0) + return false; + + var typeStart = sourceLine.LastIndexOf(symbol.ReturnType, symbolStart - 1, StringComparison.Ordinal); + if (typeStart < 0) + return false; + + var typeEnd = typeStart + symbol.ReturnType.Length; + return typeEnd <= symbolStart && sourceLine.AsSpan(typeEnd, symbolStart - typeEnd).Trim().IsEmpty; + } + + private static bool TryReadLspPosition(JsonElement range, string propertyName, out int line, out int character) + { + line = 0; + character = 0; + return range.ValueKind == JsonValueKind.Object && + range.TryGetProperty(propertyName, out var position) && + position.ValueKind == JsonValueKind.Object && + position.TryGetProperty("line", out var lineElement) && + lineElement.TryGetInt32(out line) && + line >= 0 && + position.TryGetProperty("character", out var characterElement) && + characterElement.TryGetInt32(out character) && + character >= 0; + } + + private static bool TryReadInlayHintRange( + JsonElement root, + out int startLine, + out int startCharacter, + out int endLine, + out int endCharacter) + { + startLine = 0; + startCharacter = 0; + endLine = 0; + endCharacter = 0; + return root.TryGetProperty("params", out var paramsElement) && + paramsElement.TryGetProperty("range", out var range) && + TryReadLspPosition(range, "start", out startLine, out startCharacter) && + TryReadLspPosition(range, "end", out endLine, out endCharacter); + } + + private static bool IsPositionInRange( + int line, + int character, + int startLine, + int startCharacter, + int endLine, + int endCharacter) + => ComparePosition(line, character, startLine, startCharacter) >= 0 && + ComparePosition(line, character, endLine, endCharacter) < 0; + + private static int ComparePosition(int leftLine, int leftCharacter, int rightLine, int rightCharacter) + => leftLine != rightLine ? leftLine.CompareTo(rightLine) : leftCharacter.CompareTo(rightCharacter); + + private static JsonObject CompletionList(JsonArray items) => new() + { + ["isIncomplete"] = false, + ["items"] = items, + }; + + private static string BuildCompletionIdentity(SymbolResult symbol) + => string.Join('\0', symbol.Name, symbol.Kind, symbol.Path, symbol.Line.ToString(CultureInfo.InvariantCulture)); + + private static JsonObject ToCompletionItem(SymbolResult symbol, int index) => new() + { + ["label"] = symbol.Name, + ["kind"] = CompletionItemKind(symbol.Kind), + ["detail"] = FormatSymbolDetail(symbol), + ["sortText"] = index.ToString("D4", CultureInfo.InvariantCulture) + "_" + symbol.Name, + }; + + private string FormatHoverText(SymbolResult symbol) + { + var builder = new StringBuilder(); + builder.Append(symbol.Kind).Append(' ').Append(symbol.Name); + if (!string.IsNullOrWhiteSpace(symbol.Signature)) + builder.AppendLine().Append(symbol.Signature); + builder.AppendLine().Append(FormatHoverPath(symbol.Path)).Append(':').Append(symbol.Line.ToString(CultureInfo.InvariantCulture)); + if (!string.IsNullOrWhiteSpace(symbol.ContainerName)) + builder.AppendLine().Append("container: ").Append(symbol.ContainerName); + if (!string.IsNullOrWhiteSpace(symbol.ReturnType)) + builder.AppendLine().Append("returns: ").Append(symbol.ReturnType); + return builder.ToString(); + } + + private string FormatHoverPath(string path) + { + if (!Path.IsPathRooted(path)) + return path.Replace('\\', '/'); + + foreach (var root in EnumerateHoverRoots()) + { + if (TryGetRelativePath(root, path, out var relativePath) && relativePath != null) + return relativePath.Replace('\\', '/'); + } + + return "[outside workspace]"; + } + + private IEnumerable EnumerateHoverRoots() + { + if (_projectRoot != null) + yield return _projectRoot; + foreach (var workspaceFolder in _workspaceFolders) + yield return workspaceFolder; + } + + private static string FormatSymbolDetail(SymbolResult symbol) + { + var detail = string.IsNullOrWhiteSpace(symbol.Signature) + ? $"{symbol.Kind} {symbol.Path}:{symbol.Line.ToString(CultureInfo.InvariantCulture)}" + : symbol.Signature; + return detail.Length <= MaxDocumentSymbolDetailChars + ? detail + : detail[..(MaxDocumentSymbolDetailChars - "...".Length)] + "..."; + } + + private static int CompletionItemKind(string kind) => kind switch + { + "class" => 7, + "function" or "test.method" => 3, + "property" => 10, + "enum" => 13, + "interface" => 8, + "namespace" => 9, + "struct" => 22, + _ => 6, + }; + + private static void AddDocumentHighlight(JsonArray array, HashSet seenRanges, int startLine, int startColumn, int endLine, int endColumn) + { + var key = string.Join('\0', startLine, startColumn, endLine, endColumn); + if (!seenRanges.Add(key)) + return; + + array.Add(new JsonObject + { + ["range"] = ToRange(startLine, startColumn, endLine, endColumn), + ["kind"] = 1, + }); + } + + private JsonObject ToInlayHint(IndexedDocumentContext document, SymbolResult symbol, Dictionary lineCache) + { + var startCharacter = FindSymbolStartCharacter(document.ResolvedPath, symbol, lineCache); + return new JsonObject + { + ["position"] = ToPosition(symbol.Line, startCharacter + symbol.Name.Length + 1), + ["label"] = ": " + symbol.ReturnType, + ["kind"] = 1, + ["paddingLeft"] = true, + }; + } + +} diff --git a/src/CodeIndex/Lsp/LspServer.PositionResolution.cs b/src/CodeIndex/Lsp/LspServer.PositionResolution.cs new file mode 100644 index 000000000..51879da12 --- /dev/null +++ b/src/CodeIndex/Lsp/LspServer.PositionResolution.cs @@ -0,0 +1,898 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Mcp; +using CodeIndex.Models; +using CodeIndex.Security; + +namespace CodeIndex.Lsp; + +internal sealed partial class LspServer : IDisposable +{ + private List ResolveLspDefinitions(PositionTokenContext context) + { + var localDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true, pathPatterns: [context.IndexedPath]); + if (localDefinitions.Count > 0) + { + var positionDefinitions = FindDefinitionsAtPosition(localDefinitions, context); + if (positionDefinitions.Count > 0) + return positionDefinitions; + + var localReferenceTarget = ResolveReferenceTargetAtPosition(context); + return localReferenceTarget == null ? localDefinitions : [localReferenceTarget]; + } + + var workspaceDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true); + if (workspaceDefinitions.Count > 1) + { + var referenceTarget = ResolveReferenceTargetAtPosition(context); + if (referenceTarget != null) + return [referenceTarget]; + } + return workspaceDefinitions; + } + + private IReadOnlyList ResolveLspReferences(PositionTokenContext context) + { + var localDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true, pathPatterns: [context.IndexedPath]); + if (localDefinitions.Count > 0) + { + var positionDefinitions = FindDefinitionsAtPosition(localDefinitions, context); + if (positionDefinitions.Count == 1) + return _reader.GetReferencesForDefinition(positionDefinitions[0], DefaultLimit); + + var localReferenceTarget = ResolveReferenceTargetAtPosition(context); + if (localReferenceTarget != null) + return _reader.GetReferencesForDefinition(localReferenceTarget, DefaultLimit); + + return _reader.AnalyzeSymbol(context.Token, DefaultLimit, pathPatterns: [context.IndexedPath], exact: true).References; + } + + var workspaceDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true); + if (workspaceDefinitions.Count > 1) + { + var referenceTarget = ResolveReferenceTargetAtPosition(context); + if (referenceTarget != null) + return _reader.GetReferencesForDefinition(referenceTarget, DefaultLimit); + } + + if (workspaceDefinitions.Count == 0 || !HasSingleLspDefinitionTarget(workspaceDefinitions)) + return _reader.AnalyzeSymbol(context.Token, DefaultLimit, pathPatterns: [context.IndexedPath], exact: true).References; + + return _reader.AnalyzeSymbol(context.Token, DefaultLimit, exact: true).References; + } + + private DefinitionResult? ResolveReferenceTargetAtPosition(PositionTokenContext context) + { + var resolution = _reader.GetReferencePositionResolution( + context.IndexedPath, + context.Token, + context.Line + 1, + context.StartCharacter + 1, + MaxReferencePositionCandidates); + if (!resolution.IdentityAvailable || resolution.CandidatesTruncated) + return null; + + var selected = resolution.Candidates + .Where(candidate => candidate.Authoritative) + .Take(2) + .ToList(); + if (selected.Count == 1) + return _reader.GetDefinitionForSymbol(selected[0].Definition); + + if (TryGetCSharpInvocationArgumentCount(context, out var argumentCount)) + { + selected = resolution.Candidates + .Where(candidate => TryGetCSharpDefinitionParameterCount(candidate.Definition, out var parameterCount) && + parameterCount == argumentCount) + .Take(2) + .ToList(); + if (selected.Count == 1) + return _reader.GetDefinitionForSymbol(selected[0].Definition); + } + + return resolution.Candidates.Count == 1 + ? _reader.GetDefinitionForSymbol(resolution.Candidates[0].Definition) + : null; + } + + private bool TryGetCSharpInvocationArgumentCount(PositionTokenContext context, out int argumentCount) + { + argumentCount = 0; + if (!TryReadPositionLine(context.ResolvedPath, context.Line, out var sourceLine, out _)) + return false; + + var openParenthesis = context.EndCharacter; + while (openParenthesis < sourceLine.Length && char.IsWhiteSpace(sourceLine[openParenthesis])) + openParenthesis++; + return openParenthesis < sourceLine.Length && + sourceLine[openParenthesis] == '(' && + TryCountCommaSeparatedItems(sourceLine, openParenthesis, allowAngleBrackets: false, out argumentCount); + } + + private static bool TryGetCSharpDefinitionParameterCount(SymbolResult definition, out int parameterCount) + { + parameterCount = 0; + if (!string.Equals(definition.Lang, "csharp", StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(definition.Signature)) + { + return false; + } + + var nameStart = FindIdentifierOccurrence(definition.Signature, definition.Name, 0); + if (nameStart < 0) + return false; + var openParenthesis = definition.Signature.IndexOf('(', nameStart + definition.Name.Length); + return openParenthesis >= 0 && + TryCountCommaSeparatedItems(definition.Signature, openParenthesis, allowAngleBrackets: true, out parameterCount); + } + + private static bool TryCountCommaSeparatedItems( + string text, + int openParenthesis, + bool allowAngleBrackets, + out int itemCount) + { + itemCount = 0; + var parenthesisDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var angleDepth = 0; + var hasItemContent = false; + for (var index = openParenthesis + 1; index < text.Length; index++) + { + var value = text[index]; + if (value is '\'' or '"' || + (value == '/' && index + 1 < text.Length && text[index + 1] is '/' or '*')) + { + return false; + } + + switch (value) + { + case '(': + parenthesisDepth++; + hasItemContent = true; + break; + case ')' when parenthesisDepth > 0: + parenthesisDepth--; + hasItemContent = true; + break; + case ')' when bracketDepth == 0 && braceDepth == 0 && angleDepth == 0: + itemCount = hasItemContent ? itemCount + 1 : 0; + return true; + case '[': + bracketDepth++; + hasItemContent = true; + break; + case ']' when bracketDepth > 0: + bracketDepth--; + hasItemContent = true; + break; + case '{': + braceDepth++; + hasItemContent = true; + break; + case '}' when braceDepth > 0: + braceDepth--; + hasItemContent = true; + break; + case '<' when allowAngleBrackets: + angleDepth++; + hasItemContent = true; + break; + case '>' when allowAngleBrackets && angleDepth > 0: + angleDepth--; + hasItemContent = true; + break; + case '<' or '>': + return false; + case ',' when parenthesisDepth == 0 && bracketDepth == 0 && braceDepth == 0 && angleDepth == 0: + if (!hasItemContent) + return false; + itemCount++; + hasItemContent = false; + break; + default: + hasItemContent |= !char.IsWhiteSpace(value); + break; + } + } + + return false; + } + + private List PreferDefinitionAtPosition( + List definitions, + PositionTokenContext context) + { + var positioned = FindDefinitionsAtPosition(definitions, context); + return positioned.Count > 0 ? positioned : definitions; + } + + private List FindDefinitionsAtPosition( + List definitions, + PositionTokenContext context) + { + var sourceLine = context.Line + 1; + return definitions.Where(definition => + { + var identifier = GetSymbolIdentifierPosition(definition, context.ResolvedPath); + if (identifier.Line != sourceLine) + return false; + + var definitionStart = identifier.StartColumn - 1; + var definitionEnd = identifier.EndColumn - 1; + return context.StartCharacter < definitionEnd && context.EndCharacter > definitionStart; + }).ToList(); + } + + private static bool HasSingleLspDefinitionTarget(IReadOnlyList definitions) + { + if (definitions.Count <= 1) + return true; + + var firstKey = BuildLspDefinitionTargetKey(definitions[0]); + return definitions.Skip(1).All(definition => string.Equals(BuildLspDefinitionTargetKey(definition), firstKey, StringComparison.Ordinal)); + } + + private static string BuildLspDefinitionTargetKey(DefinitionResult definition) + => string.Join('\0', definition.Path, definition.Kind, definition.ContainerKind, definition.ContainerName, definition.Name); + + private bool TryExtractPositionToken(JsonElement root, out PositionTokenContext context, out string? failureReason) + { + context = default; + failureReason = null; + var path = GetDocumentPath(root); + var line = GetInt32(root, "params", "position", "line"); + var character = GetInt32(root, "params", "position", "character"); + if (line < 0 || character < 0) + { + failureReason = FailureInvalidPosition; + return false; + } + + if (!TryResolveDocumentPath(path, out var resolvedPath, out var projectRelativePath, out var workspaceRoot, out failureReason)) + return false; + + var indexedPath = ResolveIndexedPath(path, resolvedPath, projectRelativePath, workspaceRoot); + if (indexedPath == null) + { + failureReason = FailureFileNotIndexed; + return false; + } + + var indexedPathRoot = _projectRoot == null ? workspaceRoot : null; + if (!TryResolveIndexedFilePath(indexedPath, indexedPathRoot, out var indexedFullPath)) + { + failureReason = FailureIndexedFileUnresolved; + return false; + } + + if (!string.Equals(resolvedPath, indexedFullPath, _pathStringComparison)) + { + failureReason = FailurePathCasingMismatch; + return false; + } + + if (!TryReadPositionLine(indexedFullPath, line, out var sourceLine, out failureReason)) + return false; + + var token = ExtractTokenAtUtf16Position(sourceLine, character); + if (string.IsNullOrWhiteSpace(token)) + { + failureReason = FailureNoTokenAtPosition; + return false; + } + + var (startCharacter, endCharacter) = FindTokenRangeAtUtf16Position(sourceLine, character); + context = new PositionTokenContext(token, indexedFullPath, indexedPath, workspaceRoot, line, startCharacter, endCharacter); + return true; + } + + private bool TryResolveIndexedDocument(JsonElement root, out IndexedDocumentContext context) + { + context = default; + var documentPath = GetDocumentPath(root); + if (!TryResolveDocumentPath(documentPath, out var resolvedPath, out var projectRelativePath, out var workspaceRoot)) + return false; + + var indexedPath = ResolveIndexedPath(documentPath, resolvedPath, projectRelativePath, workspaceRoot); + if (indexedPath == null) + return false; + + var indexedPathRoot = _projectRoot == null ? workspaceRoot : null; + if (!TryResolveIndexedFilePath(indexedPath, indexedPathRoot, out var indexedFullPath)) + return false; + + if (!string.Equals(resolvedPath, indexedFullPath, _pathStringComparison)) + return false; + + context = new IndexedDocumentContext(documentPath, resolvedPath, indexedPath, workspaceRoot); + return true; + } + + private List GetDocumentSymbols(string indexedPath, int limit, int? startLine = null, int? endLine = null) + => _reader.SearchSymbols((string?)null, limit, pathPatterns: [indexedPath], startLine: startLine, endLine: endLine) + .OrderBy(s => s.StartLine) + .ThenByDescending(s => s.EndLine) + .ThenBy(s => s.ContainerName == null ? 0 : 1) + .ThenBy(s => s.Name, StringComparer.Ordinal) + .ToList(); + + private bool TryReadPositionLine(string path, int targetLine, out string sourceLine, out string? failureReason) + { + if (_liveDocumentStore.TryGetText(Path.GetFullPath(path), out var liveText)) + return TryReadPositionLineFromText(liveText, targetLine, out sourceLine, out failureReason); + + return TryReadPositionLineFromFile(path, targetLine, out sourceLine, out failureReason); + } + + private bool TryReadPositionLineCached( + string path, + int targetLine, + Dictionary? lineCache, + out string sourceLine) + { + if (targetLine < 0) + { + sourceLine = string.Empty; + return false; + } + + if (lineCache != null && lineCache.TryGetValue(targetLine, out var cachedLine)) + { + sourceLine = cachedLine ?? string.Empty; + return cachedLine != null; + } + + if (lineCache is { Count: 0 } && TryReadAllPositionLines(path, out var sourceLines)) + { + for (var line = 0; line < sourceLines.Count; line++) + lineCache[line] = sourceLines[line]; + + if (lineCache.TryGetValue(targetLine, out cachedLine)) + { + sourceLine = cachedLine ?? string.Empty; + return cachedLine != null; + } + + sourceLine = string.Empty; + return false; + } + + var found = TryReadPositionLine(path, targetLine, out sourceLine, out _); + if (lineCache != null) + lineCache[targetLine] = found ? sourceLine : null; + return found; + } + + private bool TryReadAllPositionLines(string path, out IReadOnlyList sourceLines) + { + sourceLines = []; + if (_liveDocumentStore.TryGetText(Path.GetFullPath(path), out var liveText)) + { + if (Encoding.UTF8.GetByteCount(liveText) > MaxPositionDocumentBytes) + return false; + sourceLines = SplitPositionLines(liveText); + return true; + } + + return TryReadAllPositionLinesFromFile(path, out sourceLines, out _); + } + + internal static bool TryReadAllPositionLinesFromFile( + string path, + out IReadOnlyList sourceLines, + out string? failureReason) + { + sourceLines = []; + failureReason = null; + try + { + using var stream = BoundedFile.OpenReadForLengthCheckedText(path); + if (stream.Length > MaxPositionDocumentBytes) + { + failureReason = FailurePositionFileTooLarge; + return false; + } + + PositionFileLengthCheckedForTesting?.Invoke(path); + using var boundedStream = new PositionFileReadStream(stream, MaxPositionDocumentBytes); + using var reader = new StreamReader( + boundedStream, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + bufferSize: BoundedFile.SmallReadBufferSize); + sourceLines = ReadPositionLines(reader); + return true; + } + catch (PositionFileTooLargeException) + { + failureReason = FailurePositionFileTooLarge; + return false; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + failureReason = FailurePositionFileUnreadable; + return false; + } + } + + private static IReadOnlyList ReadPositionLines(TextReader reader) + { + var lines = new List(); + var line = new StringBuilder(); + var lineLength = 0; + var lineTooLong = false; + var previousWasCarriageReturn = false; + var buffer = new char[4096]; + while (true) + { + var read = reader.Read(buffer, 0, buffer.Length); + if (read == 0) + break; + + for (var index = 0; index < read; index++) + { + var value = buffer[index]; + if (previousWasCarriageReturn) + { + previousWasCarriageReturn = false; + if (value == '\n') + continue; + } + + if (value is '\r' or '\n') + { + lines.Add(lineTooLong ? null : line.ToString()); + line.Clear(); + lineLength = 0; + lineTooLong = false; + previousWasCarriageReturn = value == '\r'; + continue; + } + + lineLength++; + if (lineLength <= MaxPositionLineChars) + line.Append(value); + else if (!lineTooLong) + { + line.Clear(); + lineTooLong = true; + } + } + } + + lines.Add(lineTooLong ? null : line.ToString()); + return lines; + } + + private static IReadOnlyList SplitPositionLines(string text) + { + using var reader = new StringReader(text); + return ReadPositionLines(reader); + } + + private sealed class PositionFileTooLargeException : IOException + { + } + + private sealed class PositionFileReadStream(Stream inner, long maxBytes) : Stream + { + private long _remaining = maxBytes; + private bool _disposed; + + public override bool CanRead => !_disposed && inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_remaining > 0) + { + var read = inner.Read(buffer, offset, (int)Math.Min(count, _remaining)); + _remaining -= read; + return read; + } + + return ProbeForOverflow(); + } + + public override int Read(Span buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_remaining > 0) + { + var read = inner.Read(buffer[..(int)Math.Min(buffer.Length, _remaining)]); + _remaining -= read; + return read; + } + + return ProbeForOverflow(); + } + + private int ProbeForOverflow() + { + Span probe = stackalloc byte[1]; + if (inner.Read(probe) != 0) + throw new PositionFileTooLargeException(); + return 0; + } + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + _disposed = true; + base.Dispose(disposing); + } + } + + private static bool TryReadPositionLineFromText(string text, int targetLine, out string sourceLine, out string? failureReason) + { + sourceLine = string.Empty; + failureReason = null; + if (targetLine < 0) + { + failureReason = FailureInvalidPosition; + return false; + } + + var currentLine = 0; + var lineStart = 0; + for (var i = 0; i <= text.Length; i++) + { + var atEnd = i == text.Length; + var isLineBreak = !atEnd && (text[i] == '\r' || text[i] == '\n'); + if (!atEnd && !isLineBreak) + continue; + + if (currentLine == targetLine) + { + var length = i - lineStart; + if (length > MaxPositionLineChars) + { + failureReason = FailurePositionLineTooLong; + return false; + } + + sourceLine = text.Substring(lineStart, length); + return true; + } + + if (atEnd) + break; + + if (text[i] == '\r' && i + 1 < text.Length && text[i + 1] == '\n') + i++; + currentLine++; + lineStart = i + 1; + } + + failureReason = FailurePositionLineMissing; + return false; + } + + private static bool TryReadPositionLineFromFile(string path, int targetLine, out string sourceLine, out string? failureReason) + { + sourceLine = string.Empty; + failureReason = null; + try + { + using var stream = BoundedFile.OpenReadForLengthCheckedText(path); + if (stream.Length > MaxPositionDocumentBytes) + { + failureReason = FailurePositionFileTooLarge; + return false; + } + + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + var currentLine = 0; + var currentLineLength = 0; + StringBuilder? builder = targetLine == 0 ? new StringBuilder() : null; + while (true) + { + var next = reader.Read(); + if (stream.Position > MaxPositionDocumentBytes) + { + failureReason = FailurePositionFileTooLarge; + return false; + } + + if (next < 0) + { + if (currentLine == targetLine && currentLineLength <= MaxPositionLineChars && builder != null) + { + sourceLine = builder.ToString(); + return true; + } + + failureReason = FailurePositionLineMissing; + return false; + } + + var c = (char)next; + if (c == '\r' || c == '\n') + { + if (c == '\r' && reader.Peek() == '\n') + { + reader.Read(); + if (stream.Position > MaxPositionDocumentBytes) + { + failureReason = FailurePositionFileTooLarge; + return false; + } + } + + if (currentLine == targetLine) + { + sourceLine = builder?.ToString() ?? string.Empty; + return true; + } + + currentLine++; + currentLineLength = 0; + builder = currentLine == targetLine ? new StringBuilder() : null; + continue; + } + + currentLineLength++; + if (currentLineLength > MaxPositionLineChars) + { + if (currentLine == targetLine) + { + failureReason = FailurePositionLineTooLong; + return false; + } + continue; + } + + builder?.Append(c); + } + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + failureReason = FailurePositionFileUnreadable; + return false; + } + } + + internal static string? ExtractTokenAtUtf16Position(string line, int character) + { + if (character < 0) + return null; + var index = Math.Min(character, line.Length); + while (index > 0 && index == line.Length) + index--; + if (index < line.Length && !IsTokenChar(line[index]) && index > 0 && IsTokenChar(line[index - 1])) + index--; + if (index >= line.Length || !IsTokenChar(line[index])) + return null; + + var start = index; + while (start > 0 && IsTokenChar(line[start - 1])) + start--; + var end = index + 1; + while (end < line.Length && IsTokenChar(line[end])) + end++; + return line[start..end].TrimStart('@'); + } + + private static (int Start, int End) FindTokenRangeAtUtf16Position(string line, int character) + { + if (character < 0) + return (0, 0); + var index = Math.Min(character, line.Length); + while (index > 0 && index == line.Length) + index--; + if (index < line.Length && !IsTokenChar(line[index]) && index > 0 && IsTokenChar(line[index - 1])) + index--; + if (index >= line.Length || !IsTokenChar(line[index])) + return (Math.Max(0, Math.Min(character, line.Length)), Math.Max(0, Math.Min(character, line.Length))); + + var start = index; + while (start > 0 && IsTokenChar(line[start - 1])) + start--; + var end = index + 1; + while (end < line.Length && IsTokenChar(line[end])) + end++; + return (start, end); + } + + private static bool IsTokenChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '@'; + + private bool MatchesDocumentPath(string indexedPath, string documentPath, string? projectRelativePath, string resolvedPath, string? workspaceRoot) + { + if (TryResolveIndexedFilePath(indexedPath, null, out var indexedFullPath) + && string.Equals(resolvedPath, indexedFullPath, _pathStringComparison)) + return true; + + if (Path.IsPathRooted(indexedPath)) + return false; + + var normalizedIndexed = indexedPath.Replace('\\', '/'); + if (projectRelativePath != null) + return _projectRoot == null + && workspaceRoot != null + && string.Equals(normalizedIndexed, projectRelativePath.Replace('\\', '/'), _pathStringComparison); + + if (string.Equals(indexedPath, documentPath, StringComparison.Ordinal)) + return true; + + if (_projectRoot == null && workspaceRoot == null) + return false; + + var normalizedDocument = documentPath.Replace('\\', '/'); + return normalizedDocument.EndsWith("/" + normalizedIndexed, StringComparison.Ordinal); + } + + private string? ResolveIndexedPath(string documentPath) + { + if (!TryResolveDocumentPath(documentPath, out var resolvedPath, out var projectRelativePath, out var workspaceRoot)) + return null; + + return ResolveIndexedPath(documentPath, resolvedPath, projectRelativePath, workspaceRoot); + } + + private string? ResolveIndexedPath(string documentPath, string resolvedPath, string? projectRelativePath, string? workspaceRoot) + { + if (projectRelativePath != null) + { + var exactPath = projectRelativePath.Replace('\\', '/'); + var exactFile = _reader.GetFileByPath(exactPath); + if (exactFile != null && MatchesDocumentPath(exactFile.Path, documentPath, projectRelativePath, resolvedPath, workspaceRoot)) + return exactFile.Path; + } + + var fileName = Path.GetFileName(documentPath); + if (string.IsNullOrEmpty(fileName)) + fileName = Path.GetFileName(resolvedPath); + if (string.IsNullOrEmpty(fileName)) + return null; + + var files = _reader.ListFiles(fileName, MaxDocumentPathFallbackCandidates); + var matches = files + .Where(file => MatchesDocumentPath(file.Path, documentPath, projectRelativePath, resolvedPath, workspaceRoot)) + .Take(2) + .ToList(); + return matches.Count == 1 ? matches[0].Path : null; + } + + private bool TryResolveDocumentPath(string documentPath, out string resolvedPath, out string? projectRelativePath) => + TryResolveDocumentPath(documentPath, out resolvedPath, out projectRelativePath, out _, out _); + + private bool TryResolveDocumentPath( + string documentPath, + out string resolvedPath, + out string? projectRelativePath, + out string? workspaceRoot) => + TryResolveDocumentPath(documentPath, out resolvedPath, out projectRelativePath, out workspaceRoot, out _); + + private bool TryResolveDocumentPath( + string documentPath, + out string resolvedPath, + out string? projectRelativePath, + out string? workspaceRoot, + out string? failureReason) + { + resolvedPath = string.Empty; + projectRelativePath = null; + workspaceRoot = null; + failureReason = null; + try + { + resolvedPath = Path.IsPathRooted(documentPath) + ? Path.GetFullPath(documentPath) + : Path.GetFullPath(documentPath, _projectRoot ?? Environment.CurrentDirectory); + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + failureReason = FailureDocumentPathUnresolved; + return false; + } + + if (_workspaceFolders.Count == 0) + return true; + + if (TryGetWorkspaceRelativePath(resolvedPath, out projectRelativePath, out workspaceRoot)) + return true; + + failureReason = FailureOutsideProject; + return false; + } + + private bool TryResolveIndexedFilePath(string indexedPath, out string resolvedPath) + => TryResolveIndexedFilePath(indexedPath, null, out resolvedPath); + + private bool TryResolveIndexedFilePath(string indexedPath, string? workspaceRoot, out string resolvedPath) + { + resolvedPath = string.Empty; + try + { + resolvedPath = Path.IsPathRooted(indexedPath) + ? Path.GetFullPath(indexedPath) + : Path.GetFullPath(indexedPath, workspaceRoot ?? _projectRoot ?? Environment.CurrentDirectory); + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + return false; + } + } + + private bool TryGetWorkspaceRelativePath(string resolvedPath, out string? relativePath, out string? workspaceRoot) + { + relativePath = null; + workspaceRoot = null; + foreach (var candidateRoot in _workspaceFolders) + { + if (!TryGetRelativePath(candidateRoot, resolvedPath, out var candidateRelativePath)) + continue; + + relativePath = candidateRelativePath; + workspaceRoot = candidateRoot; + return true; + } + + return false; + } + + private static bool TryGetRelativePath(string root, string resolvedPath, out string? relativePath) + { + relativePath = null; + try + { + var normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + var normalizedPath = Path.GetFullPath(resolvedPath); + if (PathCasing.PathsEqual(normalizedRoot, normalizedPath) + || !PathCasing.IsPathEqualOrParent(normalizedRoot, normalizedPath)) + { + return false; + } + + var relative = Path.GetRelativePath(normalizedRoot, normalizedPath); + if (relative == "." + || relative == ".." + || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) + || Path.IsPathRooted(relative)) + { + return false; + } + + relativePath = relative; + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + return false; + } + } + +} diff --git a/src/CodeIndex/Lsp/LspServer.Protocol.cs b/src/CodeIndex/Lsp/LspServer.Protocol.cs new file mode 100644 index 000000000..7c00740b9 --- /dev/null +++ b/src/CodeIndex/Lsp/LspServer.Protocol.cs @@ -0,0 +1,371 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Mcp; +using CodeIndex.Models; +using CodeIndex.Security; + +namespace CodeIndex.Lsp; + +internal sealed partial class LspServer : IDisposable +{ + private JsonObject ToWorkspaceSymbol( + SymbolResult symbol, + (int Line, int StartColumn, int EndColumn) identifier) + { + return new JsonObject + { + ["name"] = symbol.Name, + ["kind"] = SymbolKind(symbol.Kind), + ["location"] = ToLocation(symbol.Path, identifier.Line, identifier.StartColumn, identifier.Line, identifier.EndColumn), + ["containerName"] = symbol.ContainerName, + }; + } + + private JsonObject ToDocumentSymbol( + IndexedDocumentContext document, + SymbolResult symbol, + Dictionary lineCache) + { + var identifier = GetSymbolIdentifierPosition(symbol, document.ResolvedPath, lineCache); + var rangeStartLine = symbol.StartLine > 0 ? Math.Min(symbol.StartLine, identifier.Line) : identifier.Line; + var rangeEndLine = symbol.EndLine > 0 ? Math.Max(symbol.EndLine, identifier.Line) : identifier.Line; + var rangeEndColumn = rangeEndLine == identifier.Line ? identifier.EndColumn : 1; + return new JsonObject + { + ["name"] = symbol.Name, + ["kind"] = SymbolKind(symbol.Kind), + ["range"] = ToRange(rangeStartLine, 1, rangeEndLine, rangeEndColumn), + ["selectionRange"] = ToRange(identifier.Line, identifier.StartColumn, identifier.Line, identifier.EndColumn), + ["detail"] = TruncateDocumentSymbolDetail(symbol.Signature), + }; + } + + private JsonObject ToDocumentSymbolInformation( + IndexedDocumentContext document, + SymbolResult symbol, + Dictionary lineCache) + { + var identifier = GetSymbolIdentifierPosition(symbol, document.ResolvedPath, lineCache); + return new JsonObject + { + ["name"] = symbol.Name, + ["kind"] = SymbolKind(symbol.Kind), + ["location"] = ToLocation( + symbol.Path, + identifier.Line, + identifier.StartColumn, + identifier.Line, + identifier.EndColumn, + document.WorkspaceRoot), + ["containerName"] = symbol.ContainerName, + }; + } + + private (int Line, int StartColumn, int EndColumn) GetSymbolIdentifierPosition(SymbolResult symbol) + { + var resolvedPath = TryResolveIndexedFilePath(symbol.Path, out var path) ? path : null; + return GetSymbolIdentifierPosition(symbol, resolvedPath); + } + + private (int Line, int StartColumn, int EndColumn) GetSymbolIdentifierPosition( + SymbolResult symbol, + PositionTokenContext context) + { + var indexedPathRoot = _projectRoot == null ? context.WorkspaceRoot : null; + var resolvedPath = TryResolveIndexedFilePath(symbol.Path, indexedPathRoot, out var path) ? path : null; + return GetSymbolIdentifierPosition(symbol, resolvedPath); + } + + private (int Line, int StartColumn, int EndColumn) GetSymbolIdentifierPosition( + SymbolResult symbol, + string? resolvedPath, + Dictionary? lineCache = null) + { + var line = symbol.Line > 0 ? symbol.Line : Math.Max(1, symbol.StartLine); + var startCharacter = resolvedPath == null + ? Math.Max(0, symbol.StartColumn ?? 0) + : FindSymbolStartCharacter(resolvedPath, symbol, lineCache); + return (line, startCharacter + 1, startCharacter + Math.Max(symbol.Name.Length, 1) + 1); + } + + private static string? TruncateDocumentSymbolDetail(string? detail) + { + if (detail == null || detail.Length <= MaxDocumentSymbolDetailChars) + return detail; + return detail[..(MaxDocumentSymbolDetailChars - "...".Length)] + "..."; + } + + private JsonObject ToLocation(string path, int startLine, int startColumn, int endLine, int endColumn, string? workspaceRoot = null) => new() + { + ["uri"] = PathToUri(path, workspaceRoot ?? _projectRoot), + ["range"] = ToRange(startLine, startColumn, endLine, endColumn), + }; + + private static JsonObject ToRange(int startLine, int startColumn, int endLine, int endColumn) => new() + { + ["start"] = new JsonObject + { + ["line"] = Math.Max(startLine - 1, 0), + ["character"] = Math.Max(startColumn - 1, 0), + }, + ["end"] = new JsonObject + { + ["line"] = Math.Max(endLine - 1, 0), + ["character"] = Math.Max(endColumn - 1, 0), + }, + }; + + private static JsonObject ToPosition(int line, int column) => new() + { + ["line"] = Math.Max(line - 1, 0), + ["character"] = Math.Max(column - 1, 0), + }; + + private static int SymbolKind(string kind) => kind switch + { + "class" => 5, + "function" or "test.method" => 12, + "property" => 7, + "enum" => 10, + "interface" => 11, + "namespace" => 3, + "struct" => 23, + _ => 13, + }; + + private static string GetDocumentPath(JsonElement root) + { + var uri = GetTextDocumentUri(root); + return UriToPath(uri); + } + + private static string GetTextDocumentUri(JsonElement root) + { + if (!TryGet(root, out var value, "params", "textDocument", "uri") || value.ValueKind != JsonValueKind.String) + throw new ArgumentException("textDocument.uri must be a string."); + + var uri = value.GetString(); + if (string.IsNullOrWhiteSpace(uri)) + throw new ArgumentException("textDocument.uri is required."); + if (uri.Length > MaxTextDocumentUriChars) + throw new ArgumentException( + $"textDocument.uri is too long. Max length is {MaxTextDocumentUriChars} characters; actual length is {uri.Length}."); + return uri; + } + + private static string? GetString(JsonElement root, params string[] path) + { + if (!TryGet(root, out var value, path) || value.ValueKind != JsonValueKind.String) + return null; + return value.GetString(); + } + + private static bool? GetBool(JsonElement root, params string[] path) + { + if (!TryGet(root, out var value, path)) + return null; + return value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null, + }; + } + + private static int? GetLimit(JsonElement root, int defaultLimit, int maxLimit, params string[] path) + { + if (!TryGet(root, out var value, path)) + return null; + if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var limit)) + return defaultLimit; + return Math.Clamp(limit, 0, maxLimit); + } + + private static int GetInt32(JsonElement root, params string[] path) + { + if (!TryGet(root, out var value, path) || value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var result)) + return -1; + return result; + } + + private static bool TryGet(JsonElement root, out JsonElement value, params string[] path) + { + value = root; + foreach (var segment in path) + { + if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(segment, out value)) + return false; + } + return true; + } + + internal static string PathToUri(string path, string? projectRoot = null) + => CodeIndex.FileUriPolicy.PathToFileUri(path, projectRoot); + + internal static string UriToPath(string uri) + => CodeIndex.FileUriPolicy.AbsoluteFileUriToPath(uri); + + private void CaptureInitializeWorkspaceFolders(JsonElement root) + { + if (!TryGet(root, out var folders, "params", "workspaceFolders") || folders.ValueKind != JsonValueKind.Array) + return; + + foreach (var folder in folders.EnumerateArray()) + { + if (_workspaceFolders.Count >= MaxWorkspaceFolders) + break; + if (TryGetWorkspaceFolderPath(folder, out var path) + && !_workspaceFolders.Any(existing => string.Equals(existing, path, _pathStringComparison))) + { + _workspaceFolders.Add(path); + } + } + + Activity.Current?.SetTag("lsp.workspace_folder_count", _workspaceFolders.Count); + } + + private static bool TryGetWorkspaceFolderPath(JsonElement folder, out string path) + { + path = string.Empty; + if (folder.ValueKind != JsonValueKind.Object + || !folder.TryGetProperty("uri", out var uriElement) + || uriElement.ValueKind != JsonValueKind.String) + { + return false; + } + + var uri = uriElement.GetString(); + if (string.IsNullOrWhiteSpace(uri) || uri.Length > MaxTextDocumentUriChars) + return false; + + try + { + path = Path.GetFullPath(UriToPath(uri)); + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + return false; + } + } + + private static JsonObject Result(JsonNode? id, JsonNode? result) => new() + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["result"] = result, + }; + + private static JsonObject Error(JsonNode? id, int code, string message) => new() + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["error"] = new JsonObject + { + ["code"] = code, + ["message"] = message, + }, + }; + + /// + /// Compatibility wrapper that reads without caller cancellation. Prefer + /// for cancellable transports. + /// caller cancellation を持たない互換 wrapper。キャンセル可能な transport では + /// を使う。 + /// + internal static bool TryReadMessage(Stream input, out string payload) => + TryReadMessage(input, out payload, CancellationToken.None); + + internal static bool TryReadMessage(Stream input, out string payload, CancellationToken cancellationToken) + => TryReadMessage(input, out payload, out _, cancellationToken); + + internal static bool TryReadMessage( + Stream input, + out string payload, + out LspMessageReadDiagnostic? diagnostic, + CancellationToken cancellationToken = default) + { + var success = LspProtocol.TryReadMessage(input, out payload, out var protocolDiagnostic, cancellationToken); + diagnostic = protocolDiagnostic.HasValue ? ToServerDiagnostic(protocolDiagnostic.Value) : null; + return success; + } + + internal static async ValueTask TryReadMessageAsync( + Stream input, + CancellationToken cancellationToken = default) + { + var result = await LspProtocol.TryReadMessageAsync(input, cancellationToken).ConfigureAwait(false); + return new MessageReadResult(result.Success, result.Payload); + } + + private static LspMessageReadDiagnostic ToServerDiagnostic(LspProtocol.ReadDiagnostic diagnostic) + => new(diagnostic.Code, diagnostic.Message, diagnostic.ContentLength, diagnostic.MaxContentLength); + + private async Task WriteResponseMessageAsync( + Stream output, + SemaphoreSlim outputGate, + JsonObject response, + CancellationToken cancellationToken) + { + await outputGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var payload = response.ToJsonString(_jsonOptions); + if (await LspProtocol.TryWriteMessageAsync(output, payload, cancellationToken).ConfigureAwait(false)) + return; + + var id = response["id"]?.DeepClone(); + var errorPayload = Error(id, JsonRpcInternalErrorCode, "Response too large").ToJsonString(_jsonOptions); + if (!await LspProtocol.TryWriteMessageAsync(output, errorPayload, cancellationToken).ConfigureAwait(false)) + throw new InvalidOperationException("LSP response error exceeded the response frame byte limit."); + } + finally + { + outputGate.Release(); + } + } + + private async Task WriteServerNotificationAsync( + Stream output, + SemaphoreSlim outputGate, + JsonObject notification, + CancellationToken cancellationToken) + { + await outputGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var payload = notification.ToJsonString(_jsonOptions); + if (!await LspProtocol.TryWriteMessageAsync(output, payload, cancellationToken).ConfigureAwait(false)) + throw new InvalidOperationException("LSP server notification exceeded the response frame byte limit."); + } + finally + { + outputGate.Release(); + } + } + + internal static void WriteMessage(Stream output, string payload) => + LspProtocol.WriteMessage(output, payload); + + internal static bool TryWriteMessage(Stream output, string payload, out int bodyBytes) => + LspProtocol.TryWriteMessage(output, payload, out bodyBytes); + + public void Dispose() + { + _ = _shutdownRequested; + if (_ownedQueryDb != null) + { + _reader.Dispose(); + _ownedQueryDb.Dispose(); + _ownedQueryDb = null; + } + } +} diff --git a/src/CodeIndex/Lsp/LspServer.SemanticTokens.cs b/src/CodeIndex/Lsp/LspServer.SemanticTokens.cs new file mode 100644 index 000000000..0c05a4df1 --- /dev/null +++ b/src/CodeIndex/Lsp/LspServer.SemanticTokens.cs @@ -0,0 +1,372 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Mcp; +using CodeIndex.Models; +using CodeIndex.Security; + +namespace CodeIndex.Lsp; + +internal sealed partial class LspServer : IDisposable +{ + private readonly record struct SemanticToken(int Line, int StartCharacter, int Length, int TokenType, int TokenModifiers); + + private static IEnumerable RemoveOverlappingSemanticTokens(IEnumerable candidates) + { + var selected = new List(); + foreach (var candidate in candidates) + { + if (selected.Any(existing => + existing.Line == candidate.Line && + existing.StartCharacter < candidate.StartCharacter + candidate.Length && + candidate.StartCharacter < existing.StartCharacter + existing.Length)) + { + continue; + } + + selected.Add(candidate); + if (selected.Count == MaxSemanticTokenItems) + break; + } + return selected; + } + + private static readonly HashSet CSharpModifiers = new(StringComparer.Ordinal) + { + "abstract", "async", "const", "extern", "file", "internal", "override", "partial", + "private", "protected", "public", "readonly", "required", "sealed", "static", "unsafe", "virtual", "volatile", + }; + + private static readonly HashSet CSharpKeywords = new(StringComparer.Ordinal) + { + "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", "class", "continue", + "decimal", "default", "delegate", "do", "double", "else", "enum", "event", "explicit", "false", "finally", + "fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int", "interface", "is", "lock", + "long", "namespace", "new", "null", "object", "operator", "out", "params", "record", "ref", "return", "sbyte", + "short", "sizeof", "stackalloc", "string", "struct", "switch", "this", "throw", "true", "try", "typeof", + "uint", "ulong", "unchecked", "using", "ushort", "void", "while", "with", "yield", + }; + + private IEnumerable BuildCSharpLexicalSemanticTokens( + IndexedDocumentContext document, + Dictionary lineCache) + { + var inBlockComment = false; + var stringMode = CSharpStringMode.None; + var rawQuoteCount = 0; + var ordinaryQuote = '\0'; + for (var line = 0; line < MaxSemanticTokenItems; line++) + { + if (!TryReadPositionLineCached(document.ResolvedPath, line, lineCache, out var sourceLine)) + yield break; + + for (var index = 0; index < sourceLine.Length;) + { + if (inBlockComment) + { + var end = sourceLine.IndexOf("*/", index, StringComparison.Ordinal); + if (end < 0) + break; + inBlockComment = false; + index = end + 2; + continue; + } + + if (stringMode == CSharpStringMode.Raw) + { + var end = FindRawStringEnd(sourceLine, index, rawQuoteCount); + if (end < 0) + break; + stringMode = CSharpStringMode.None; + index = end; + continue; + } + + if (stringMode == CSharpStringMode.Verbatim) + { + var end = sourceLine.IndexOf('"', index); + if (end < 0) + break; + if (end + 1 < sourceLine.Length && sourceLine[end + 1] == '"') + { + index = end + 2; + continue; + } + stringMode = CSharpStringMode.None; + index = end + 1; + continue; + } + + if (stringMode == CSharpStringMode.Ordinary) + { + if (sourceLine[index] == '\\') + { + index = Math.Min(index + 2, sourceLine.Length); + continue; + } + if (sourceLine[index++] == ordinaryQuote) + stringMode = CSharpStringMode.None; + continue; + } + + if (index + 1 < sourceLine.Length && sourceLine[index] == '/' && sourceLine[index + 1] == '/') + break; + if (index + 1 < sourceLine.Length && sourceLine[index] == '/' && sourceLine[index + 1] == '*') + { + inBlockComment = true; + index += 2; + continue; + } + var quoteCount = CountConsecutive(sourceLine, index, '"'); + if (quoteCount >= 3) + { + stringMode = CSharpStringMode.Raw; + rawQuoteCount = quoteCount; + index += quoteCount; + continue; + } + if (sourceLine[index] == '@' && index + 1 < sourceLine.Length && sourceLine[index + 1] == '"') + { + stringMode = CSharpStringMode.Verbatim; + index += 2; + continue; + } + if (sourceLine[index] == '@' && index + 2 < sourceLine.Length && sourceLine[index + 1] == '$' && sourceLine[index + 2] == '"') + { + stringMode = CSharpStringMode.Verbatim; + index += 3; + continue; + } + if (sourceLine[index] is '\'' or '"') + { + stringMode = CSharpStringMode.Ordinary; + ordinaryQuote = sourceLine[index]; + index++; + continue; + } + if (!IsCSharpIdentifierStart(sourceLine[index])) + { + index++; + continue; + } + + var start = index++; + while (index < sourceLine.Length && IsTokenChar(sourceLine[index])) + index++; + var word = sourceLine[start..index].TrimStart('@'); + if (CSharpModifiers.Contains(word)) + yield return new SemanticToken(line, start, index - start, 16, 0); + else if (CSharpKeywords.Contains(word)) + yield return new SemanticToken(line, start, index - start, 15, 0); + else if (IsCSharpNamespaceComponent(sourceLine, start)) + yield return new SemanticToken(line, start, index - start, 0, 0); + } + } + } + + private static bool IsCSharpIdentifierStart(char value) => char.IsLetter(value) || value is '_' or '@'; + + private enum CSharpStringMode + { + None, + Ordinary, + Verbatim, + Raw, + } + + private static int CountConsecutive(string text, int start, char value) + { + var index = start; + while (index < text.Length && text[index] == value) + index++; + return index - start; + } + + private static int FindRawStringEnd(string line, int start, int quoteCount) + { + for (var index = start; index < line.Length; index++) + { + if (line[index] == '"' && CountConsecutive(line, index, '"') >= quoteCount) + return index + quoteCount; + } + return -1; + } + + private static bool IsCSharpNamespaceComponent(string line, int start) + { + var trimmedStart = line.Length - line.AsSpan().TrimStart().Length; + var trimmedLine = line.AsSpan(trimmedStart); + var nameStart = trimmedLine.StartsWith("global using ", StringComparison.Ordinal) + ? trimmedStart + "global using ".Length + : trimmedLine.StartsWith("using ", StringComparison.Ordinal) + ? trimmedStart + "using ".Length + : trimmedLine.StartsWith("namespace ", StringComparison.Ordinal) + ? trimmedStart + "namespace ".Length + : -1; + if (nameStart < 0 || start < nameStart) + return false; + + var semicolon = line.IndexOf(';', nameStart); + var brace = line.IndexOf('{', nameStart); + var nameEnd = new[] { semicolon, brace }.Where(value => value >= 0).DefaultIfEmpty(line.Length).Min(); + var alias = line.IndexOf('=', nameStart, Math.Max(0, nameEnd - nameStart)); + var qualifiedNameStart = alias >= 0 ? alias + 1 : nameStart; + return start >= qualifiedNameStart && start < nameEnd; + } + + private SemanticToken? BuildSemanticToken(IndexedDocumentContext document, SymbolResult symbol, Dictionary lineCache) + { + var line = Math.Max(symbol.Line, symbol.StartLine); + if (line <= 0) + return null; + + var startCharacter = FindSymbolStartCharacter(document.ResolvedPath, symbol, lineCache); + var length = Math.Max(symbol.Name.Length, 1); + return new SemanticToken( + line - 1, + startCharacter, + length, + SemanticTokenType(symbol.Kind), + 1 << 0); + } + + private int FindSymbolStartCharacter(string resolvedPath, SymbolResult symbol, Dictionary? lineCache = null) + { + var indexedStart = Math.Max(0, symbol.StartColumn ?? 0); + var line = Math.Max(symbol.Line, symbol.StartLine); + if (line <= 0 || string.IsNullOrWhiteSpace(symbol.Name)) + return indexedStart; + + if (!TryReadPositionLineCached(resolvedPath, line - 1, lineCache, out var sourceLine)) + return indexedStart; + + var searchStart = Math.Min( + ResolveDeclarationIdentifierAnchor(sourceLine, symbol, indexedStart), + sourceLine.Length); + var sourceStart = FindIdentifierOccurrence(sourceLine, symbol.Name, searchStart); + if (sourceStart >= 0) + return sourceStart; + + sourceStart = FindIdentifierOccurrence(sourceLine, symbol.Name, 0); + return sourceStart >= 0 ? sourceStart : indexedStart; + } + + private static int ResolveDeclarationIdentifierAnchor(string sourceLine, SymbolResult symbol, int indexedStart) + { + if (string.IsNullOrWhiteSpace(symbol.Signature)) + return indexedStart; + + var firstLineEnd = symbol.Signature.IndexOfAny(['\r', '\n']); + var signatureLine = firstLineEnd >= 0 ? symbol.Signature[..firstLineEnd] : symbol.Signature; + var firstName = signatureLine.IndexOf(symbol.Name, StringComparison.Ordinal); + var declarationName = FindDeclarationIdentifierOffset(signatureLine, symbol.Kind, symbol.Name); + if (firstName < 0 || declarationName < firstName) + return indexedStart; + + var adjusted = (long)indexedStart + declarationName - firstName; + if (adjusted < 0 || adjusted > sourceLine.Length) + return indexedStart; + + var candidate = (int)adjusted; + return IsIdentifierOccurrenceAt(sourceLine, symbol.Name, candidate) ? candidate : indexedStart; + } + + private static int FindDeclarationIdentifierOffset(string signatureLine, string kind, string name) + { + var headerEnd = FindDeclarationHeaderEnd(signatureLine, kind); + var result = -1; + var searchStart = 0; + while (searchStart <= headerEnd) + { + var candidate = FindIdentifierOccurrence(signatureLine, name, searchStart); + if (candidate < 0 || candidate + name.Length > headerEnd) + break; + result = candidate; + searchStart = candidate + 1; + } + + return result; + } + + private static int FindDeclarationHeaderEnd(string signatureLine, string kind) + { + ReadOnlySpan delimiters = kind switch + { + "function" or "test.method" => "(", + "class" or "struct" or "interface" or "enum" or "namespace" => ":{(;", + _ => "{=;", + }; + var end = signatureLine.Length; + foreach (var delimiter in delimiters) + { + var candidate = signatureLine.IndexOf(delimiter); + if (candidate >= 0) + end = Math.Min(end, candidate); + } + + return end; + } + + private static int FindIdentifierOccurrence(string sourceLine, string name, int startIndex) + { + var candidate = sourceLine.IndexOf(name, startIndex, StringComparison.Ordinal); + while (candidate >= 0) + { + var hasStartBoundary = candidate == 0 || !IsIdentifierContinuation(sourceLine[candidate - 1]); + var end = candidate + name.Length; + var hasEndBoundary = end == sourceLine.Length || !IsIdentifierContinuation(sourceLine[end]); + if (hasStartBoundary && hasEndBoundary) + return candidate; + + candidate = sourceLine.IndexOf(name, candidate + 1, StringComparison.Ordinal); + } + + return -1; + } + + private static bool IsIdentifierOccurrenceAt(string sourceLine, string name, int start) + { + if (start < 0 || start + name.Length > sourceLine.Length || + !sourceLine.AsSpan(start, name.Length).SequenceEqual(name.AsSpan())) + { + return false; + } + + var hasStartBoundary = start == 0 || !IsIdentifierContinuation(sourceLine[start - 1]); + var end = start + name.Length; + var hasEndBoundary = end == sourceLine.Length || !IsIdentifierContinuation(sourceLine[end]); + return hasStartBoundary && hasEndBoundary; + } + + private static bool IsIdentifierContinuation(char value) + { + var category = char.GetUnicodeCategory(value); + return char.IsLetterOrDigit(value) || + value is '_' or '$' || + category is UnicodeCategory.NonSpacingMark or + UnicodeCategory.SpacingCombiningMark or + UnicodeCategory.ConnectorPunctuation or + UnicodeCategory.Format; + } + + private static int SemanticTokenType(string kind) => kind switch + { + "namespace" => 0, + "class" => 2, + "enum" => 3, + "interface" => 4, + "struct" => 5, + "property" => 9, + "field" => 23, + "function" or "test.method" => 13, + _ => 8, + }; + +} diff --git a/src/CodeIndex/Lsp/LspServer.SymbolLocations.cs b/src/CodeIndex/Lsp/LspServer.SymbolLocations.cs new file mode 100644 index 000000000..24379a0a2 --- /dev/null +++ b/src/CodeIndex/Lsp/LspServer.SymbolLocations.cs @@ -0,0 +1,257 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Mcp; +using CodeIndex.Models; +using CodeIndex.Security; + +namespace CodeIndex.Lsp; + +internal sealed partial class LspServer : IDisposable +{ + private JsonObject ToSymbolLocation(SymbolResult symbol, PositionTokenContext context) + { + var identifier = GetSymbolIdentifierPosition(symbol, context); + return ToLocation( + symbol.Path, + identifier.Line, + identifier.StartColumn, + identifier.Line, + identifier.EndColumn, + GetLocationWorkspaceRoot(symbol.Path, context)); + } + + private void AddSymbolLocation(JsonArray array, HashSet seenLocations, SymbolResult symbol, PositionTokenContext context) + { + var identifier = GetSymbolIdentifierPosition(symbol, context); + AddLocation(array, seenLocations, symbol.Path, identifier.Line, identifier.StartColumn, identifier.Line, identifier.EndColumn, context); + } + + private void AddLocation( + JsonArray array, + HashSet seenLocations, + string path, + int startLine, + int startColumn, + int endLine, + int endColumn, + PositionTokenContext context) + { + var workspaceRoot = GetLocationWorkspaceRoot(path, context); + var key = string.Join('\0', PathToUri(path, workspaceRoot ?? _projectRoot), startLine, startColumn, endLine, endColumn); + if (seenLocations.Add(key)) + array.Add((JsonNode)ToLocation(path, startLine, startColumn, endLine, endColumn, workspaceRoot)); + } + + private string? GetLocationWorkspaceRoot(string path, PositionTokenContext context) + { + if (Path.IsPathRooted(path)) + return null; + return _projectRoot ?? context.WorkspaceRoot; + } + + private static void RecordLookupFailure(string method, string? failureReason) + { + if (string.IsNullOrEmpty(failureReason)) + return; + + Activity.Current?.AddEvent(new ActivityEvent( + LspLookupFailureEventName, + tags: new ActivityTagsCollection + { + [LspMethodTag] = method, + [LspLookupFailureReasonTag] = failureReason, + })); + } + + private DocumentSymbolNode? FindDocumentSymbolParent(IReadOnlyList nodes, int symbolIndex) + { + var symbolNode = nodes[symbolIndex]; + var symbol = symbolNode.Symbol; + DocumentSymbolNode? parent = null; + int? nearestSameLineStart = null; + for (var i = nodes.Count - 1; i >= 0; i--) + { + if (i == symbolIndex) + continue; + + var candidate = nodes[i].Symbol; + if (!ContainsDocumentSymbol(candidate, symbol)) + continue; + if (symbol.ContainerName != null + && !string.Equals(candidate.Name, symbol.ContainerName, StringComparison.Ordinal)) + { + continue; + } + if (symbol.ContainerKind != null + && !string.Equals(candidate.Kind, symbol.ContainerKind, StringComparison.Ordinal)) + { + continue; + } + + ConsiderDocumentSymbolParent(nodes[i], symbolNode, ref parent, ref nearestSameLineStart); + } + + if (parent != null) + return parent; + if (symbol.ContainerName != null) + return null; + + for (var i = nodes.Count - 1; i >= 0; i--) + { + if (i == symbolIndex) + continue; + + var candidate = nodes[i].Symbol; + if (ContainsDocumentSymbol(candidate, symbol)) + ConsiderDocumentSymbolParent(nodes[i], symbolNode, ref parent, ref nearestSameLineStart); + } + + return parent; + } + + private static void ConsiderDocumentSymbolParent( + DocumentSymbolNode candidate, + DocumentSymbolNode symbol, + ref DocumentSymbolNode? parent, + ref int? nearestSameLineStart) + { + if (candidate.Symbol.StartLine == symbol.Symbol.StartLine) + { + var candidateStart = candidate.Item["selectionRange"]?["start"]?["character"]?.GetValue(); + var symbolStart = symbol.Item["selectionRange"]?["start"]?["character"]?.GetValue(); + if (candidateStart.HasValue && symbolStart.HasValue) + { + if (candidateStart.Value > symbolStart.Value) + return; + if (!nearestSameLineStart.HasValue || candidateStart.Value > nearestSameLineStart.Value) + { + parent = candidate; + nearestSameLineStart = candidateStart.Value; + } + return; + } + } + + if (!nearestSameLineStart.HasValue && parent == null) + parent = candidate; + } + + private static bool ContainsDocumentSymbol(SymbolResult candidate, SymbolResult symbol) => + candidate.StartLine <= symbol.StartLine + && candidate.EndLine >= symbol.EndLine + && (candidate.StartLine < symbol.StartLine + || candidate.EndLine > symbol.EndLine + || (symbol.ContainerName != null + && symbol.ContainerKind != null + && string.Equals(candidate.Name, symbol.ContainerName, StringComparison.Ordinal) + && string.Equals(candidate.Kind, symbol.ContainerKind, StringComparison.Ordinal))); + + private static void AddDocumentSymbolChild(JsonObject parent, JsonObject child) + { + if (parent["children"] is not JsonArray children) + { + children = []; + parent["children"] = children; + } + + children.Add((JsonNode)child); + } + + private int TrimDocumentSymbolsToBudget(JsonArray roots) + { + var removedCount = 0; + var responseBudget = DocumentSymbolResponseBytesForTesting ?? MaxDocumentSymbolResponseBytes; + var responseBytes = MeasureJsonUtf8Bytes(roots); + while (roots.Count > 0 && responseBytes > responseBudget) + { + if (!RemoveLastDocumentSymbol(roots, out var removedBytes)) + break; + removedCount++; + + if (_jsonOptions.WriteIndented) + responseBytes = MeasureJsonUtf8Bytes(roots); + else + responseBytes = removedBytes > 0 + ? Math.Max(0, responseBytes - removedBytes) + : MeasureJsonUtf8Bytes(roots); + } + + return removedCount; + } + + private bool RemoveLastDocumentSymbol(JsonArray symbols, out int removedBytes) + { + removedBytes = 0; + if (symbols.Count == 0) + return false; + + if (symbols[symbols.Count - 1] is JsonObject last + && last["children"] is JsonArray children + && children.Count > 0) + { + var beforeBytes = MeasureJsonUtf8Bytes(last); + if (RemoveLastDocumentSymbol(children, out _)) + { + if (children.Count == 0) + last.Remove("children"); + removedBytes = Math.Max(0, beforeBytes - MeasureJsonUtf8Bytes(last)); + return true; + } + } + + removedBytes = MeasureJsonUtf8Bytes(symbols[symbols.Count - 1]) + + (symbols.Count > 1 ? 1 : 0); + symbols.RemoveAt(symbols.Count - 1); + return true; + } + + private int MeasureJsonUtf8Bytes(JsonNode? node) + { + if (node == null) + return "null"u8.Length; + if (_jsonOptions.WriteIndented) + return Encoding.UTF8.GetByteCount(node.ToJsonString(_jsonOptions)); + + if (node is JsonArray array) + { + var bytes = "[]"u8.Length; + for (var i = 0; i < array.Count; i++) + { + if (i > 0) + bytes++; + bytes += MeasureJsonUtf8Bytes(array[i]); + } + return bytes; + } + + if (node is JsonObject obj) + { + var bytes = "{}"u8.Length; + var propertyIndex = 0; + foreach (var property in obj) + { + if (propertyIndex > 0) + bytes++; + bytes += MeasureJsonStringUtf8Bytes(property.Key); + bytes++; + bytes += MeasureJsonUtf8Bytes(property.Value); + propertyIndex++; + } + return bytes; + } + + return Encoding.UTF8.GetByteCount(node.ToJsonString(_jsonOptions)); + } + + private int MeasureJsonStringUtf8Bytes(string value) => + Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(value, _jsonOptions)); + +} diff --git a/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs b/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs new file mode 100644 index 000000000..0aa8e57ca --- /dev/null +++ b/src/CodeIndex/Lsp/LspServer.SymbolRequests.cs @@ -0,0 +1,536 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Mcp; +using CodeIndex.Models; +using CodeIndex.Security; + +namespace CodeIndex.Lsp; + +internal sealed partial class LspServer : IDisposable +{ + private JsonObject BuildInitializeResult() => new() + { + ["capabilities"] = new JsonObject + { + ["definitionProvider"] = true, + ["declarationProvider"] = true, + ["referencesProvider"] = true, + ["documentSymbolProvider"] = new JsonObject + { + ["workDoneProgress"] = true, + }, + ["workspaceSymbolProvider"] = new JsonObject + { + ["workDoneProgress"] = true, + }, + ["hoverProvider"] = true, + ["completionProvider"] = new JsonObject + { + ["resolveProvider"] = false, + ["triggerCharacters"] = new JsonArray(".", ":", "_"), + }, + ["documentHighlightProvider"] = true, + ["semanticTokensProvider"] = new JsonObject + { + ["legend"] = new JsonObject + { + ["tokenTypes"] = ToJsonStringArray(SemanticTokenTypes), + ["tokenModifiers"] = ToJsonStringArray(SemanticTokenModifiers), + }, + ["full"] = true, + ["range"] = false, + }, + ["inlayHintProvider"] = new JsonObject + { + ["resolveProvider"] = false, + }, + ["textDocumentSync"] = new JsonObject + { + ["openClose"] = true, + ["change"] = 1, + }, + ["workspace"] = new JsonObject + { + ["workspaceFolders"] = new JsonObject + { + ["supported"] = true, + ["changeNotifications"] = true, + }, + }, + }, + ["serverInfo"] = new JsonObject + { + ["name"] = "cdidx", + ["version"] = _version, + }, + }; + + private static JsonArray ToJsonStringArray(IEnumerable values) + { + var array = new JsonArray(); + foreach (var value in values) + array.Add(value); + return array; + } + + private JsonArray WorkspaceSymbol(JsonElement root) => + CreateWorkspaceSymbolResponse( + root, + createPartialItems: false, + CancellationToken.None).FinalItems; + + private SymbolResponse CreateWorkspaceSymbolResponse( + JsonElement root, + bool createPartialItems, + CancellationToken cancellationToken) + { + var query = GetString(root, "params", "query"); + if (query != null && query.Length > QueryLimits.MaxQueryLength) + throw new ArgumentException(QueryLimits.FormatQueryTooLongError()); + + var limit = GetLimit(root, DefaultLimit, MaxWorkspaceSymbols, "params", "limit") + ?? GetLimit(root, DefaultLimit, MaxWorkspaceSymbols, "params", "maxResults") + ?? DefaultLimit; + IReadOnlyList candidates = limit == 0 + ? [] + : _reader.SearchSymbols(query, checked(limit + 1)); + var truncated = candidates.Count > limit; + var symbols = candidates.Take(limit).ToList(); + if (createPartialItems) + { + return new SymbolResponse( + [], + EnumerateWorkspaceSymbolItems(symbols, cancellationToken), + symbols.Count, + truncated); + } + + var identifiers = new (int Line, int StartColumn, int EndColumn)[symbols.Count]; + var pathComparer = _pathStringComparison == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + foreach (var pathGroup in symbols + .Select((symbol, index) => (Symbol: symbol, Index: index)) + .GroupBy(item => item.Symbol.Path, pathComparer)) + { + var resolvedPath = TryResolveIndexedFilePath(pathGroup.Key, out var path) ? path : null; + var lineCache = new Dictionary(); + foreach (var item in pathGroup) + { + cancellationToken.ThrowIfCancellationRequested(); + identifiers[item.Index] = GetSymbolIdentifierPosition(item.Symbol, resolvedPath, lineCache); + } + } + + var array = new JsonArray(); + for (var index = 0; index < symbols.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + array.Add((JsonNode)ToWorkspaceSymbol(symbols[index], identifiers[index])); + } + + return new SymbolResponse(array, [], array.Count, truncated); + } + + private IEnumerable EnumerateWorkspaceSymbolItems( + IReadOnlyList symbols, + CancellationToken cancellationToken) + { + var pathComparer = _pathStringComparison == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var pathContexts = new Dictionary< + string, + (string? ResolvedPath, Dictionary LineCache)>(pathComparer); + foreach (var symbol in symbols) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!pathContexts.TryGetValue(symbol.Path, out var context)) + { + context = ( + TryResolveIndexedFilePath(symbol.Path, out var path) ? path : null, + new Dictionary()); + pathContexts.Add(symbol.Path, context); + } + + var identifier = GetSymbolIdentifierPosition( + symbol, + context.ResolvedPath, + context.LineCache); + yield return ToWorkspaceSymbol(symbol, identifier); + } + } + + private JsonArray DocumentSymbol(JsonElement root) => + CreateDocumentSymbolResponse( + root, + createPartialItems: false, + CancellationToken.None).FinalItems; + + private SymbolResponse CreateDocumentSymbolResponse( + JsonElement root, + bool createPartialItems, + CancellationToken cancellationToken) + { + if (!TryResolveIndexedDocument(root, out var document)) + return new SymbolResponse([], [], 0, false); + + var candidates = _reader.SearchSymbols((string?)null, MaxDocumentSymbolMaterialization + 1, pathPatterns: [document.IndexedPath]); + var materializationTruncated = candidates.Count > MaxDocumentSymbolMaterialization; + var materializedCount = Math.Min(candidates.Count, MaxDocumentSymbolMaterialization); + Activity.Current?.SetTag("lsp.document_symbols.materialized_count", materializedCount); + Activity.Current?.SetTag("lsp.document_symbols.materialization_truncated", materializationTruncated); + + var symbols = candidates + .Take(MaxDocumentSymbolMaterialization) + .OrderBy(s => s.StartLine) + .ThenByDescending(s => s.EndLine) + .ThenBy(s => s.ContainerName == null ? 0 : 1) + .ThenBy(s => s.Name, StringComparer.Ordinal) + .ToList(); + + if (createPartialItems) + { + Activity.Current?.SetTag("lsp.document_symbols.returned_root_count", 0); + Activity.Current?.SetTag("lsp.document_symbols.returned_partial_count", symbols.Count); + return new SymbolResponse( + [], + EnumerateDocumentSymbolItems(document, symbols, cancellationToken), + symbols.Count, + materializationTruncated); + } + + var tree = BuildDocumentSymbolTree(document, symbols, cancellationToken); + Activity.Current?.SetTag("lsp.document_symbols.returned_root_count", tree.Roots.Count); + return new SymbolResponse( + tree.Roots, + [], + Math.Max(0, symbols.Count - tree.RemovedCount), + materializationTruncated || tree.RemovedCount > 0); + } + + private IEnumerable EnumerateDocumentSymbolItems( + IndexedDocumentContext document, + IReadOnlyList symbols, + CancellationToken cancellationToken) + { + var lineCache = new Dictionary(); + foreach (var symbol in symbols) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return ToDocumentSymbolInformation(document, symbol, lineCache); + } + } + + private DocumentSymbolTreeResult BuildDocumentSymbolTree( + IndexedDocumentContext document, + IReadOnlyList symbols, + CancellationToken cancellationToken) + { + var roots = new JsonArray(); + var nodes = new List(symbols.Count); + var lineCache = new Dictionary(); + foreach (var symbol in symbols) + { + cancellationToken.ThrowIfCancellationRequested(); + var item = ToDocumentSymbol(document, symbol, lineCache); + nodes.Add(new DocumentSymbolNode(symbol, item)); + } + + for (var nodeIndex = 0; nodeIndex < nodes.Count; nodeIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + var node = nodes[nodeIndex]; + var parent = FindDocumentSymbolParent(nodes, nodeIndex); + if (parent == null) + roots.Add((JsonNode)node.Item); + else + AddDocumentSymbolChild(parent.Value.Item, node.Item); + } + + return new DocumentSymbolTreeResult(roots, TrimDocumentSymbolsToBudget(roots)); + } + + private JsonObject HandleSymbolRequest( + JsonNode? id, + JsonElement root, + bool documentSymbols, + Action? outbound, + CancellationToken cancellationToken) + { + var partialResultToken = GetProgressToken(root, "partialResultToken"); + var workDoneToken = GetProgressToken(root, "workDoneToken"); + if (outbound == null) + return Result(id, documentSymbols ? DocumentSymbol(root) : WorkspaceSymbol(root)); + + var title = documentSymbols ? "CodeIndex document symbols" : "CodeIndex workspace symbols"; + if (workDoneToken != null) + outbound(CreateProgressNotification(workDoneToken, CreateWorkDoneBegin(title))); + + var emittedCount = 0; + try + { + cancellationToken.ThrowIfCancellationRequested(); + BeforeSymbolRequestForTesting?.Invoke(cancellationToken); + var response = documentSymbols + ? CreateDocumentSymbolResponse( + root, + createPartialItems: partialResultToken != null, + cancellationToken) + : CreateWorkspaceSymbolResponse( + root, + createPartialItems: partialResultToken != null, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + var truncated = response.Truncated; + JsonNode? finalResult = response.FinalItems; + if (partialResultToken != null) + { + var emission = EmitPartialResultChunks( + outbound, + partialResultToken, + workDoneToken, + response.PartialItems, + response.ReturnedCount, + cancellationToken); + emittedCount = emission.EmittedCount; + truncated |= emission.Truncated; + if (emission.Cancelled) + { + return CompleteCancelledSymbolRequest( + id, + outbound, + workDoneToken, + emittedCount); + } + + finalResult = null; + } + else + { + emittedCount = response.ReturnedCount; + if (workDoneToken != null) + { + outbound(CreateProgressNotification( + workDoneToken, + CreateWorkDoneReport(100, $"Prepared {emittedCount} symbols."))); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + var summary = CreateSymbolProgressSummary(emittedCount, truncated); + if (workDoneToken != null) + outbound(CreateProgressNotification(workDoneToken, CreateWorkDoneEnd(summary))); + else if (truncated) + outbound(CreateLogMessage(summary)); + + return Result(id, finalResult); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return CompleteCancelledSymbolRequest( + id, + outbound, + workDoneToken, + emittedCount); + } + catch + { + if (workDoneToken != null) + { + outbound(CreateProgressNotification( + workDoneToken, + CreateWorkDoneEnd("Symbol request failed."))); + } + + throw; + } + } + + private PartialResultEmission EmitPartialResultChunks( + Action outbound, + JsonNode partialResultToken, + JsonNode? workDoneToken, + IEnumerable items, + int totalCount, + CancellationToken cancellationToken) + { + var emittedCount = 0; + var chunk = new JsonArray(); + try + { + foreach (var item in items) + { + if (cancellationToken.IsCancellationRequested) + return new PartialResultEmission(emittedCount, false, true); + + chunk.Add(item); + var measuredNotification = CreateProgressNotification( + partialResultToken, + chunk.DeepClone()); + var exceedsChunkBudget = chunk.Count > MaxSymbolProgressChunkItems + || MeasureJsonUtf8Bytes(measuredNotification) > MaxSymbolProgressChunkBytes; + if (exceedsChunkBudget) + { + chunk.RemoveAt(chunk.Count - 1); + if (chunk.Count > 0) + { + EmitPartialResultChunk( + outbound, + partialResultToken, + workDoneToken, + chunk, + ref emittedCount, + totalCount); + } + + chunk = []; + chunk.Add(item); + measuredNotification = CreateProgressNotification( + partialResultToken, + chunk.DeepClone()); + if (MeasureJsonUtf8Bytes(measuredNotification) > MaxSymbolProgressChunkBytes) + return new PartialResultEmission(emittedCount, true, false); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return new PartialResultEmission(emittedCount, false, true); + } + + if (chunk.Count > 0) + { + EmitPartialResultChunk( + outbound, + partialResultToken, + workDoneToken, + chunk, + ref emittedCount, + totalCount); + } + else if (totalCount == 0 && workDoneToken != null) + { + outbound(CreateProgressNotification( + workDoneToken, + CreateWorkDoneReport(100, "Prepared 0 symbols."))); + } + + return new PartialResultEmission(emittedCount, false, cancellationToken.IsCancellationRequested); + } + + private static void EmitPartialResultChunk( + Action outbound, + JsonNode partialResultToken, + JsonNode? workDoneToken, + JsonArray chunk, + ref int emittedCount, + int totalCount) + { + var chunkCount = chunk.Count; + outbound(CreateProgressNotification(partialResultToken, chunk)); + emittedCount += chunkCount; + if (workDoneToken == null) + return; + + var percentage = totalCount == 0 + ? 100 + : Math.Clamp((int)((long)emittedCount * 100 / totalCount), 0, 100); + outbound(CreateProgressNotification( + workDoneToken, + CreateWorkDoneReport(percentage, $"Streamed {emittedCount} symbols."))); + } + + private static JsonObject CompleteCancelledSymbolRequest( + JsonNode? id, + Action outbound, + JsonNode? workDoneToken, + int emittedCount) + { + if (workDoneToken != null) + { + outbound(CreateProgressNotification( + workDoneToken, + CreateWorkDoneEnd($"Cancelled after {emittedCount} symbols."))); + } + + return Error(id, JsonRpcRequestCancelledCode, JsonRpcRequestCancelledMessage); + } + + private static JsonNode? GetProgressToken(JsonElement root, string propertyName) + { + if (!TryGet(root, out var token, "params", propertyName)) + return null; + + if (token.ValueKind == JsonValueKind.String) + { + var value = token.GetString() ?? string.Empty; + if (value.Length <= MaxRequestIdStringChars) + return JsonValue.Create(value); + } + else if (token.ValueKind == JsonValueKind.Number && token.TryGetInt64(out var integer)) + { + return JsonValue.Create(integer); + } + + throw new ArgumentException($"{propertyName} must be a bounded string or integer."); + } + + private static JsonObject CreateProgressNotification(JsonNode token, JsonNode? value) => new() + { + ["jsonrpc"] = "2.0", + ["method"] = "$/progress", + ["params"] = new JsonObject + { + ["token"] = token.DeepClone(), + ["value"] = value, + }, + }; + + private static JsonObject CreateWorkDoneBegin(string title) => new() + { + ["kind"] = "begin", + ["title"] = title, + ["cancellable"] = true, + ["percentage"] = 0, + }; + + private static JsonObject CreateWorkDoneReport(int percentage, string message) => new() + { + ["kind"] = "report", + ["percentage"] = percentage, + ["message"] = message, + }; + + private static JsonObject CreateWorkDoneEnd(string message) => new() + { + ["kind"] = "end", + ["message"] = message, + }; + + private static JsonObject CreateLogMessage(string message) => new() + { + ["jsonrpc"] = "2.0", + ["method"] = "window/logMessage", + ["params"] = new JsonObject + { + ["type"] = 2, + ["message"] = message, + }, + }; + + private static string CreateSymbolProgressSummary(int returnedCount, bool truncated) => + truncated + ? $"Returned {returnedCount} symbols; truncated at a configured result or progress-frame limit." + : $"Returned {returnedCount} symbols."; + +} diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index ccf79b971..f6954264c 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -14,7 +14,7 @@ namespace CodeIndex.Lsp; -internal sealed class LspServer : IDisposable +internal sealed partial class LspServer : IDisposable { private const int DefaultLimit = 50; internal const int MaxWorkspaceSymbols = 1000; @@ -848,2706 +848,4 @@ private bool TryGetLiveDocumentKeyFromUri(string uri, out string key) return activity; } - private JsonObject BuildInitializeResult() => new() - { - ["capabilities"] = new JsonObject - { - ["definitionProvider"] = true, - ["declarationProvider"] = true, - ["referencesProvider"] = true, - ["documentSymbolProvider"] = new JsonObject - { - ["workDoneProgress"] = true, - }, - ["workspaceSymbolProvider"] = new JsonObject - { - ["workDoneProgress"] = true, - }, - ["hoverProvider"] = true, - ["completionProvider"] = new JsonObject - { - ["resolveProvider"] = false, - ["triggerCharacters"] = new JsonArray(".", ":", "_"), - }, - ["documentHighlightProvider"] = true, - ["semanticTokensProvider"] = new JsonObject - { - ["legend"] = new JsonObject - { - ["tokenTypes"] = ToJsonStringArray(SemanticTokenTypes), - ["tokenModifiers"] = ToJsonStringArray(SemanticTokenModifiers), - }, - ["full"] = true, - ["range"] = false, - }, - ["inlayHintProvider"] = new JsonObject - { - ["resolveProvider"] = false, - }, - ["textDocumentSync"] = new JsonObject - { - ["openClose"] = true, - ["change"] = 1, - }, - ["workspace"] = new JsonObject - { - ["workspaceFolders"] = new JsonObject - { - ["supported"] = true, - ["changeNotifications"] = true, - }, - }, - }, - ["serverInfo"] = new JsonObject - { - ["name"] = "cdidx", - ["version"] = _version, - }, - }; - - private static JsonArray ToJsonStringArray(IEnumerable values) - { - var array = new JsonArray(); - foreach (var value in values) - array.Add(value); - return array; - } - - private JsonArray WorkspaceSymbol(JsonElement root) => - CreateWorkspaceSymbolResponse( - root, - createPartialItems: false, - CancellationToken.None).FinalItems; - - private SymbolResponse CreateWorkspaceSymbolResponse( - JsonElement root, - bool createPartialItems, - CancellationToken cancellationToken) - { - var query = GetString(root, "params", "query"); - if (query != null && query.Length > QueryLimits.MaxQueryLength) - throw new ArgumentException(QueryLimits.FormatQueryTooLongError()); - - var limit = GetLimit(root, DefaultLimit, MaxWorkspaceSymbols, "params", "limit") - ?? GetLimit(root, DefaultLimit, MaxWorkspaceSymbols, "params", "maxResults") - ?? DefaultLimit; - IReadOnlyList candidates = limit == 0 - ? [] - : _reader.SearchSymbols(query, checked(limit + 1)); - var truncated = candidates.Count > limit; - var symbols = candidates.Take(limit).ToList(); - if (createPartialItems) - { - return new SymbolResponse( - [], - EnumerateWorkspaceSymbolItems(symbols, cancellationToken), - symbols.Count, - truncated); - } - - var identifiers = new (int Line, int StartColumn, int EndColumn)[symbols.Count]; - var pathComparer = _pathStringComparison == StringComparison.OrdinalIgnoreCase - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - foreach (var pathGroup in symbols - .Select((symbol, index) => (Symbol: symbol, Index: index)) - .GroupBy(item => item.Symbol.Path, pathComparer)) - { - var resolvedPath = TryResolveIndexedFilePath(pathGroup.Key, out var path) ? path : null; - var lineCache = new Dictionary(); - foreach (var item in pathGroup) - { - cancellationToken.ThrowIfCancellationRequested(); - identifiers[item.Index] = GetSymbolIdentifierPosition(item.Symbol, resolvedPath, lineCache); - } - } - - var array = new JsonArray(); - for (var index = 0; index < symbols.Count; index++) - { - cancellationToken.ThrowIfCancellationRequested(); - array.Add((JsonNode)ToWorkspaceSymbol(symbols[index], identifiers[index])); - } - - return new SymbolResponse(array, [], array.Count, truncated); - } - - private IEnumerable EnumerateWorkspaceSymbolItems( - IReadOnlyList symbols, - CancellationToken cancellationToken) - { - var pathComparer = _pathStringComparison == StringComparison.OrdinalIgnoreCase - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - var pathContexts = new Dictionary< - string, - (string? ResolvedPath, Dictionary LineCache)>(pathComparer); - foreach (var symbol in symbols) - { - cancellationToken.ThrowIfCancellationRequested(); - if (!pathContexts.TryGetValue(symbol.Path, out var context)) - { - context = ( - TryResolveIndexedFilePath(symbol.Path, out var path) ? path : null, - new Dictionary()); - pathContexts.Add(symbol.Path, context); - } - - var identifier = GetSymbolIdentifierPosition( - symbol, - context.ResolvedPath, - context.LineCache); - yield return ToWorkspaceSymbol(symbol, identifier); - } - } - - private JsonArray DocumentSymbol(JsonElement root) => - CreateDocumentSymbolResponse( - root, - createPartialItems: false, - CancellationToken.None).FinalItems; - - private SymbolResponse CreateDocumentSymbolResponse( - JsonElement root, - bool createPartialItems, - CancellationToken cancellationToken) - { - if (!TryResolveIndexedDocument(root, out var document)) - return new SymbolResponse([], [], 0, false); - - var candidates = _reader.SearchSymbols((string?)null, MaxDocumentSymbolMaterialization + 1, pathPatterns: [document.IndexedPath]); - var materializationTruncated = candidates.Count > MaxDocumentSymbolMaterialization; - var materializedCount = Math.Min(candidates.Count, MaxDocumentSymbolMaterialization); - Activity.Current?.SetTag("lsp.document_symbols.materialized_count", materializedCount); - Activity.Current?.SetTag("lsp.document_symbols.materialization_truncated", materializationTruncated); - - var symbols = candidates - .Take(MaxDocumentSymbolMaterialization) - .OrderBy(s => s.StartLine) - .ThenByDescending(s => s.EndLine) - .ThenBy(s => s.ContainerName == null ? 0 : 1) - .ThenBy(s => s.Name, StringComparer.Ordinal) - .ToList(); - - if (createPartialItems) - { - Activity.Current?.SetTag("lsp.document_symbols.returned_root_count", 0); - Activity.Current?.SetTag("lsp.document_symbols.returned_partial_count", symbols.Count); - return new SymbolResponse( - [], - EnumerateDocumentSymbolItems(document, symbols, cancellationToken), - symbols.Count, - materializationTruncated); - } - - var tree = BuildDocumentSymbolTree(document, symbols, cancellationToken); - Activity.Current?.SetTag("lsp.document_symbols.returned_root_count", tree.Roots.Count); - return new SymbolResponse( - tree.Roots, - [], - Math.Max(0, symbols.Count - tree.RemovedCount), - materializationTruncated || tree.RemovedCount > 0); - } - - private IEnumerable EnumerateDocumentSymbolItems( - IndexedDocumentContext document, - IReadOnlyList symbols, - CancellationToken cancellationToken) - { - var lineCache = new Dictionary(); - foreach (var symbol in symbols) - { - cancellationToken.ThrowIfCancellationRequested(); - yield return ToDocumentSymbolInformation(document, symbol, lineCache); - } - } - - private DocumentSymbolTreeResult BuildDocumentSymbolTree( - IndexedDocumentContext document, - IReadOnlyList symbols, - CancellationToken cancellationToken) - { - var roots = new JsonArray(); - var nodes = new List(symbols.Count); - var lineCache = new Dictionary(); - foreach (var symbol in symbols) - { - cancellationToken.ThrowIfCancellationRequested(); - var item = ToDocumentSymbol(document, symbol, lineCache); - nodes.Add(new DocumentSymbolNode(symbol, item)); - } - - for (var nodeIndex = 0; nodeIndex < nodes.Count; nodeIndex++) - { - cancellationToken.ThrowIfCancellationRequested(); - var node = nodes[nodeIndex]; - var parent = FindDocumentSymbolParent(nodes, nodeIndex); - if (parent == null) - roots.Add((JsonNode)node.Item); - else - AddDocumentSymbolChild(parent.Value.Item, node.Item); - } - - return new DocumentSymbolTreeResult(roots, TrimDocumentSymbolsToBudget(roots)); - } - - private JsonObject HandleSymbolRequest( - JsonNode? id, - JsonElement root, - bool documentSymbols, - Action? outbound, - CancellationToken cancellationToken) - { - var partialResultToken = GetProgressToken(root, "partialResultToken"); - var workDoneToken = GetProgressToken(root, "workDoneToken"); - if (outbound == null) - return Result(id, documentSymbols ? DocumentSymbol(root) : WorkspaceSymbol(root)); - - var title = documentSymbols ? "CodeIndex document symbols" : "CodeIndex workspace symbols"; - if (workDoneToken != null) - outbound(CreateProgressNotification(workDoneToken, CreateWorkDoneBegin(title))); - - var emittedCount = 0; - try - { - cancellationToken.ThrowIfCancellationRequested(); - BeforeSymbolRequestForTesting?.Invoke(cancellationToken); - var response = documentSymbols - ? CreateDocumentSymbolResponse( - root, - createPartialItems: partialResultToken != null, - cancellationToken) - : CreateWorkspaceSymbolResponse( - root, - createPartialItems: partialResultToken != null, - cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - - var truncated = response.Truncated; - JsonNode? finalResult = response.FinalItems; - if (partialResultToken != null) - { - var emission = EmitPartialResultChunks( - outbound, - partialResultToken, - workDoneToken, - response.PartialItems, - response.ReturnedCount, - cancellationToken); - emittedCount = emission.EmittedCount; - truncated |= emission.Truncated; - if (emission.Cancelled) - { - return CompleteCancelledSymbolRequest( - id, - outbound, - workDoneToken, - emittedCount); - } - - finalResult = null; - } - else - { - emittedCount = response.ReturnedCount; - if (workDoneToken != null) - { - outbound(CreateProgressNotification( - workDoneToken, - CreateWorkDoneReport(100, $"Prepared {emittedCount} symbols."))); - } - } - - cancellationToken.ThrowIfCancellationRequested(); - var summary = CreateSymbolProgressSummary(emittedCount, truncated); - if (workDoneToken != null) - outbound(CreateProgressNotification(workDoneToken, CreateWorkDoneEnd(summary))); - else if (truncated) - outbound(CreateLogMessage(summary)); - - return Result(id, finalResult); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - return CompleteCancelledSymbolRequest( - id, - outbound, - workDoneToken, - emittedCount); - } - catch - { - if (workDoneToken != null) - { - outbound(CreateProgressNotification( - workDoneToken, - CreateWorkDoneEnd("Symbol request failed."))); - } - - throw; - } - } - - private PartialResultEmission EmitPartialResultChunks( - Action outbound, - JsonNode partialResultToken, - JsonNode? workDoneToken, - IEnumerable items, - int totalCount, - CancellationToken cancellationToken) - { - var emittedCount = 0; - var chunk = new JsonArray(); - try - { - foreach (var item in items) - { - if (cancellationToken.IsCancellationRequested) - return new PartialResultEmission(emittedCount, false, true); - - chunk.Add(item); - var measuredNotification = CreateProgressNotification( - partialResultToken, - chunk.DeepClone()); - var exceedsChunkBudget = chunk.Count > MaxSymbolProgressChunkItems - || MeasureJsonUtf8Bytes(measuredNotification) > MaxSymbolProgressChunkBytes; - if (exceedsChunkBudget) - { - chunk.RemoveAt(chunk.Count - 1); - if (chunk.Count > 0) - { - EmitPartialResultChunk( - outbound, - partialResultToken, - workDoneToken, - chunk, - ref emittedCount, - totalCount); - } - - chunk = []; - chunk.Add(item); - measuredNotification = CreateProgressNotification( - partialResultToken, - chunk.DeepClone()); - if (MeasureJsonUtf8Bytes(measuredNotification) > MaxSymbolProgressChunkBytes) - return new PartialResultEmission(emittedCount, true, false); - } - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - return new PartialResultEmission(emittedCount, false, true); - } - - if (chunk.Count > 0) - { - EmitPartialResultChunk( - outbound, - partialResultToken, - workDoneToken, - chunk, - ref emittedCount, - totalCount); - } - else if (totalCount == 0 && workDoneToken != null) - { - outbound(CreateProgressNotification( - workDoneToken, - CreateWorkDoneReport(100, "Prepared 0 symbols."))); - } - - return new PartialResultEmission(emittedCount, false, cancellationToken.IsCancellationRequested); - } - - private static void EmitPartialResultChunk( - Action outbound, - JsonNode partialResultToken, - JsonNode? workDoneToken, - JsonArray chunk, - ref int emittedCount, - int totalCount) - { - var chunkCount = chunk.Count; - outbound(CreateProgressNotification(partialResultToken, chunk)); - emittedCount += chunkCount; - if (workDoneToken == null) - return; - - var percentage = totalCount == 0 - ? 100 - : Math.Clamp((int)((long)emittedCount * 100 / totalCount), 0, 100); - outbound(CreateProgressNotification( - workDoneToken, - CreateWorkDoneReport(percentage, $"Streamed {emittedCount} symbols."))); - } - - private static JsonObject CompleteCancelledSymbolRequest( - JsonNode? id, - Action outbound, - JsonNode? workDoneToken, - int emittedCount) - { - if (workDoneToken != null) - { - outbound(CreateProgressNotification( - workDoneToken, - CreateWorkDoneEnd($"Cancelled after {emittedCount} symbols."))); - } - - return Error(id, JsonRpcRequestCancelledCode, JsonRpcRequestCancelledMessage); - } - - private static JsonNode? GetProgressToken(JsonElement root, string propertyName) - { - if (!TryGet(root, out var token, "params", propertyName)) - return null; - - if (token.ValueKind == JsonValueKind.String) - { - var value = token.GetString() ?? string.Empty; - if (value.Length <= MaxRequestIdStringChars) - return JsonValue.Create(value); - } - else if (token.ValueKind == JsonValueKind.Number && token.TryGetInt64(out var integer)) - { - return JsonValue.Create(integer); - } - - throw new ArgumentException($"{propertyName} must be a bounded string or integer."); - } - - private static JsonObject CreateProgressNotification(JsonNode token, JsonNode? value) => new() - { - ["jsonrpc"] = "2.0", - ["method"] = "$/progress", - ["params"] = new JsonObject - { - ["token"] = token.DeepClone(), - ["value"] = value, - }, - }; - - private static JsonObject CreateWorkDoneBegin(string title) => new() - { - ["kind"] = "begin", - ["title"] = title, - ["cancellable"] = true, - ["percentage"] = 0, - }; - - private static JsonObject CreateWorkDoneReport(int percentage, string message) => new() - { - ["kind"] = "report", - ["percentage"] = percentage, - ["message"] = message, - }; - - private static JsonObject CreateWorkDoneEnd(string message) => new() - { - ["kind"] = "end", - ["message"] = message, - }; - - private static JsonObject CreateLogMessage(string message) => new() - { - ["jsonrpc"] = "2.0", - ["method"] = "window/logMessage", - ["params"] = new JsonObject - { - ["type"] = 2, - ["message"] = message, - }, - }; - - private static string CreateSymbolProgressSummary(int returnedCount, bool truncated) => - truncated - ? $"Returned {returnedCount} symbols; truncated at a configured result or progress-frame limit." - : $"Returned {returnedCount} symbols."; - - private JsonArray Definition(JsonElement root, string method) - { - if (!TryExtractPositionToken(root, out var context, out var failureReason)) - { - RecordLookupFailure(method, failureReason); - return []; - } - - var definitions = ResolveLspDefinitions(context); - var array = new JsonArray(); - foreach (var definition in definitions) - array.Add((JsonNode)ToSymbolLocation(definition, context)); - return array; - } - - private JsonArray References(JsonElement root, string method) - { - if (!TryExtractPositionToken(root, out var context, out var failureReason)) - { - RecordLookupFailure(method, failureReason); - return []; - } - - var includeDeclaration = GetBool(root, "params", "context", "includeDeclaration") == true; - var references = ResolveLspReferences(context); - var array = new JsonArray(); - var seenLocations = new HashSet(StringComparer.Ordinal); - if (includeDeclaration) - { - foreach (var definition in ResolveLspDefinitions(context)) - AddSymbolLocation(array, seenLocations, definition, context); - } - - foreach (var reference in references) - AddLocation( - array, - seenLocations, - reference.Path, - reference.Line, - Math.Max(reference.Column, 1), - reference.Line, - Math.Max(reference.Column, 1) + Math.Max(context.Token.Length, 1), - context); - return array; - } - - private JsonNode? Hover(JsonElement root, string method) - { - if (!TryExtractPositionToken(root, out var context, out var failureReason)) - { - RecordLookupFailure(method, failureReason); - return null; - } - - var definition = ResolveLspDefinitions(context).FirstOrDefault(); - if (definition == null) - return null; - - return new JsonObject - { - ["contents"] = new JsonObject - { - ["kind"] = "plaintext", - ["value"] = FormatHoverText(definition), - }, - ["range"] = ToRange(context.Line + 1, context.StartCharacter + 1, context.Line + 1, context.EndCharacter + 1), - }; - } - - private JsonObject Completion(JsonElement root, string method) - { - if (!TryExtractPositionToken(root, out var context, out var failureReason)) - { - RecordLookupFailure(method, failureReason); - return CompletionList([]); - } - - var symbols = _reader.SearchSymbols(context.Token, MaxCompletionItems, pathPatterns: [context.IndexedPath]) - .Concat(_reader.SearchSymbols(context.Token, MaxCompletionItems)) - .DistinctBy(BuildCompletionIdentity) - .Take(MaxCompletionItems) - .ToList(); - var items = new JsonArray(); - for (var i = 0; i < symbols.Count; i++) - items.Add((JsonNode)ToCompletionItem(symbols[i], i)); - return CompletionList(items); - } - - private JsonArray DocumentHighlight(JsonElement root, string method) - { - if (!TryExtractPositionToken(root, out var context, out var failureReason)) - { - RecordLookupFailure(method, failureReason); - return []; - } - - var array = new JsonArray(); - var seenRanges = new HashSet(StringComparer.Ordinal); - foreach (var definition in ResolveLspDefinitions(context).Where(definition => string.Equals(definition.Path, context.IndexedPath, StringComparison.Ordinal))) - { - var identifier = GetSymbolIdentifierPosition(definition, context.ResolvedPath); - AddDocumentHighlight(array, seenRanges, identifier.Line, identifier.StartColumn, identifier.Line, identifier.EndColumn); - } - - foreach (var reference in ResolveLspReferences(context).Where(reference => string.Equals(reference.Path, context.IndexedPath, StringComparison.Ordinal))) - { - var startColumn = Math.Max(reference.Column, 1); - AddDocumentHighlight(array, seenRanges, reference.Line, startColumn, reference.Line, startColumn + Math.Max(context.Token.Length, 1)); - } - - if (array.Count == 0) - AddDocumentHighlight(array, seenRanges, context.Line + 1, context.StartCharacter + 1, context.Line + 1, context.EndCharacter + 1); - return array; - } - - private JsonObject SemanticTokensFull(JsonElement root) - { - if (!TryResolveIndexedDocument(root, out var document)) - return new JsonObject { ["data"] = new JsonArray() }; - - var lineCache = new Dictionary(); - var symbols = GetDocumentSymbols(document.IndexedPath, MaxSemanticTokenItems) - .Where(symbol => !string.IsNullOrWhiteSpace(symbol.Name)) - .Take(MaxSemanticTokenItems) - .Select(symbol => BuildSemanticToken(document, symbol, lineCache)) - .Where(token => token.HasValue) - .Select(token => token!.Value) - .OrderBy(token => token.Line) - .ThenBy(token => token.StartCharacter) - .ToList(); - IEnumerable lexicalTokens = string.Equals(Path.GetExtension(document.ResolvedPath), ".cs", StringComparison.OrdinalIgnoreCase) - ? BuildCSharpLexicalSemanticTokens(document, lineCache) - : []; - symbols = RemoveOverlappingSemanticTokens(lexicalTokens.Concat(symbols)) - .OrderBy(token => token.Line) - .ThenBy(token => token.StartCharacter) - .ToList(); - var data = new JsonArray(); - var previousLine = 0; - var previousStart = 0; - foreach (var token in symbols) - { - var deltaLine = token.Line - previousLine; - var deltaStart = deltaLine == 0 ? token.StartCharacter - previousStart : token.StartCharacter; - data.Add(deltaLine); - data.Add(deltaStart); - data.Add(token.Length); - data.Add(token.TokenType); - data.Add(token.TokenModifiers); - previousLine = token.Line; - previousStart = token.StartCharacter; - } - - return new JsonObject { ["data"] = data }; - } - - private JsonArray InlayHint(JsonElement root) - { - if (!TryResolveIndexedDocument(root, out var document)) - return []; - - var array = new JsonArray(); - var lineCache = new Dictionary(); - var hasRange = TryReadInlayHintRange(root, out var startLine, out _, out var endLine, out _); - foreach (var symbol in GetDocumentSymbols( - document.IndexedPath, - MaxDocumentSymbols, - hasRange ? startLine + 1 : null, - hasRange ? endLine + 1 : null) - .Where(symbol => !string.IsNullOrWhiteSpace(symbol.ReturnType)) - .Where(symbol => IsInlayHintInRequestedRange(root, document, symbol, lineCache)) - .Where(symbol => !HasExplicitTypeBeforeSymbol(document, symbol, lineCache)) - .Take(MaxInlayHintItems)) - { - array.Add((JsonNode)ToInlayHint(document, symbol, lineCache)); - } - return array; - } - - private bool IsInlayHintInRequestedRange( - JsonElement root, - IndexedDocumentContext document, - SymbolResult symbol, - Dictionary lineCache) - { - if (!TryReadInlayHintRange(root, out var startLine, out var startCharacter, out var endLine, out var endCharacter)) - return true; - - var line = Math.Max(symbol.Line, 1) - 1; - var character = FindSymbolStartCharacter(document.ResolvedPath, symbol, lineCache) + symbol.Name.Length; - return IsPositionInRange(line, character, startLine, startCharacter, endLine, endCharacter); - } - - private bool HasExplicitTypeBeforeSymbol( - IndexedDocumentContext document, - SymbolResult symbol, - Dictionary lineCache) - { - var line = Math.Max(symbol.Line, symbol.StartLine); - if (string.IsNullOrWhiteSpace(symbol.ReturnType) || - line <= 0 || - !TryReadPositionLineCached(document.ResolvedPath, line - 1, lineCache, out var sourceLine)) - { - return false; - } - - var symbolStart = FindSymbolStartCharacter(document.ResolvedPath, symbol, lineCache); - if (symbolStart <= 0 || sourceLine.Length == 0) - return false; - - var typeStart = sourceLine.LastIndexOf(symbol.ReturnType, symbolStart - 1, StringComparison.Ordinal); - if (typeStart < 0) - return false; - - var typeEnd = typeStart + symbol.ReturnType.Length; - return typeEnd <= symbolStart && sourceLine.AsSpan(typeEnd, symbolStart - typeEnd).Trim().IsEmpty; - } - - private static bool TryReadLspPosition(JsonElement range, string propertyName, out int line, out int character) - { - line = 0; - character = 0; - return range.ValueKind == JsonValueKind.Object && - range.TryGetProperty(propertyName, out var position) && - position.ValueKind == JsonValueKind.Object && - position.TryGetProperty("line", out var lineElement) && - lineElement.TryGetInt32(out line) && - line >= 0 && - position.TryGetProperty("character", out var characterElement) && - characterElement.TryGetInt32(out character) && - character >= 0; - } - - private static bool TryReadInlayHintRange( - JsonElement root, - out int startLine, - out int startCharacter, - out int endLine, - out int endCharacter) - { - startLine = 0; - startCharacter = 0; - endLine = 0; - endCharacter = 0; - return root.TryGetProperty("params", out var paramsElement) && - paramsElement.TryGetProperty("range", out var range) && - TryReadLspPosition(range, "start", out startLine, out startCharacter) && - TryReadLspPosition(range, "end", out endLine, out endCharacter); - } - - private static bool IsPositionInRange( - int line, - int character, - int startLine, - int startCharacter, - int endLine, - int endCharacter) - => ComparePosition(line, character, startLine, startCharacter) >= 0 && - ComparePosition(line, character, endLine, endCharacter) < 0; - - private static int ComparePosition(int leftLine, int leftCharacter, int rightLine, int rightCharacter) - => leftLine != rightLine ? leftLine.CompareTo(rightLine) : leftCharacter.CompareTo(rightCharacter); - - private static JsonObject CompletionList(JsonArray items) => new() - { - ["isIncomplete"] = false, - ["items"] = items, - }; - - private static string BuildCompletionIdentity(SymbolResult symbol) - => string.Join('\0', symbol.Name, symbol.Kind, symbol.Path, symbol.Line.ToString(CultureInfo.InvariantCulture)); - - private static JsonObject ToCompletionItem(SymbolResult symbol, int index) => new() - { - ["label"] = symbol.Name, - ["kind"] = CompletionItemKind(symbol.Kind), - ["detail"] = FormatSymbolDetail(symbol), - ["sortText"] = index.ToString("D4", CultureInfo.InvariantCulture) + "_" + symbol.Name, - }; - - private string FormatHoverText(SymbolResult symbol) - { - var builder = new StringBuilder(); - builder.Append(symbol.Kind).Append(' ').Append(symbol.Name); - if (!string.IsNullOrWhiteSpace(symbol.Signature)) - builder.AppendLine().Append(symbol.Signature); - builder.AppendLine().Append(FormatHoverPath(symbol.Path)).Append(':').Append(symbol.Line.ToString(CultureInfo.InvariantCulture)); - if (!string.IsNullOrWhiteSpace(symbol.ContainerName)) - builder.AppendLine().Append("container: ").Append(symbol.ContainerName); - if (!string.IsNullOrWhiteSpace(symbol.ReturnType)) - builder.AppendLine().Append("returns: ").Append(symbol.ReturnType); - return builder.ToString(); - } - - private string FormatHoverPath(string path) - { - if (!Path.IsPathRooted(path)) - return path.Replace('\\', '/'); - - foreach (var root in EnumerateHoverRoots()) - { - if (TryGetRelativePath(root, path, out var relativePath) && relativePath != null) - return relativePath.Replace('\\', '/'); - } - - return "[outside workspace]"; - } - - private IEnumerable EnumerateHoverRoots() - { - if (_projectRoot != null) - yield return _projectRoot; - foreach (var workspaceFolder in _workspaceFolders) - yield return workspaceFolder; - } - - private static string FormatSymbolDetail(SymbolResult symbol) - { - var detail = string.IsNullOrWhiteSpace(symbol.Signature) - ? $"{symbol.Kind} {symbol.Path}:{symbol.Line.ToString(CultureInfo.InvariantCulture)}" - : symbol.Signature; - return detail.Length <= MaxDocumentSymbolDetailChars - ? detail - : detail[..(MaxDocumentSymbolDetailChars - "...".Length)] + "..."; - } - - private static int CompletionItemKind(string kind) => kind switch - { - "class" => 7, - "function" or "test.method" => 3, - "property" => 10, - "enum" => 13, - "interface" => 8, - "namespace" => 9, - "struct" => 22, - _ => 6, - }; - - private static void AddDocumentHighlight(JsonArray array, HashSet seenRanges, int startLine, int startColumn, int endLine, int endColumn) - { - var key = string.Join('\0', startLine, startColumn, endLine, endColumn); - if (!seenRanges.Add(key)) - return; - - array.Add(new JsonObject - { - ["range"] = ToRange(startLine, startColumn, endLine, endColumn), - ["kind"] = 1, - }); - } - - private JsonObject ToInlayHint(IndexedDocumentContext document, SymbolResult symbol, Dictionary lineCache) - { - var startCharacter = FindSymbolStartCharacter(document.ResolvedPath, symbol, lineCache); - return new JsonObject - { - ["position"] = ToPosition(symbol.Line, startCharacter + symbol.Name.Length + 1), - ["label"] = ": " + symbol.ReturnType, - ["kind"] = 1, - ["paddingLeft"] = true, - }; - } - - private readonly record struct SemanticToken(int Line, int StartCharacter, int Length, int TokenType, int TokenModifiers); - - private static IEnumerable RemoveOverlappingSemanticTokens(IEnumerable candidates) - { - var selected = new List(); - foreach (var candidate in candidates) - { - if (selected.Any(existing => - existing.Line == candidate.Line && - existing.StartCharacter < candidate.StartCharacter + candidate.Length && - candidate.StartCharacter < existing.StartCharacter + existing.Length)) - { - continue; - } - - selected.Add(candidate); - if (selected.Count == MaxSemanticTokenItems) - break; - } - return selected; - } - - private static readonly HashSet CSharpModifiers = new(StringComparer.Ordinal) - { - "abstract", "async", "const", "extern", "file", "internal", "override", "partial", - "private", "protected", "public", "readonly", "required", "sealed", "static", "unsafe", "virtual", "volatile", - }; - - private static readonly HashSet CSharpKeywords = new(StringComparer.Ordinal) - { - "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", "class", "continue", - "decimal", "default", "delegate", "do", "double", "else", "enum", "event", "explicit", "false", "finally", - "fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int", "interface", "is", "lock", - "long", "namespace", "new", "null", "object", "operator", "out", "params", "record", "ref", "return", "sbyte", - "short", "sizeof", "stackalloc", "string", "struct", "switch", "this", "throw", "true", "try", "typeof", - "uint", "ulong", "unchecked", "using", "ushort", "void", "while", "with", "yield", - }; - - private IEnumerable BuildCSharpLexicalSemanticTokens( - IndexedDocumentContext document, - Dictionary lineCache) - { - var inBlockComment = false; - var stringMode = CSharpStringMode.None; - var rawQuoteCount = 0; - var ordinaryQuote = '\0'; - for (var line = 0; line < MaxSemanticTokenItems; line++) - { - if (!TryReadPositionLineCached(document.ResolvedPath, line, lineCache, out var sourceLine)) - yield break; - - for (var index = 0; index < sourceLine.Length;) - { - if (inBlockComment) - { - var end = sourceLine.IndexOf("*/", index, StringComparison.Ordinal); - if (end < 0) - break; - inBlockComment = false; - index = end + 2; - continue; - } - - if (stringMode == CSharpStringMode.Raw) - { - var end = FindRawStringEnd(sourceLine, index, rawQuoteCount); - if (end < 0) - break; - stringMode = CSharpStringMode.None; - index = end; - continue; - } - - if (stringMode == CSharpStringMode.Verbatim) - { - var end = sourceLine.IndexOf('"', index); - if (end < 0) - break; - if (end + 1 < sourceLine.Length && sourceLine[end + 1] == '"') - { - index = end + 2; - continue; - } - stringMode = CSharpStringMode.None; - index = end + 1; - continue; - } - - if (stringMode == CSharpStringMode.Ordinary) - { - if (sourceLine[index] == '\\') - { - index = Math.Min(index + 2, sourceLine.Length); - continue; - } - if (sourceLine[index++] == ordinaryQuote) - stringMode = CSharpStringMode.None; - continue; - } - - if (index + 1 < sourceLine.Length && sourceLine[index] == '/' && sourceLine[index + 1] == '/') - break; - if (index + 1 < sourceLine.Length && sourceLine[index] == '/' && sourceLine[index + 1] == '*') - { - inBlockComment = true; - index += 2; - continue; - } - var quoteCount = CountConsecutive(sourceLine, index, '"'); - if (quoteCount >= 3) - { - stringMode = CSharpStringMode.Raw; - rawQuoteCount = quoteCount; - index += quoteCount; - continue; - } - if (sourceLine[index] == '@' && index + 1 < sourceLine.Length && sourceLine[index + 1] == '"') - { - stringMode = CSharpStringMode.Verbatim; - index += 2; - continue; - } - if (sourceLine[index] == '@' && index + 2 < sourceLine.Length && sourceLine[index + 1] == '$' && sourceLine[index + 2] == '"') - { - stringMode = CSharpStringMode.Verbatim; - index += 3; - continue; - } - if (sourceLine[index] is '\'' or '"') - { - stringMode = CSharpStringMode.Ordinary; - ordinaryQuote = sourceLine[index]; - index++; - continue; - } - if (!IsCSharpIdentifierStart(sourceLine[index])) - { - index++; - continue; - } - - var start = index++; - while (index < sourceLine.Length && IsTokenChar(sourceLine[index])) - index++; - var word = sourceLine[start..index].TrimStart('@'); - if (CSharpModifiers.Contains(word)) - yield return new SemanticToken(line, start, index - start, 16, 0); - else if (CSharpKeywords.Contains(word)) - yield return new SemanticToken(line, start, index - start, 15, 0); - else if (IsCSharpNamespaceComponent(sourceLine, start)) - yield return new SemanticToken(line, start, index - start, 0, 0); - } - } - } - - private static bool IsCSharpIdentifierStart(char value) => char.IsLetter(value) || value is '_' or '@'; - - private enum CSharpStringMode - { - None, - Ordinary, - Verbatim, - Raw, - } - - private static int CountConsecutive(string text, int start, char value) - { - var index = start; - while (index < text.Length && text[index] == value) - index++; - return index - start; - } - - private static int FindRawStringEnd(string line, int start, int quoteCount) - { - for (var index = start; index < line.Length; index++) - { - if (line[index] == '"' && CountConsecutive(line, index, '"') >= quoteCount) - return index + quoteCount; - } - return -1; - } - - private static bool IsCSharpNamespaceComponent(string line, int start) - { - var trimmedStart = line.Length - line.AsSpan().TrimStart().Length; - var trimmedLine = line.AsSpan(trimmedStart); - var nameStart = trimmedLine.StartsWith("global using ", StringComparison.Ordinal) - ? trimmedStart + "global using ".Length - : trimmedLine.StartsWith("using ", StringComparison.Ordinal) - ? trimmedStart + "using ".Length - : trimmedLine.StartsWith("namespace ", StringComparison.Ordinal) - ? trimmedStart + "namespace ".Length - : -1; - if (nameStart < 0 || start < nameStart) - return false; - - var semicolon = line.IndexOf(';', nameStart); - var brace = line.IndexOf('{', nameStart); - var nameEnd = new[] { semicolon, brace }.Where(value => value >= 0).DefaultIfEmpty(line.Length).Min(); - var alias = line.IndexOf('=', nameStart, Math.Max(0, nameEnd - nameStart)); - var qualifiedNameStart = alias >= 0 ? alias + 1 : nameStart; - return start >= qualifiedNameStart && start < nameEnd; - } - - private SemanticToken? BuildSemanticToken(IndexedDocumentContext document, SymbolResult symbol, Dictionary lineCache) - { - var line = Math.Max(symbol.Line, symbol.StartLine); - if (line <= 0) - return null; - - var startCharacter = FindSymbolStartCharacter(document.ResolvedPath, symbol, lineCache); - var length = Math.Max(symbol.Name.Length, 1); - return new SemanticToken( - line - 1, - startCharacter, - length, - SemanticTokenType(symbol.Kind), - 1 << 0); - } - - private int FindSymbolStartCharacter(string resolvedPath, SymbolResult symbol, Dictionary? lineCache = null) - { - var indexedStart = Math.Max(0, symbol.StartColumn ?? 0); - var line = Math.Max(symbol.Line, symbol.StartLine); - if (line <= 0 || string.IsNullOrWhiteSpace(symbol.Name)) - return indexedStart; - - if (!TryReadPositionLineCached(resolvedPath, line - 1, lineCache, out var sourceLine)) - return indexedStart; - - var searchStart = Math.Min( - ResolveDeclarationIdentifierAnchor(sourceLine, symbol, indexedStart), - sourceLine.Length); - var sourceStart = FindIdentifierOccurrence(sourceLine, symbol.Name, searchStart); - if (sourceStart >= 0) - return sourceStart; - - sourceStart = FindIdentifierOccurrence(sourceLine, symbol.Name, 0); - return sourceStart >= 0 ? sourceStart : indexedStart; - } - - private static int ResolveDeclarationIdentifierAnchor(string sourceLine, SymbolResult symbol, int indexedStart) - { - if (string.IsNullOrWhiteSpace(symbol.Signature)) - return indexedStart; - - var firstLineEnd = symbol.Signature.IndexOfAny(['\r', '\n']); - var signatureLine = firstLineEnd >= 0 ? symbol.Signature[..firstLineEnd] : symbol.Signature; - var firstName = signatureLine.IndexOf(symbol.Name, StringComparison.Ordinal); - var declarationName = FindDeclarationIdentifierOffset(signatureLine, symbol.Kind, symbol.Name); - if (firstName < 0 || declarationName < firstName) - return indexedStart; - - var adjusted = (long)indexedStart + declarationName - firstName; - if (adjusted < 0 || adjusted > sourceLine.Length) - return indexedStart; - - var candidate = (int)adjusted; - return IsIdentifierOccurrenceAt(sourceLine, symbol.Name, candidate) ? candidate : indexedStart; - } - - private static int FindDeclarationIdentifierOffset(string signatureLine, string kind, string name) - { - var headerEnd = FindDeclarationHeaderEnd(signatureLine, kind); - var result = -1; - var searchStart = 0; - while (searchStart <= headerEnd) - { - var candidate = FindIdentifierOccurrence(signatureLine, name, searchStart); - if (candidate < 0 || candidate + name.Length > headerEnd) - break; - result = candidate; - searchStart = candidate + 1; - } - - return result; - } - - private static int FindDeclarationHeaderEnd(string signatureLine, string kind) - { - ReadOnlySpan delimiters = kind switch - { - "function" or "test.method" => "(", - "class" or "struct" or "interface" or "enum" or "namespace" => ":{(;", - _ => "{=;", - }; - var end = signatureLine.Length; - foreach (var delimiter in delimiters) - { - var candidate = signatureLine.IndexOf(delimiter); - if (candidate >= 0) - end = Math.Min(end, candidate); - } - - return end; - } - - private static int FindIdentifierOccurrence(string sourceLine, string name, int startIndex) - { - var candidate = sourceLine.IndexOf(name, startIndex, StringComparison.Ordinal); - while (candidate >= 0) - { - var hasStartBoundary = candidate == 0 || !IsIdentifierContinuation(sourceLine[candidate - 1]); - var end = candidate + name.Length; - var hasEndBoundary = end == sourceLine.Length || !IsIdentifierContinuation(sourceLine[end]); - if (hasStartBoundary && hasEndBoundary) - return candidate; - - candidate = sourceLine.IndexOf(name, candidate + 1, StringComparison.Ordinal); - } - - return -1; - } - - private static bool IsIdentifierOccurrenceAt(string sourceLine, string name, int start) - { - if (start < 0 || start + name.Length > sourceLine.Length || - !sourceLine.AsSpan(start, name.Length).SequenceEqual(name.AsSpan())) - { - return false; - } - - var hasStartBoundary = start == 0 || !IsIdentifierContinuation(sourceLine[start - 1]); - var end = start + name.Length; - var hasEndBoundary = end == sourceLine.Length || !IsIdentifierContinuation(sourceLine[end]); - return hasStartBoundary && hasEndBoundary; - } - - private static bool IsIdentifierContinuation(char value) - { - var category = char.GetUnicodeCategory(value); - return char.IsLetterOrDigit(value) || - value is '_' or '$' || - category is UnicodeCategory.NonSpacingMark or - UnicodeCategory.SpacingCombiningMark or - UnicodeCategory.ConnectorPunctuation or - UnicodeCategory.Format; - } - - private static int SemanticTokenType(string kind) => kind switch - { - "namespace" => 0, - "class" => 2, - "enum" => 3, - "interface" => 4, - "struct" => 5, - "property" => 9, - "field" => 23, - "function" or "test.method" => 13, - _ => 8, - }; - - private JsonObject ToSymbolLocation(SymbolResult symbol, PositionTokenContext context) - { - var identifier = GetSymbolIdentifierPosition(symbol, context); - return ToLocation( - symbol.Path, - identifier.Line, - identifier.StartColumn, - identifier.Line, - identifier.EndColumn, - GetLocationWorkspaceRoot(symbol.Path, context)); - } - - private void AddSymbolLocation(JsonArray array, HashSet seenLocations, SymbolResult symbol, PositionTokenContext context) - { - var identifier = GetSymbolIdentifierPosition(symbol, context); - AddLocation(array, seenLocations, symbol.Path, identifier.Line, identifier.StartColumn, identifier.Line, identifier.EndColumn, context); - } - - private void AddLocation( - JsonArray array, - HashSet seenLocations, - string path, - int startLine, - int startColumn, - int endLine, - int endColumn, - PositionTokenContext context) - { - var workspaceRoot = GetLocationWorkspaceRoot(path, context); - var key = string.Join('\0', PathToUri(path, workspaceRoot ?? _projectRoot), startLine, startColumn, endLine, endColumn); - if (seenLocations.Add(key)) - array.Add((JsonNode)ToLocation(path, startLine, startColumn, endLine, endColumn, workspaceRoot)); - } - - private string? GetLocationWorkspaceRoot(string path, PositionTokenContext context) - { - if (Path.IsPathRooted(path)) - return null; - return _projectRoot ?? context.WorkspaceRoot; - } - - private static void RecordLookupFailure(string method, string? failureReason) - { - if (string.IsNullOrEmpty(failureReason)) - return; - - Activity.Current?.AddEvent(new ActivityEvent( - LspLookupFailureEventName, - tags: new ActivityTagsCollection - { - [LspMethodTag] = method, - [LspLookupFailureReasonTag] = failureReason, - })); - } - - private DocumentSymbolNode? FindDocumentSymbolParent(IReadOnlyList nodes, int symbolIndex) - { - var symbolNode = nodes[symbolIndex]; - var symbol = symbolNode.Symbol; - DocumentSymbolNode? parent = null; - int? nearestSameLineStart = null; - for (var i = nodes.Count - 1; i >= 0; i--) - { - if (i == symbolIndex) - continue; - - var candidate = nodes[i].Symbol; - if (!ContainsDocumentSymbol(candidate, symbol)) - continue; - if (symbol.ContainerName != null - && !string.Equals(candidate.Name, symbol.ContainerName, StringComparison.Ordinal)) - { - continue; - } - if (symbol.ContainerKind != null - && !string.Equals(candidate.Kind, symbol.ContainerKind, StringComparison.Ordinal)) - { - continue; - } - - ConsiderDocumentSymbolParent(nodes[i], symbolNode, ref parent, ref nearestSameLineStart); - } - - if (parent != null) - return parent; - if (symbol.ContainerName != null) - return null; - - for (var i = nodes.Count - 1; i >= 0; i--) - { - if (i == symbolIndex) - continue; - - var candidate = nodes[i].Symbol; - if (ContainsDocumentSymbol(candidate, symbol)) - ConsiderDocumentSymbolParent(nodes[i], symbolNode, ref parent, ref nearestSameLineStart); - } - - return parent; - } - - private static void ConsiderDocumentSymbolParent( - DocumentSymbolNode candidate, - DocumentSymbolNode symbol, - ref DocumentSymbolNode? parent, - ref int? nearestSameLineStart) - { - if (candidate.Symbol.StartLine == symbol.Symbol.StartLine) - { - var candidateStart = candidate.Item["selectionRange"]?["start"]?["character"]?.GetValue(); - var symbolStart = symbol.Item["selectionRange"]?["start"]?["character"]?.GetValue(); - if (candidateStart.HasValue && symbolStart.HasValue) - { - if (candidateStart.Value > symbolStart.Value) - return; - if (!nearestSameLineStart.HasValue || candidateStart.Value > nearestSameLineStart.Value) - { - parent = candidate; - nearestSameLineStart = candidateStart.Value; - } - return; - } - } - - if (!nearestSameLineStart.HasValue && parent == null) - parent = candidate; - } - - private static bool ContainsDocumentSymbol(SymbolResult candidate, SymbolResult symbol) => - candidate.StartLine <= symbol.StartLine - && candidate.EndLine >= symbol.EndLine - && (candidate.StartLine < symbol.StartLine - || candidate.EndLine > symbol.EndLine - || (symbol.ContainerName != null - && symbol.ContainerKind != null - && string.Equals(candidate.Name, symbol.ContainerName, StringComparison.Ordinal) - && string.Equals(candidate.Kind, symbol.ContainerKind, StringComparison.Ordinal))); - - private static void AddDocumentSymbolChild(JsonObject parent, JsonObject child) - { - if (parent["children"] is not JsonArray children) - { - children = []; - parent["children"] = children; - } - - children.Add((JsonNode)child); - } - - private int TrimDocumentSymbolsToBudget(JsonArray roots) - { - var removedCount = 0; - var responseBudget = DocumentSymbolResponseBytesForTesting ?? MaxDocumentSymbolResponseBytes; - var responseBytes = MeasureJsonUtf8Bytes(roots); - while (roots.Count > 0 && responseBytes > responseBudget) - { - if (!RemoveLastDocumentSymbol(roots, out var removedBytes)) - break; - removedCount++; - - if (_jsonOptions.WriteIndented) - responseBytes = MeasureJsonUtf8Bytes(roots); - else - responseBytes = removedBytes > 0 - ? Math.Max(0, responseBytes - removedBytes) - : MeasureJsonUtf8Bytes(roots); - } - - return removedCount; - } - - private bool RemoveLastDocumentSymbol(JsonArray symbols, out int removedBytes) - { - removedBytes = 0; - if (symbols.Count == 0) - return false; - - if (symbols[symbols.Count - 1] is JsonObject last - && last["children"] is JsonArray children - && children.Count > 0) - { - var beforeBytes = MeasureJsonUtf8Bytes(last); - if (RemoveLastDocumentSymbol(children, out _)) - { - if (children.Count == 0) - last.Remove("children"); - removedBytes = Math.Max(0, beforeBytes - MeasureJsonUtf8Bytes(last)); - return true; - } - } - - removedBytes = MeasureJsonUtf8Bytes(symbols[symbols.Count - 1]) - + (symbols.Count > 1 ? 1 : 0); - symbols.RemoveAt(symbols.Count - 1); - return true; - } - - private int MeasureJsonUtf8Bytes(JsonNode? node) - { - if (node == null) - return "null"u8.Length; - if (_jsonOptions.WriteIndented) - return Encoding.UTF8.GetByteCount(node.ToJsonString(_jsonOptions)); - - if (node is JsonArray array) - { - var bytes = "[]"u8.Length; - for (var i = 0; i < array.Count; i++) - { - if (i > 0) - bytes++; - bytes += MeasureJsonUtf8Bytes(array[i]); - } - return bytes; - } - - if (node is JsonObject obj) - { - var bytes = "{}"u8.Length; - var propertyIndex = 0; - foreach (var property in obj) - { - if (propertyIndex > 0) - bytes++; - bytes += MeasureJsonStringUtf8Bytes(property.Key); - bytes++; - bytes += MeasureJsonUtf8Bytes(property.Value); - propertyIndex++; - } - return bytes; - } - - return Encoding.UTF8.GetByteCount(node.ToJsonString(_jsonOptions)); - } - - private int MeasureJsonStringUtf8Bytes(string value) => - Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(value, _jsonOptions)); - - private List ResolveLspDefinitions(PositionTokenContext context) - { - var localDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true, pathPatterns: [context.IndexedPath]); - if (localDefinitions.Count > 0) - { - var positionDefinitions = FindDefinitionsAtPosition(localDefinitions, context); - if (positionDefinitions.Count > 0) - return positionDefinitions; - - var localReferenceTarget = ResolveReferenceTargetAtPosition(context); - return localReferenceTarget == null ? localDefinitions : [localReferenceTarget]; - } - - var workspaceDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true); - if (workspaceDefinitions.Count > 1) - { - var referenceTarget = ResolveReferenceTargetAtPosition(context); - if (referenceTarget != null) - return [referenceTarget]; - } - return workspaceDefinitions; - } - - private IReadOnlyList ResolveLspReferences(PositionTokenContext context) - { - var localDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true, pathPatterns: [context.IndexedPath]); - if (localDefinitions.Count > 0) - { - var positionDefinitions = FindDefinitionsAtPosition(localDefinitions, context); - if (positionDefinitions.Count == 1) - return _reader.GetReferencesForDefinition(positionDefinitions[0], DefaultLimit); - - var localReferenceTarget = ResolveReferenceTargetAtPosition(context); - if (localReferenceTarget != null) - return _reader.GetReferencesForDefinition(localReferenceTarget, DefaultLimit); - - return _reader.AnalyzeSymbol(context.Token, DefaultLimit, pathPatterns: [context.IndexedPath], exact: true).References; - } - - var workspaceDefinitions = _reader.GetDefinitions(context.Token, DefaultLimit, exact: true); - if (workspaceDefinitions.Count > 1) - { - var referenceTarget = ResolveReferenceTargetAtPosition(context); - if (referenceTarget != null) - return _reader.GetReferencesForDefinition(referenceTarget, DefaultLimit); - } - - if (workspaceDefinitions.Count == 0 || !HasSingleLspDefinitionTarget(workspaceDefinitions)) - return _reader.AnalyzeSymbol(context.Token, DefaultLimit, pathPatterns: [context.IndexedPath], exact: true).References; - - return _reader.AnalyzeSymbol(context.Token, DefaultLimit, exact: true).References; - } - - private DefinitionResult? ResolveReferenceTargetAtPosition(PositionTokenContext context) - { - var resolution = _reader.GetReferencePositionResolution( - context.IndexedPath, - context.Token, - context.Line + 1, - context.StartCharacter + 1, - MaxReferencePositionCandidates); - if (!resolution.IdentityAvailable || resolution.CandidatesTruncated) - return null; - - var selected = resolution.Candidates - .Where(candidate => candidate.Authoritative) - .Take(2) - .ToList(); - if (selected.Count == 1) - return _reader.GetDefinitionForSymbol(selected[0].Definition); - - if (TryGetCSharpInvocationArgumentCount(context, out var argumentCount)) - { - selected = resolution.Candidates - .Where(candidate => TryGetCSharpDefinitionParameterCount(candidate.Definition, out var parameterCount) && - parameterCount == argumentCount) - .Take(2) - .ToList(); - if (selected.Count == 1) - return _reader.GetDefinitionForSymbol(selected[0].Definition); - } - - return resolution.Candidates.Count == 1 - ? _reader.GetDefinitionForSymbol(resolution.Candidates[0].Definition) - : null; - } - - private bool TryGetCSharpInvocationArgumentCount(PositionTokenContext context, out int argumentCount) - { - argumentCount = 0; - if (!TryReadPositionLine(context.ResolvedPath, context.Line, out var sourceLine, out _)) - return false; - - var openParenthesis = context.EndCharacter; - while (openParenthesis < sourceLine.Length && char.IsWhiteSpace(sourceLine[openParenthesis])) - openParenthesis++; - return openParenthesis < sourceLine.Length && - sourceLine[openParenthesis] == '(' && - TryCountCommaSeparatedItems(sourceLine, openParenthesis, allowAngleBrackets: false, out argumentCount); - } - - private static bool TryGetCSharpDefinitionParameterCount(SymbolResult definition, out int parameterCount) - { - parameterCount = 0; - if (!string.Equals(definition.Lang, "csharp", StringComparison.OrdinalIgnoreCase) || - string.IsNullOrWhiteSpace(definition.Signature)) - { - return false; - } - - var nameStart = FindIdentifierOccurrence(definition.Signature, definition.Name, 0); - if (nameStart < 0) - return false; - var openParenthesis = definition.Signature.IndexOf('(', nameStart + definition.Name.Length); - return openParenthesis >= 0 && - TryCountCommaSeparatedItems(definition.Signature, openParenthesis, allowAngleBrackets: true, out parameterCount); - } - - private static bool TryCountCommaSeparatedItems( - string text, - int openParenthesis, - bool allowAngleBrackets, - out int itemCount) - { - itemCount = 0; - var parenthesisDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - var angleDepth = 0; - var hasItemContent = false; - for (var index = openParenthesis + 1; index < text.Length; index++) - { - var value = text[index]; - if (value is '\'' or '"' || - (value == '/' && index + 1 < text.Length && text[index + 1] is '/' or '*')) - { - return false; - } - - switch (value) - { - case '(': - parenthesisDepth++; - hasItemContent = true; - break; - case ')' when parenthesisDepth > 0: - parenthesisDepth--; - hasItemContent = true; - break; - case ')' when bracketDepth == 0 && braceDepth == 0 && angleDepth == 0: - itemCount = hasItemContent ? itemCount + 1 : 0; - return true; - case '[': - bracketDepth++; - hasItemContent = true; - break; - case ']' when bracketDepth > 0: - bracketDepth--; - hasItemContent = true; - break; - case '{': - braceDepth++; - hasItemContent = true; - break; - case '}' when braceDepth > 0: - braceDepth--; - hasItemContent = true; - break; - case '<' when allowAngleBrackets: - angleDepth++; - hasItemContent = true; - break; - case '>' when allowAngleBrackets && angleDepth > 0: - angleDepth--; - hasItemContent = true; - break; - case '<' or '>': - return false; - case ',' when parenthesisDepth == 0 && bracketDepth == 0 && braceDepth == 0 && angleDepth == 0: - if (!hasItemContent) - return false; - itemCount++; - hasItemContent = false; - break; - default: - hasItemContent |= !char.IsWhiteSpace(value); - break; - } - } - - return false; - } - - private List PreferDefinitionAtPosition( - List definitions, - PositionTokenContext context) - { - var positioned = FindDefinitionsAtPosition(definitions, context); - return positioned.Count > 0 ? positioned : definitions; - } - - private List FindDefinitionsAtPosition( - List definitions, - PositionTokenContext context) - { - var sourceLine = context.Line + 1; - return definitions.Where(definition => - { - var identifier = GetSymbolIdentifierPosition(definition, context.ResolvedPath); - if (identifier.Line != sourceLine) - return false; - - var definitionStart = identifier.StartColumn - 1; - var definitionEnd = identifier.EndColumn - 1; - return context.StartCharacter < definitionEnd && context.EndCharacter > definitionStart; - }).ToList(); - } - - private static bool HasSingleLspDefinitionTarget(IReadOnlyList definitions) - { - if (definitions.Count <= 1) - return true; - - var firstKey = BuildLspDefinitionTargetKey(definitions[0]); - return definitions.Skip(1).All(definition => string.Equals(BuildLspDefinitionTargetKey(definition), firstKey, StringComparison.Ordinal)); - } - - private static string BuildLspDefinitionTargetKey(DefinitionResult definition) - => string.Join('\0', definition.Path, definition.Kind, definition.ContainerKind, definition.ContainerName, definition.Name); - - private bool TryExtractPositionToken(JsonElement root, out PositionTokenContext context, out string? failureReason) - { - context = default; - failureReason = null; - var path = GetDocumentPath(root); - var line = GetInt32(root, "params", "position", "line"); - var character = GetInt32(root, "params", "position", "character"); - if (line < 0 || character < 0) - { - failureReason = FailureInvalidPosition; - return false; - } - - if (!TryResolveDocumentPath(path, out var resolvedPath, out var projectRelativePath, out var workspaceRoot, out failureReason)) - return false; - - var indexedPath = ResolveIndexedPath(path, resolvedPath, projectRelativePath, workspaceRoot); - if (indexedPath == null) - { - failureReason = FailureFileNotIndexed; - return false; - } - - var indexedPathRoot = _projectRoot == null ? workspaceRoot : null; - if (!TryResolveIndexedFilePath(indexedPath, indexedPathRoot, out var indexedFullPath)) - { - failureReason = FailureIndexedFileUnresolved; - return false; - } - - if (!string.Equals(resolvedPath, indexedFullPath, _pathStringComparison)) - { - failureReason = FailurePathCasingMismatch; - return false; - } - - if (!TryReadPositionLine(indexedFullPath, line, out var sourceLine, out failureReason)) - return false; - - var token = ExtractTokenAtUtf16Position(sourceLine, character); - if (string.IsNullOrWhiteSpace(token)) - { - failureReason = FailureNoTokenAtPosition; - return false; - } - - var (startCharacter, endCharacter) = FindTokenRangeAtUtf16Position(sourceLine, character); - context = new PositionTokenContext(token, indexedFullPath, indexedPath, workspaceRoot, line, startCharacter, endCharacter); - return true; - } - - private bool TryResolveIndexedDocument(JsonElement root, out IndexedDocumentContext context) - { - context = default; - var documentPath = GetDocumentPath(root); - if (!TryResolveDocumentPath(documentPath, out var resolvedPath, out var projectRelativePath, out var workspaceRoot)) - return false; - - var indexedPath = ResolveIndexedPath(documentPath, resolvedPath, projectRelativePath, workspaceRoot); - if (indexedPath == null) - return false; - - var indexedPathRoot = _projectRoot == null ? workspaceRoot : null; - if (!TryResolveIndexedFilePath(indexedPath, indexedPathRoot, out var indexedFullPath)) - return false; - - if (!string.Equals(resolvedPath, indexedFullPath, _pathStringComparison)) - return false; - - context = new IndexedDocumentContext(documentPath, resolvedPath, indexedPath, workspaceRoot); - return true; - } - - private List GetDocumentSymbols(string indexedPath, int limit, int? startLine = null, int? endLine = null) - => _reader.SearchSymbols((string?)null, limit, pathPatterns: [indexedPath], startLine: startLine, endLine: endLine) - .OrderBy(s => s.StartLine) - .ThenByDescending(s => s.EndLine) - .ThenBy(s => s.ContainerName == null ? 0 : 1) - .ThenBy(s => s.Name, StringComparer.Ordinal) - .ToList(); - - private bool TryReadPositionLine(string path, int targetLine, out string sourceLine, out string? failureReason) - { - if (_liveDocumentStore.TryGetText(Path.GetFullPath(path), out var liveText)) - return TryReadPositionLineFromText(liveText, targetLine, out sourceLine, out failureReason); - - return TryReadPositionLineFromFile(path, targetLine, out sourceLine, out failureReason); - } - - private bool TryReadPositionLineCached( - string path, - int targetLine, - Dictionary? lineCache, - out string sourceLine) - { - if (targetLine < 0) - { - sourceLine = string.Empty; - return false; - } - - if (lineCache != null && lineCache.TryGetValue(targetLine, out var cachedLine)) - { - sourceLine = cachedLine ?? string.Empty; - return cachedLine != null; - } - - if (lineCache is { Count: 0 } && TryReadAllPositionLines(path, out var sourceLines)) - { - for (var line = 0; line < sourceLines.Count; line++) - lineCache[line] = sourceLines[line]; - - if (lineCache.TryGetValue(targetLine, out cachedLine)) - { - sourceLine = cachedLine ?? string.Empty; - return cachedLine != null; - } - - sourceLine = string.Empty; - return false; - } - - var found = TryReadPositionLine(path, targetLine, out sourceLine, out _); - if (lineCache != null) - lineCache[targetLine] = found ? sourceLine : null; - return found; - } - - private bool TryReadAllPositionLines(string path, out IReadOnlyList sourceLines) - { - sourceLines = []; - if (_liveDocumentStore.TryGetText(Path.GetFullPath(path), out var liveText)) - { - if (Encoding.UTF8.GetByteCount(liveText) > MaxPositionDocumentBytes) - return false; - sourceLines = SplitPositionLines(liveText); - return true; - } - - return TryReadAllPositionLinesFromFile(path, out sourceLines, out _); - } - - internal static bool TryReadAllPositionLinesFromFile( - string path, - out IReadOnlyList sourceLines, - out string? failureReason) - { - sourceLines = []; - failureReason = null; - try - { - using var stream = BoundedFile.OpenReadForLengthCheckedText(path); - if (stream.Length > MaxPositionDocumentBytes) - { - failureReason = FailurePositionFileTooLarge; - return false; - } - - PositionFileLengthCheckedForTesting?.Invoke(path); - using var boundedStream = new PositionFileReadStream(stream, MaxPositionDocumentBytes); - using var reader = new StreamReader( - boundedStream, - Encoding.UTF8, - detectEncodingFromByteOrderMarks: true, - bufferSize: BoundedFile.SmallReadBufferSize); - sourceLines = ReadPositionLines(reader); - return true; - } - catch (PositionFileTooLargeException) - { - failureReason = FailurePositionFileTooLarge; - return false; - } - catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) - { - failureReason = FailurePositionFileUnreadable; - return false; - } - } - - private static IReadOnlyList ReadPositionLines(TextReader reader) - { - var lines = new List(); - var line = new StringBuilder(); - var lineLength = 0; - var lineTooLong = false; - var previousWasCarriageReturn = false; - var buffer = new char[4096]; - while (true) - { - var read = reader.Read(buffer, 0, buffer.Length); - if (read == 0) - break; - - for (var index = 0; index < read; index++) - { - var value = buffer[index]; - if (previousWasCarriageReturn) - { - previousWasCarriageReturn = false; - if (value == '\n') - continue; - } - - if (value is '\r' or '\n') - { - lines.Add(lineTooLong ? null : line.ToString()); - line.Clear(); - lineLength = 0; - lineTooLong = false; - previousWasCarriageReturn = value == '\r'; - continue; - } - - lineLength++; - if (lineLength <= MaxPositionLineChars) - line.Append(value); - else if (!lineTooLong) - { - line.Clear(); - lineTooLong = true; - } - } - } - - lines.Add(lineTooLong ? null : line.ToString()); - return lines; - } - - private static IReadOnlyList SplitPositionLines(string text) - { - using var reader = new StringReader(text); - return ReadPositionLines(reader); - } - - private sealed class PositionFileTooLargeException : IOException - { - } - - private sealed class PositionFileReadStream(Stream inner, long maxBytes) : Stream - { - private long _remaining = maxBytes; - private bool _disposed; - - public override bool CanRead => !_disposed && inner.CanRead; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => throw new NotSupportedException(); - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override void Flush() - { - } - - public override int Read(byte[] buffer, int offset, int count) - { - ObjectDisposedException.ThrowIf(_disposed, this); - if (_remaining > 0) - { - var read = inner.Read(buffer, offset, (int)Math.Min(count, _remaining)); - _remaining -= read; - return read; - } - - return ProbeForOverflow(); - } - - public override int Read(Span buffer) - { - ObjectDisposedException.ThrowIf(_disposed, this); - if (_remaining > 0) - { - var read = inner.Read(buffer[..(int)Math.Min(buffer.Length, _remaining)]); - _remaining -= read; - return read; - } - - return ProbeForOverflow(); - } - - private int ProbeForOverflow() - { - Span probe = stackalloc byte[1]; - if (inner.Read(probe) != 0) - throw new PositionFileTooLargeException(); - return 0; - } - - public override long Seek(long offset, SeekOrigin origin) - => throw new NotSupportedException(); - - public override void SetLength(long value) - => throw new NotSupportedException(); - - public override void Write(byte[] buffer, int offset, int count) - => throw new NotSupportedException(); - - protected override void Dispose(bool disposing) - { - _disposed = true; - base.Dispose(disposing); - } - } - - private static bool TryReadPositionLineFromText(string text, int targetLine, out string sourceLine, out string? failureReason) - { - sourceLine = string.Empty; - failureReason = null; - if (targetLine < 0) - { - failureReason = FailureInvalidPosition; - return false; - } - - var currentLine = 0; - var lineStart = 0; - for (var i = 0; i <= text.Length; i++) - { - var atEnd = i == text.Length; - var isLineBreak = !atEnd && (text[i] == '\r' || text[i] == '\n'); - if (!atEnd && !isLineBreak) - continue; - - if (currentLine == targetLine) - { - var length = i - lineStart; - if (length > MaxPositionLineChars) - { - failureReason = FailurePositionLineTooLong; - return false; - } - - sourceLine = text.Substring(lineStart, length); - return true; - } - - if (atEnd) - break; - - if (text[i] == '\r' && i + 1 < text.Length && text[i + 1] == '\n') - i++; - currentLine++; - lineStart = i + 1; - } - - failureReason = FailurePositionLineMissing; - return false; - } - - private static bool TryReadPositionLineFromFile(string path, int targetLine, out string sourceLine, out string? failureReason) - { - sourceLine = string.Empty; - failureReason = null; - try - { - using var stream = BoundedFile.OpenReadForLengthCheckedText(path); - if (stream.Length > MaxPositionDocumentBytes) - { - failureReason = FailurePositionFileTooLarge; - return false; - } - - using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - var currentLine = 0; - var currentLineLength = 0; - StringBuilder? builder = targetLine == 0 ? new StringBuilder() : null; - while (true) - { - var next = reader.Read(); - if (stream.Position > MaxPositionDocumentBytes) - { - failureReason = FailurePositionFileTooLarge; - return false; - } - - if (next < 0) - { - if (currentLine == targetLine && currentLineLength <= MaxPositionLineChars && builder != null) - { - sourceLine = builder.ToString(); - return true; - } - - failureReason = FailurePositionLineMissing; - return false; - } - - var c = (char)next; - if (c == '\r' || c == '\n') - { - if (c == '\r' && reader.Peek() == '\n') - { - reader.Read(); - if (stream.Position > MaxPositionDocumentBytes) - { - failureReason = FailurePositionFileTooLarge; - return false; - } - } - - if (currentLine == targetLine) - { - sourceLine = builder?.ToString() ?? string.Empty; - return true; - } - - currentLine++; - currentLineLength = 0; - builder = currentLine == targetLine ? new StringBuilder() : null; - continue; - } - - currentLineLength++; - if (currentLineLength > MaxPositionLineChars) - { - if (currentLine == targetLine) - { - failureReason = FailurePositionLineTooLong; - return false; - } - continue; - } - - builder?.Append(c); - } - } - catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) - { - failureReason = FailurePositionFileUnreadable; - return false; - } - } - - internal static string? ExtractTokenAtUtf16Position(string line, int character) - { - if (character < 0) - return null; - var index = Math.Min(character, line.Length); - while (index > 0 && index == line.Length) - index--; - if (index < line.Length && !IsTokenChar(line[index]) && index > 0 && IsTokenChar(line[index - 1])) - index--; - if (index >= line.Length || !IsTokenChar(line[index])) - return null; - - var start = index; - while (start > 0 && IsTokenChar(line[start - 1])) - start--; - var end = index + 1; - while (end < line.Length && IsTokenChar(line[end])) - end++; - return line[start..end].TrimStart('@'); - } - - private static (int Start, int End) FindTokenRangeAtUtf16Position(string line, int character) - { - if (character < 0) - return (0, 0); - var index = Math.Min(character, line.Length); - while (index > 0 && index == line.Length) - index--; - if (index < line.Length && !IsTokenChar(line[index]) && index > 0 && IsTokenChar(line[index - 1])) - index--; - if (index >= line.Length || !IsTokenChar(line[index])) - return (Math.Max(0, Math.Min(character, line.Length)), Math.Max(0, Math.Min(character, line.Length))); - - var start = index; - while (start > 0 && IsTokenChar(line[start - 1])) - start--; - var end = index + 1; - while (end < line.Length && IsTokenChar(line[end])) - end++; - return (start, end); - } - - private static bool IsTokenChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '@'; - - private bool MatchesDocumentPath(string indexedPath, string documentPath, string? projectRelativePath, string resolvedPath, string? workspaceRoot) - { - if (TryResolveIndexedFilePath(indexedPath, null, out var indexedFullPath) - && string.Equals(resolvedPath, indexedFullPath, _pathStringComparison)) - return true; - - if (Path.IsPathRooted(indexedPath)) - return false; - - var normalizedIndexed = indexedPath.Replace('\\', '/'); - if (projectRelativePath != null) - return _projectRoot == null - && workspaceRoot != null - && string.Equals(normalizedIndexed, projectRelativePath.Replace('\\', '/'), _pathStringComparison); - - if (string.Equals(indexedPath, documentPath, StringComparison.Ordinal)) - return true; - - if (_projectRoot == null && workspaceRoot == null) - return false; - - var normalizedDocument = documentPath.Replace('\\', '/'); - return normalizedDocument.EndsWith("/" + normalizedIndexed, StringComparison.Ordinal); - } - - private string? ResolveIndexedPath(string documentPath) - { - if (!TryResolveDocumentPath(documentPath, out var resolvedPath, out var projectRelativePath, out var workspaceRoot)) - return null; - - return ResolveIndexedPath(documentPath, resolvedPath, projectRelativePath, workspaceRoot); - } - - private string? ResolveIndexedPath(string documentPath, string resolvedPath, string? projectRelativePath, string? workspaceRoot) - { - if (projectRelativePath != null) - { - var exactPath = projectRelativePath.Replace('\\', '/'); - var exactFile = _reader.GetFileByPath(exactPath); - if (exactFile != null && MatchesDocumentPath(exactFile.Path, documentPath, projectRelativePath, resolvedPath, workspaceRoot)) - return exactFile.Path; - } - - var fileName = Path.GetFileName(documentPath); - if (string.IsNullOrEmpty(fileName)) - fileName = Path.GetFileName(resolvedPath); - if (string.IsNullOrEmpty(fileName)) - return null; - - var files = _reader.ListFiles(fileName, MaxDocumentPathFallbackCandidates); - var matches = files - .Where(file => MatchesDocumentPath(file.Path, documentPath, projectRelativePath, resolvedPath, workspaceRoot)) - .Take(2) - .ToList(); - return matches.Count == 1 ? matches[0].Path : null; - } - - private bool TryResolveDocumentPath(string documentPath, out string resolvedPath, out string? projectRelativePath) => - TryResolveDocumentPath(documentPath, out resolvedPath, out projectRelativePath, out _, out _); - - private bool TryResolveDocumentPath( - string documentPath, - out string resolvedPath, - out string? projectRelativePath, - out string? workspaceRoot) => - TryResolveDocumentPath(documentPath, out resolvedPath, out projectRelativePath, out workspaceRoot, out _); - - private bool TryResolveDocumentPath( - string documentPath, - out string resolvedPath, - out string? projectRelativePath, - out string? workspaceRoot, - out string? failureReason) - { - resolvedPath = string.Empty; - projectRelativePath = null; - workspaceRoot = null; - failureReason = null; - try - { - resolvedPath = Path.IsPathRooted(documentPath) - ? Path.GetFullPath(documentPath) - : Path.GetFullPath(documentPath, _projectRoot ?? Environment.CurrentDirectory); - } - catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) - { - failureReason = FailureDocumentPathUnresolved; - return false; - } - - if (_workspaceFolders.Count == 0) - return true; - - if (TryGetWorkspaceRelativePath(resolvedPath, out projectRelativePath, out workspaceRoot)) - return true; - - failureReason = FailureOutsideProject; - return false; - } - - private bool TryResolveIndexedFilePath(string indexedPath, out string resolvedPath) - => TryResolveIndexedFilePath(indexedPath, null, out resolvedPath); - - private bool TryResolveIndexedFilePath(string indexedPath, string? workspaceRoot, out string resolvedPath) - { - resolvedPath = string.Empty; - try - { - resolvedPath = Path.IsPathRooted(indexedPath) - ? Path.GetFullPath(indexedPath) - : Path.GetFullPath(indexedPath, workspaceRoot ?? _projectRoot ?? Environment.CurrentDirectory); - return true; - } - catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) - { - return false; - } - } - - private bool TryGetWorkspaceRelativePath(string resolvedPath, out string? relativePath, out string? workspaceRoot) - { - relativePath = null; - workspaceRoot = null; - foreach (var candidateRoot in _workspaceFolders) - { - if (!TryGetRelativePath(candidateRoot, resolvedPath, out var candidateRelativePath)) - continue; - - relativePath = candidateRelativePath; - workspaceRoot = candidateRoot; - return true; - } - - return false; - } - - private static bool TryGetRelativePath(string root, string resolvedPath, out string? relativePath) - { - relativePath = null; - try - { - var normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); - var normalizedPath = Path.GetFullPath(resolvedPath); - if (PathCasing.PathsEqual(normalizedRoot, normalizedPath) - || !PathCasing.IsPathEqualOrParent(normalizedRoot, normalizedPath)) - { - return false; - } - - var relative = Path.GetRelativePath(normalizedRoot, normalizedPath); - if (relative == "." - || relative == ".." - || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) - || relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) - || Path.IsPathRooted(relative)) - { - return false; - } - - relativePath = relative; - return true; - } - catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) - { - return false; - } - } - - private JsonObject ToWorkspaceSymbol( - SymbolResult symbol, - (int Line, int StartColumn, int EndColumn) identifier) - { - return new JsonObject - { - ["name"] = symbol.Name, - ["kind"] = SymbolKind(symbol.Kind), - ["location"] = ToLocation(symbol.Path, identifier.Line, identifier.StartColumn, identifier.Line, identifier.EndColumn), - ["containerName"] = symbol.ContainerName, - }; - } - - private JsonObject ToDocumentSymbol( - IndexedDocumentContext document, - SymbolResult symbol, - Dictionary lineCache) - { - var identifier = GetSymbolIdentifierPosition(symbol, document.ResolvedPath, lineCache); - var rangeStartLine = symbol.StartLine > 0 ? Math.Min(symbol.StartLine, identifier.Line) : identifier.Line; - var rangeEndLine = symbol.EndLine > 0 ? Math.Max(symbol.EndLine, identifier.Line) : identifier.Line; - var rangeEndColumn = rangeEndLine == identifier.Line ? identifier.EndColumn : 1; - return new JsonObject - { - ["name"] = symbol.Name, - ["kind"] = SymbolKind(symbol.Kind), - ["range"] = ToRange(rangeStartLine, 1, rangeEndLine, rangeEndColumn), - ["selectionRange"] = ToRange(identifier.Line, identifier.StartColumn, identifier.Line, identifier.EndColumn), - ["detail"] = TruncateDocumentSymbolDetail(symbol.Signature), - }; - } - - private JsonObject ToDocumentSymbolInformation( - IndexedDocumentContext document, - SymbolResult symbol, - Dictionary lineCache) - { - var identifier = GetSymbolIdentifierPosition(symbol, document.ResolvedPath, lineCache); - return new JsonObject - { - ["name"] = symbol.Name, - ["kind"] = SymbolKind(symbol.Kind), - ["location"] = ToLocation( - symbol.Path, - identifier.Line, - identifier.StartColumn, - identifier.Line, - identifier.EndColumn, - document.WorkspaceRoot), - ["containerName"] = symbol.ContainerName, - }; - } - - private (int Line, int StartColumn, int EndColumn) GetSymbolIdentifierPosition(SymbolResult symbol) - { - var resolvedPath = TryResolveIndexedFilePath(symbol.Path, out var path) ? path : null; - return GetSymbolIdentifierPosition(symbol, resolvedPath); - } - - private (int Line, int StartColumn, int EndColumn) GetSymbolIdentifierPosition( - SymbolResult symbol, - PositionTokenContext context) - { - var indexedPathRoot = _projectRoot == null ? context.WorkspaceRoot : null; - var resolvedPath = TryResolveIndexedFilePath(symbol.Path, indexedPathRoot, out var path) ? path : null; - return GetSymbolIdentifierPosition(symbol, resolvedPath); - } - - private (int Line, int StartColumn, int EndColumn) GetSymbolIdentifierPosition( - SymbolResult symbol, - string? resolvedPath, - Dictionary? lineCache = null) - { - var line = symbol.Line > 0 ? symbol.Line : Math.Max(1, symbol.StartLine); - var startCharacter = resolvedPath == null - ? Math.Max(0, symbol.StartColumn ?? 0) - : FindSymbolStartCharacter(resolvedPath, symbol, lineCache); - return (line, startCharacter + 1, startCharacter + Math.Max(symbol.Name.Length, 1) + 1); - } - - private static string? TruncateDocumentSymbolDetail(string? detail) - { - if (detail == null || detail.Length <= MaxDocumentSymbolDetailChars) - return detail; - return detail[..(MaxDocumentSymbolDetailChars - "...".Length)] + "..."; - } - - private JsonObject ToLocation(string path, int startLine, int startColumn, int endLine, int endColumn, string? workspaceRoot = null) => new() - { - ["uri"] = PathToUri(path, workspaceRoot ?? _projectRoot), - ["range"] = ToRange(startLine, startColumn, endLine, endColumn), - }; - - private static JsonObject ToRange(int startLine, int startColumn, int endLine, int endColumn) => new() - { - ["start"] = new JsonObject - { - ["line"] = Math.Max(startLine - 1, 0), - ["character"] = Math.Max(startColumn - 1, 0), - }, - ["end"] = new JsonObject - { - ["line"] = Math.Max(endLine - 1, 0), - ["character"] = Math.Max(endColumn - 1, 0), - }, - }; - - private static JsonObject ToPosition(int line, int column) => new() - { - ["line"] = Math.Max(line - 1, 0), - ["character"] = Math.Max(column - 1, 0), - }; - - private static int SymbolKind(string kind) => kind switch - { - "class" => 5, - "function" or "test.method" => 12, - "property" => 7, - "enum" => 10, - "interface" => 11, - "namespace" => 3, - "struct" => 23, - _ => 13, - }; - - private static string GetDocumentPath(JsonElement root) - { - var uri = GetTextDocumentUri(root); - return UriToPath(uri); - } - - private static string GetTextDocumentUri(JsonElement root) - { - if (!TryGet(root, out var value, "params", "textDocument", "uri") || value.ValueKind != JsonValueKind.String) - throw new ArgumentException("textDocument.uri must be a string."); - - var uri = value.GetString(); - if (string.IsNullOrWhiteSpace(uri)) - throw new ArgumentException("textDocument.uri is required."); - if (uri.Length > MaxTextDocumentUriChars) - throw new ArgumentException( - $"textDocument.uri is too long. Max length is {MaxTextDocumentUriChars} characters; actual length is {uri.Length}."); - return uri; - } - - private static string? GetString(JsonElement root, params string[] path) - { - if (!TryGet(root, out var value, path) || value.ValueKind != JsonValueKind.String) - return null; - return value.GetString(); - } - - private static bool? GetBool(JsonElement root, params string[] path) - { - if (!TryGet(root, out var value, path)) - return null; - return value.ValueKind switch - { - JsonValueKind.True => true, - JsonValueKind.False => false, - _ => null, - }; - } - - private static int? GetLimit(JsonElement root, int defaultLimit, int maxLimit, params string[] path) - { - if (!TryGet(root, out var value, path)) - return null; - if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var limit)) - return defaultLimit; - return Math.Clamp(limit, 0, maxLimit); - } - - private static int GetInt32(JsonElement root, params string[] path) - { - if (!TryGet(root, out var value, path) || value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var result)) - return -1; - return result; - } - - private static bool TryGet(JsonElement root, out JsonElement value, params string[] path) - { - value = root; - foreach (var segment in path) - { - if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(segment, out value)) - return false; - } - return true; - } - - internal static string PathToUri(string path, string? projectRoot = null) - => CodeIndex.FileUriPolicy.PathToFileUri(path, projectRoot); - - internal static string UriToPath(string uri) - => CodeIndex.FileUriPolicy.AbsoluteFileUriToPath(uri); - - private void CaptureInitializeWorkspaceFolders(JsonElement root) - { - if (!TryGet(root, out var folders, "params", "workspaceFolders") || folders.ValueKind != JsonValueKind.Array) - return; - - foreach (var folder in folders.EnumerateArray()) - { - if (_workspaceFolders.Count >= MaxWorkspaceFolders) - break; - if (TryGetWorkspaceFolderPath(folder, out var path) - && !_workspaceFolders.Any(existing => string.Equals(existing, path, _pathStringComparison))) - { - _workspaceFolders.Add(path); - } - } - - Activity.Current?.SetTag("lsp.workspace_folder_count", _workspaceFolders.Count); - } - - private static bool TryGetWorkspaceFolderPath(JsonElement folder, out string path) - { - path = string.Empty; - if (folder.ValueKind != JsonValueKind.Object - || !folder.TryGetProperty("uri", out var uriElement) - || uriElement.ValueKind != JsonValueKind.String) - { - return false; - } - - var uri = uriElement.GetString(); - if (string.IsNullOrWhiteSpace(uri) || uri.Length > MaxTextDocumentUriChars) - return false; - - try - { - path = Path.GetFullPath(UriToPath(uri)); - return true; - } - catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) - { - return false; - } - } - - private static JsonObject Result(JsonNode? id, JsonNode? result) => new() - { - ["jsonrpc"] = "2.0", - ["id"] = id, - ["result"] = result, - }; - - private static JsonObject Error(JsonNode? id, int code, string message) => new() - { - ["jsonrpc"] = "2.0", - ["id"] = id, - ["error"] = new JsonObject - { - ["code"] = code, - ["message"] = message, - }, - }; - - /// - /// Compatibility wrapper that reads without caller cancellation. Prefer - /// for cancellable transports. - /// caller cancellation を持たない互換 wrapper。キャンセル可能な transport では - /// を使う。 - /// - internal static bool TryReadMessage(Stream input, out string payload) => - TryReadMessage(input, out payload, CancellationToken.None); - - internal static bool TryReadMessage(Stream input, out string payload, CancellationToken cancellationToken) - => TryReadMessage(input, out payload, out _, cancellationToken); - - internal static bool TryReadMessage( - Stream input, - out string payload, - out LspMessageReadDiagnostic? diagnostic, - CancellationToken cancellationToken = default) - { - var success = LspProtocol.TryReadMessage(input, out payload, out var protocolDiagnostic, cancellationToken); - diagnostic = protocolDiagnostic.HasValue ? ToServerDiagnostic(protocolDiagnostic.Value) : null; - return success; - } - - internal static async ValueTask TryReadMessageAsync( - Stream input, - CancellationToken cancellationToken = default) - { - var result = await LspProtocol.TryReadMessageAsync(input, cancellationToken).ConfigureAwait(false); - return new MessageReadResult(result.Success, result.Payload); - } - - private static LspMessageReadDiagnostic ToServerDiagnostic(LspProtocol.ReadDiagnostic diagnostic) - => new(diagnostic.Code, diagnostic.Message, diagnostic.ContentLength, diagnostic.MaxContentLength); - - private async Task WriteResponseMessageAsync( - Stream output, - SemaphoreSlim outputGate, - JsonObject response, - CancellationToken cancellationToken) - { - await outputGate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - var payload = response.ToJsonString(_jsonOptions); - if (await LspProtocol.TryWriteMessageAsync(output, payload, cancellationToken).ConfigureAwait(false)) - return; - - var id = response["id"]?.DeepClone(); - var errorPayload = Error(id, JsonRpcInternalErrorCode, "Response too large").ToJsonString(_jsonOptions); - if (!await LspProtocol.TryWriteMessageAsync(output, errorPayload, cancellationToken).ConfigureAwait(false)) - throw new InvalidOperationException("LSP response error exceeded the response frame byte limit."); - } - finally - { - outputGate.Release(); - } - } - - private async Task WriteServerNotificationAsync( - Stream output, - SemaphoreSlim outputGate, - JsonObject notification, - CancellationToken cancellationToken) - { - await outputGate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - var payload = notification.ToJsonString(_jsonOptions); - if (!await LspProtocol.TryWriteMessageAsync(output, payload, cancellationToken).ConfigureAwait(false)) - throw new InvalidOperationException("LSP server notification exceeded the response frame byte limit."); - } - finally - { - outputGate.Release(); - } - } - - internal static void WriteMessage(Stream output, string payload) => - LspProtocol.WriteMessage(output, payload); - - internal static bool TryWriteMessage(Stream output, string payload, out int bodyBytes) => - LspProtocol.TryWriteMessage(output, payload, out bodyBytes); - - public void Dispose() - { - _ = _shutdownRequested; - if (_ownedQueryDb != null) - { - _reader.Dispose(); - _ownedQueryDb.Dispose(); - _ownedQueryDb = null; - } - } } From c766a40496973798f249177b9c84cec9f1790deb Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:12:54 +0900 Subject: [PATCH 039/101] Split reference extraction support concerns --- .../ReferenceExtractor.CSharpPatterns.cs | 555 +++ .../ReferenceExtractor.CSharpTypeNames.cs | 496 +++ .../ReferenceExtractor.Configuration.cs | 516 +++ .../ReferenceExtractor.Definitions.cs | 507 +++ .../ReferenceExtractor.LanguageSupport.cs | 197 + .../References/ReferenceExtractor.Patterns.cs | 417 ++ .../ReferenceExtractor.PythonLogicalLines.cs | 446 ++ .../ReferenceExtractor.ReferenceRecords.cs | 540 +++ .../Indexer/References/ReferenceExtractor.cs | 3586 ----------------- 9 files changed, 3674 insertions(+), 3586 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CSharpPatterns.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CSharpTypeNames.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.Configuration.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.Definitions.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.LanguageSupport.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.Patterns.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.PythonLogicalLines.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.ReferenceRecords.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CSharpPatterns.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CSharpPatterns.cs new file mode 100644 index 000000000..7d72273c5 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CSharpPatterns.cs @@ -0,0 +1,555 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + internal static void EmitCSharpTypePositionReferences( + string preparedLine, + string originalLine, + IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, + IReadOnlyDictionary> csharpQualifiedTypePatternLookup, + IReadOnlyList csharpUsingAliases, + IReadOnlyList csharpUsingStatics, + Func hasActiveSameFileCSharpTypeCandidate, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + SymbolRecord? container, + CSharpWhereConstraintState pendingWhereConstraint, + ref CSharpMultiLineTypePatternState pendingCSharpMultiLineTypePattern) + { + var csharpGenericParameterNames = CollectCSharpGenericParameterNamesForDeclaration(preparedLine); + TryEmitCSharpBaseListReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, csharpGenericParameterNames); + EmitCSharpWhereConstraintReferences( + preparedLine, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + csharpGenericParameterNames, + pendingWhereConstraint); + EmitDeclarationTypeReferences("csharp", preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, csharpGenericParameterNames); + + foreach (Match match in CSharpIsAsTypeTestRegex.Matches(preparedLine)) + { + var typeGroup = match.Groups["type"]; + int continuationIndex = SkipWhitespace(preparedLine, typeGroup.Index + typeGroup.Length); + if (TryEmitCSharpLogicalTypePatternHeads( + preparedLine, + typeGroup.Value, + typeGroup.Index, + continuationIndex, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpQualifiedTypePatternLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate, + (logicalTypeExpression, logicalTypeIndex) => AddTypeExpressionSegments( + references, + seen, + fileId, + logicalTypeExpression, + logicalTypeIndex, + context, + lineNumber, + resolveContainerForColumn(logicalTypeIndex), + "csharp", + csharpGenericParameterNames))) + { + continue; + } + + if (IsCSharpNonTypePatternExpression(typeGroup.Value) + || IsCSharpConstantPatternMemberHead( + typeGroup.Value, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate) + || IsCSharpLogicalConstantPatternAtCursor( + preparedLine, + typeGroup.Value, + continuationIndex, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpQualifiedTypePatternLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate)) + { + continue; + } + + AddTypeExpressionSegments( + references, + seen, + fileId, + typeGroup.Value, + typeGroup.Index, + context, + lineNumber, + resolveContainerForColumn(typeGroup.Index), + "csharp", + csharpGenericParameterNames); + } + + EmitCSharpCaseTypePatternReferences( + preparedLine, + originalLine, + csharpQualifiedConstantPatternMemberLookup, + csharpQualifiedTypePatternLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + ref pendingCSharpMultiLineTypePattern); + } + + internal static void AdvanceCSharpMultiLineTypePatternState( + string preparedLine, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, + IReadOnlyList csharpUsingAliases, + IReadOnlyList csharpUsingStatics, + Func hasActiveSameFileCSharpTypeCandidate, + List references, + ReferenceDedupeSet seen, + long fileId, + ref CSharpMultiLineTypePatternState state) + { + if (!state.WaitingForHead && state.PendingTypeExpression == null) + return; + + var cursor = SkipWhitespace(preparedLine, 0); + if (state.WaitingForHead) + { + if (!TryConsumeCSharpMultiLineTypePatternHead( + preparedLine, + context, + lineNumber, + resolveContainerForColumn, + ref cursor, + ref state)) + { + if (IsStandaloneCSharpMultiLinePatternNegation(preparedLine)) + return; + + state = default; + return; + } + } + else if (!TryConsumeCSharpLogicalPatternKeyword(preparedLine, cursor, out cursor)) + { + FlushPendingCSharpMultiLineTypePatternReference( + ref state, + csharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate, + references, + seen, + fileId); + return; + } + else + { + FlushPendingCSharpMultiLineTypePatternReference( + ref state, + csharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate, + references, + seen, + fileId); + if (!TryConsumeCSharpMultiLineTypePatternHead( + preparedLine, + context, + lineNumber, + resolveContainerForColumn, + ref cursor, + ref state)) + { + state = state with { WaitingForHead = true }; + return; + } + } + + while (TryConsumeCSharpLogicalPatternKeyword( + preparedLine, + SkipWhitespace(preparedLine, state.PendingTypeIndex + state.PendingTypeExpression!.Length), + out cursor)) + { + FlushPendingCSharpMultiLineTypePatternReference( + ref state, + csharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate, + references, + seen, + fileId); + if (!TryConsumeCSharpMultiLineTypePatternHead( + preparedLine, + context, + lineNumber, + resolveContainerForColumn, + ref cursor, + ref state)) + { + state = state with { WaitingForHead = true }; + return; + } + } + } + + private static bool TryConsumeCSharpMultiLineTypePatternHead( + string preparedLine, + string context, + int lineNumber, + Func resolveContainerForColumn, + ref int cursor, + ref CSharpMultiLineTypePatternState state) + { + cursor = SkipWhitespace(preparedLine, cursor); + if (TryConsumeCSharpPatternKeyword(preparedLine, ref cursor, "not")) + cursor = SkipWhitespace(preparedLine, cursor); + + var match = CSharpTypeExpressionAtCursorRegex.Match(preparedLine, cursor); + if (!match.Success) + return false; + + var typeGroup = match.Groups["type"]; + state = new CSharpMultiLineTypePatternState( + WaitingForHead: false, + PendingTypeExpression: typeGroup.Value, + PendingTypeIndex: typeGroup.Index, + PendingTypeLineNumber: lineNumber, + PendingContext: context, + PendingContainer: resolveContainerForColumn(typeGroup.Index)); + cursor = SkipWhitespace(preparedLine, typeGroup.Index + typeGroup.Length); + return true; + } + + internal static void FlushPendingCSharpMultiLineTypePatternReference( + ref CSharpMultiLineTypePatternState state, + IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, + IReadOnlyList csharpUsingAliases, + IReadOnlyList csharpUsingStatics, + Func hasActiveSameFileCSharpTypeCandidate, + List references, + ReferenceDedupeSet seen, + long fileId) + { + if (state.PendingTypeExpression == null || state.PendingContext == null) + { + state = default; + return; + } + + if (!IsCSharpNonTypePatternExpression(state.PendingTypeExpression) + && !IsCSharpConstantPatternMemberHead( + state.PendingTypeExpression, + state.PendingTypeLineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate)) + { + AddTypeExpressionSegments( + references, + seen, + fileId, + state.PendingTypeExpression, + state.PendingTypeIndex, + state.PendingContext, + state.PendingTypeLineNumber, + state.PendingContainer, + "csharp"); + } + + state = default; + } + + private static bool IsStandaloneCSharpMultiLinePatternNegation(string preparedLine) + { + var cursor = SkipWhitespace(preparedLine, 0); + if (!TryConsumeCSharpPatternKeyword(preparedLine, ref cursor, "not")) + return false; + + return SkipWhitespace(preparedLine, cursor) >= preparedLine.Length; + } + + internal static void StartWaitingForCSharpMultiLineTypePatternHead(ref CSharpMultiLineTypePatternState state) + { + state = new CSharpMultiLineTypePatternState( + WaitingForHead: true, + PendingTypeExpression: null, + PendingTypeIndex: 0, + PendingTypeLineNumber: 0, + PendingContext: null, + PendingContainer: null); + } + + private static void EmitCSharpCaseTypePatternReferences( + string preparedLine, + string originalLine, + IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, + IReadOnlyDictionary> csharpQualifiedTypePatternLookup, + IReadOnlyList csharpUsingAliases, + IReadOnlyList csharpUsingStatics, + Func hasActiveSameFileCSharpTypeCandidate, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + ref CSharpMultiLineTypePatternState pendingCSharpMultiLineTypePattern) + { + foreach (Match caseMatch in CSharpCaseLabelRegex.Matches(preparedLine)) + { + int cursor = SkipWhitespace(preparedLine, caseMatch.Index + caseMatch.Length); + bool hadLeadingNot = TryConsumeCSharpPatternKeyword(preparedLine, ref cursor, "not"); + if (hadLeadingNot) + cursor = SkipWhitespace(preparedLine, cursor); + + var typeMatch = CSharpTypeExpressionAtCursorRegex.Match(preparedLine, cursor); + if (!typeMatch.Success) + { + var rawCaseCursor = SkipCSharpTriviaForward(originalLine, caseMatch.Index + caseMatch.Length); + if (TryConsumeLeadingCSharpPatternKeyword(originalLine, ref rawCaseCursor, "not")) + rawCaseCursor = SkipCSharpTriviaForward(originalLine, rawCaseCursor); + + if (HasOnlyTrailingCSharpTrivia(originalLine, rawCaseCursor)) + StartWaitingForCSharpMultiLineTypePatternHead(ref pendingCSharpMultiLineTypePattern); + continue; + } + + var typeGroup = typeMatch.Groups["type"]; + var currentTypeExpression = typeGroup.Value; + var currentTypeIndex = typeGroup.Index; + var currentContinuationIndex = SkipWhitespace(preparedLine, typeGroup.Index + typeGroup.Length); + var sawLogicalKeyword = false; + var waitingForNextHead = false; + + while (TryConsumeCSharpLogicalPatternKeyword(preparedLine, currentContinuationIndex, out var nextHeadCursor)) + { + sawLogicalKeyword = true; + if (!IsCSharpLogicalConstantPatternHead( + preparedLine, + currentTypeExpression, + nextHeadCursor, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpQualifiedTypePatternLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate)) + { + AddTypeExpressionSegments( + references, + seen, + fileId, + currentTypeExpression, + currentTypeIndex, + context, + lineNumber, + resolveContainerForColumn(currentTypeIndex), + "csharp"); + } + + int nextTypeCursor = nextHeadCursor; + if (TryConsumeCSharpPatternKeyword(preparedLine, ref nextTypeCursor, "not")) + nextTypeCursor = SkipWhitespace(preparedLine, nextTypeCursor); + + var nextMatch = CSharpTypeExpressionAtCursorRegex.Match(preparedLine, nextTypeCursor); + if (!nextMatch.Success) + { + var rawNextTypeCursor = SkipCSharpTriviaForward(originalLine, nextHeadCursor); + if (TryConsumeLeadingCSharpPatternKeyword(originalLine, ref rawNextTypeCursor, "not")) + rawNextTypeCursor = SkipCSharpTriviaForward(originalLine, rawNextTypeCursor); + + if (HasOnlyTrailingCSharpTrivia(originalLine, rawNextTypeCursor)) + { + StartWaitingForCSharpMultiLineTypePatternHead(ref pendingCSharpMultiLineTypePattern); + waitingForNextHead = true; + } + break; + } + + var nextTypeGroup = nextMatch.Groups["type"]; + currentTypeExpression = nextTypeGroup.Value; + currentTypeIndex = nextTypeGroup.Index; + currentContinuationIndex = SkipWhitespace(preparedLine, currentTypeIndex + currentTypeExpression.Length); + } + + if (waitingForNextHead) + continue; + + if (sawLogicalKeyword) + { + if (!IsCSharpNonTypePatternExpression(currentTypeExpression) + && !IsCSharpConstantPatternMemberHead( + currentTypeExpression, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate)) + { + AddTypeExpressionSegments( + references, + seen, + fileId, + currentTypeExpression, + currentTypeIndex, + context, + lineNumber, + resolveContainerForColumn(currentTypeIndex), + "csharp"); + } + + continue; + } + + if (!IsCSharpCaseTypePatternContinuation( + preparedLine, + currentTypeExpression, + currentContinuationIndex, + csharpQualifiedConstantPatternMemberLookup, + csharpQualifiedTypePatternLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate, + lineNumber)) + { + continue; + } + + AddTypeExpressionSegments( + references, + seen, + fileId, + currentTypeExpression, + currentTypeIndex, + context, + lineNumber, + resolveContainerForColumn(currentTypeIndex), + "csharp"); + } + } + + private static bool HasOnlyTrailingCSharpTrivia(string text, int cursor) + { + while (cursor < text.Length) + { + if (char.IsWhiteSpace(text[cursor])) + { + cursor++; + continue; + } + + if (cursor + 1 < text.Length + && text[cursor] == '/' + && text[cursor + 1] == '/') + { + return true; + } + + if (cursor + 1 < text.Length + && text[cursor] == '/' + && text[cursor + 1] == '*') + { + var commentEnd = text.IndexOf("*/", cursor + 2, StringComparison.Ordinal); + if (commentEnd < 0) + return true; + + cursor = commentEnd + 2; + continue; + } + + return false; + } + + return true; + } + + private static int SkipCSharpTriviaForward(string text, int cursor) + { + while (cursor < text.Length) + { + if (char.IsWhiteSpace(text[cursor])) + { + cursor++; + continue; + } + + if (cursor + 1 < text.Length + && text[cursor] == '/' + && text[cursor + 1] == '/') + { + return text.Length; + } + + if (cursor + 1 < text.Length + && text[cursor] == '/' + && text[cursor + 1] == '*') + { + var commentEnd = text.IndexOf("*/", cursor + 2, StringComparison.Ordinal); + if (commentEnd < 0) + return text.Length; + + cursor = commentEnd + 2; + continue; + } + + break; + } + + return cursor; + } + + private static bool TryConsumeLeadingCSharpPatternKeyword(string text, ref int cursor, string keyword) + { + if (string.IsNullOrEmpty(keyword)) + return false; + + cursor = SkipCSharpTriviaForward(text, cursor); + if (cursor + keyword.Length > text.Length + || !text.AsSpan(cursor, keyword.Length).Equals(keyword, StringComparison.Ordinal)) + { + return false; + } + + var nextIndex = cursor + keyword.Length; + if (nextIndex < text.Length + && (char.IsLetterOrDigit(text[nextIndex]) || text[nextIndex] == '_')) + { + return false; + } + + cursor = nextIndex; + return true; + } + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CSharpTypeNames.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CSharpTypeNames.cs new file mode 100644 index 000000000..422a43a32 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CSharpTypeNames.cs @@ -0,0 +1,496 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + internal static void AddTypeReferenceSegments( + List references, + ReferenceDedupeSet seen, + long fileId, + string arg, + int argStartInLine, + string context, + int lineNumber, + SymbolRecord? container, + string language) + { + int offset = 0; + var segmentStart = 0; + while (segmentStart <= arg.Length) + { + var dotIndex = arg.IndexOf('.', segmentStart); + var segmentLength = dotIndex < 0 ? arg.Length - segmentStart : dotIndex - segmentStart; + if (segmentLength == 0) + { + offset += 1; // '.' separator / ドット区切り分 + if (dotIndex < 0) + break; + segmentStart = dotIndex + 1; + continue; + } + + var segment = arg.Substring(segmentStart, segmentLength); + var normalizedSegment = language == "csharp" ? NormalizeCSharpIdentifier(segment) : segment; + var isEscapedCSharpIdentifier = language == "csharp" && segment[0] == '@'; + if (!IsIgnoredTypeReferenceSegment(language, normalizedSegment, isEscapedCSharpIdentifier)) + { + int column = argStartInLine + offset + 1; // 1-based / 1始まり + var dedupeKey = CreateReferenceDedupeKey(fileId, language, lineNumber, column, "type_reference", normalizedSegment, container); + if (seen.Add(dedupeKey)) + { + if (!TryAddReference( + references, + new ReferenceRecord + { + FileId = fileId, + SymbolName = normalizedSegment, + ReferenceKind = "type_reference", + Line = lineNumber, + Column = column, + Context = context, + ContainerKind = container?.Kind, + ContainerName = container?.Name, + })) + { + return; + } + } + } + + offset += segment.Length + 1; // segment + '.' + if (dotIndex < 0) + break; + segmentStart = dotIndex + 1; + } + } + + private static bool IsIgnoredTypeReferenceSegment(string language, string segment, bool isEscapedCSharpIdentifier = false, IReadOnlySet? ignoredSegments = null) + { + if (isEscapedCSharpIdentifier) + return false; + if (ignoredSegments != null && ignoredSegments.Contains(segment)) + return true; + if (IsIgnoredCallName(language, segment)) + return true; + if (language == "java" && JavaPrimitiveTypeNames.Contains(segment)) + return true; + if (language == "csharp" && CSharpBuiltInTypeNames.Contains(segment)) + return true; + if (LanguageBuiltInTypeNames.TryGetValue(language, out var builtInTypes) + && builtInTypes.Contains(segment)) + { + return true; + } + + return false; + } + + /// + /// Walk the argument list of a C# nameof/typeof/sizeof/default starting at + /// (the char right after `(`). Emits one `type_reference` row + /// per identifier segment while handling generic `<...>`, array `[...]`, + /// parenthesized/tuple groups `(...)`, and `global::` / `Alias::` qualifier skipping so nested + /// paths like `nameof(List<int>.Count)`, `nameof(global::System.String)`, + /// and `typeof((Foo, Bar))` are indexed correctly. + /// C# の nameof/typeof/sizeof/default の引数を `(` 直後から lexer で走査し、 + /// generic `<...>`・配列 `[...]`・タプル `(...)` 群・`global::` / `Alias::` 修飾子を + /// 跨ぎながら識別子セグメントごとに type_reference を発行する。 + /// + private static void ExtractCSharpTypeKeywordSegments( + List references, + ReferenceDedupeSet seen, + long fileId, + string line, + int startIndex, + string context, + int lineNumber, + SymbolRecord? container, + string language, + IReadOnlySet? ignoredSegments = null) + { + int i = startIndex; + int parenDepth = 0; + int angleDepth = 0; + bool expectSegment = true; + while (i < line.Length) + { + char c = line[i]; + if (c == ')') + { + if (parenDepth == 0) + return; + parenDepth--; + i++; + expectSegment = false; + continue; + } + + if (c == ',') + { + if (parenDepth == 0 && angleDepth == 0) + return; + // Tuple or generic argument separator inside `typeof((Foo, Bar))` / + // `typeof(List)` — keep scanning. + // `typeof((Foo, Bar))` のタプル要素区切りや `typeof(List)` + // の generic 引数区切りは続けて走査する。 + i++; + expectSegment = true; + continue; + } + + if (char.IsWhiteSpace(c)) + { + i++; + continue; + } + + if (expectSegment && IsCSharpIdentifierStart(c)) + { + int segStart = i; + if (line[i] == '@') + i++; + while (i < line.Length && IsCSharpIdentifierPart(line[i])) + i++; + var rawSegment = line.Substring(segStart, i - segStart); + var segment = NormalizeCSharpIdentifier(rawSegment); + var isEscapedCSharpIdentifier = rawSegment.Length > 0 && rawSegment[0] == '@'; + // `Alias::Member` — the left-hand side is a namespace alias, not an indexed + // type. Drop it instead of emitting it, and treat what follows the `::` as a + // fresh segment head. + // `Alias::Member` の左辺はエイリアスであり型シンボルではないため発行せず、 + // `::` の右側を新しいセグメント先頭として読み直す。 + if (i + 1 < line.Length && line[i] == ':' && line[i + 1] == ':') + { + i += 2; + expectSegment = true; + continue; + } + + if (ignoredSegments?.Contains(segment) == true) + { + expectSegment = false; + continue; + } + + AddTypeReferenceSegment(references, seen, fileId, segment, segStart, context, lineNumber, container, language, isEscapedCSharpIdentifier); + expectSegment = false; + continue; + } + + if (c == '.') + { + i++; + expectSegment = true; + continue; + } + + if (c == '<') + { + angleDepth++; + i++; + expectSegment = true; + continue; + } + + if (c == '>') + { + if (angleDepth == 0) + return; + angleDepth--; + i++; + expectSegment = false; + continue; + } + + if (c == '[') + { + i = SkipBalanced(line, i, '[', ']'); + continue; + } + + if (c == '(') + { + // Track paren depth instead of skipping the body so tuple/parenthesized + // type groups like `typeof((Foo, Bar))` still yield inner segments. + // タプル型 `typeof((Foo, Bar))` の中身も拾えるよう、括弧はスキップせず + // 深さだけ追跡する。 + parenDepth++; + i++; + expectSegment = true; + continue; + } + + // Unknown token (operator, string start, etc.) — stop scanning this argument. + // 解釈できないトークンが来たら、このキーワード引数の走査を打ち切る。 + return; + } + } + + private static void ExtractCSharpReflectionNameLiteralReferences( + List references, + ReferenceDedupeSet seen, + long fileId, + string preparedLine, + string originalLine, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("Get", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('(') < 0 + || !CSharpReflectionNameApiIntroRegex.IsMatch(preparedLine)) + { + return; + } + + var codeLine = SanitizeCSharpCommentsForReflectionNameScan(originalLine); + foreach (Match match in CSharpReflectionNameApiIntroRegex.Matches(codeLine)) + { + if (IsInsideCSharpStringLiteral(codeLine, match.Index)) + continue; + if (!preparedLine.Contains(match.Groups["name"].Value, StringComparison.Ordinal)) + continue; + + var argStart = match.Index + match.Length; + if (!TryReadCSharpReflectionNameLiteral(originalLine, argStart, out var symbolName, out var nameIndex)) + continue; + if (!IsValidCSharpReflectionSymbolName(symbolName)) + continue; + + AddReference(references, seen, fileId, symbolName, nameIndex, "type_reference", context, lineNumber, container, "csharp"); + } + } + + private static bool TryReadCSharpReflectionNameLiteral(string line, int startIndex, out string symbolName, out int nameIndex) + { + symbolName = string.Empty; + nameIndex = -1; + var builder = new StringBuilder(Math.Min(256, Math.Max(0, line.Length - startIndex))); + var i = startIndex; + var sawLiteral = false; + var firstLiteralIndex = -1; + + while (i < line.Length) + { + SkipWhitespace(line, ref i); + if (!TryReadCSharpStringLiteral(line, ref i, out var value, out var literalContentIndex)) + return false; + + if (!sawLiteral) + firstLiteralIndex = literalContentIndex; + sawLiteral = true; + builder.Append(value); + + SkipWhitespace(line, ref i); + if (i >= line.Length) + return false; + if (line[i] == ',' || line[i] == ')') + { + symbolName = builder.ToString(); + nameIndex = firstLiteralIndex; + return sawLiteral && symbolName.Length > 0; + } + if (line[i] != '+') + return false; + + i++; + } + + return false; + } + + private static string SanitizeCSharpCommentsForReflectionNameScan(string line) + { + char[]? chars = null; + var inRegularString = false; + var inVerbatimString = false; + var inChar = false; + for (var i = 0; i < line.Length; i++) + { + var c = line[i]; + if (inRegularString) + { + if (c == '\\' && i + 1 < line.Length) + i++; + else if (c == '"') + inRegularString = false; + continue; + } + if (inVerbatimString) + { + if (c == '"' && i + 1 < line.Length && line[i + 1] == '"') + i++; + else if (c == '"') + inVerbatimString = false; + continue; + } + if (inChar) + { + if (c == '\\' && i + 1 < line.Length) + i++; + else if (c == '\'') + inChar = false; + continue; + } + + if (c == '/' && i + 1 < line.Length && line[i + 1] == '/') + return line[..i]; + if (c == '/' && i + 1 < line.Length && line[i + 1] == '*') + { + chars ??= line.ToCharArray(); + chars[i] = ' '; + chars[i + 1] = ' '; + i += 2; + while (i < line.Length) + { + chars[i] = ' '; + if (line[i] == '*' && i + 1 < line.Length && line[i + 1] == '/') + { + chars[i + 1] = ' '; + i++; + break; + } + i++; + } + continue; + } + if (c == '@' && i + 1 < line.Length && line[i + 1] == '"') + { + inVerbatimString = true; + i++; + continue; + } + if (c == '$' && i + 1 < line.Length && line[i + 1] == '"') + { + inRegularString = true; + i++; + continue; + } + if (c == '"') + inRegularString = true; + else if (c == '\'') + inChar = true; + } + + return chars == null ? line : new string(chars); + } + + private static bool IsInsideCSharpStringLiteral(string line, int targetIndex) + { + var inRegularString = false; + var inVerbatimString = false; + for (var i = 0; i < line.Length && i < targetIndex; i++) + { + var c = line[i]; + if (inRegularString) + { + if (c == '\\' && i + 1 < line.Length) + i++; + else if (c == '"') + inRegularString = false; + continue; + } + if (inVerbatimString) + { + if (c == '"' && i + 1 < line.Length && line[i + 1] == '"') + i++; + else if (c == '"') + inVerbatimString = false; + continue; + } + + if (c == '@' && i + 1 < line.Length && line[i + 1] == '"') + { + inVerbatimString = true; + i++; + } + else if (c == '$' && i + 1 < line.Length && line[i + 1] == '"') + { + inRegularString = true; + i++; + } + else if (c == '"') + { + inRegularString = true; + } + } + + return inRegularString || inVerbatimString; + } + + private static bool TryReadCSharpStringLiteral(string line, ref int index, out string value, out int contentIndex) + { + value = string.Empty; + contentIndex = -1; + var verbatim = false; + if (index + 1 < line.Length && line[index] == '@' && line[index + 1] == '"') + { + verbatim = true; + index++; + } + else if (index < line.Length && line[index] == '$') + { + return false; + } + + if (index >= line.Length || line[index] != '"') + return false; + + contentIndex = index + 1; + index++; + var builder = new StringBuilder(Math.Min(256, line.Length - contentIndex)); + while (index < line.Length) + { + var c = line[index]; + if (c == '"') + { + if (verbatim && index + 1 < line.Length && line[index + 1] == '"') + { + builder.Append('"'); + index += 2; + continue; + } + + index++; + value = builder.ToString(); + return true; + } + + if (!verbatim && c == '\\' && index + 1 < line.Length) + { + builder.Append(line[index + 1]); + index += 2; + continue; + } + + builder.Append(c); + index++; + } + + return false; + } + + private static void SkipWhitespace(string text, ref int index) + { + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + } + + private static bool IsValidCSharpReflectionSymbolName(string symbolName) + { + if (symbolName.Length == 0 || !IsCSharpIdentifierStart(symbolName[0])) + return false; + for (var i = 1; i < symbolName.Length; i++) + { + if (!IsCSharpIdentifierPart(symbolName[i])) + return false; + } + return true; + } + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.Configuration.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.Configuration.cs new file mode 100644 index 000000000..e24a6c6c2 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.Configuration.cs @@ -0,0 +1,516 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static readonly TimeSpan ExtractionRegexTimeout = TimeSpan.FromSeconds(2); + internal const int MaxReferenceLookupSymbols = 50_000; + internal const int MaxReferenceLookupLines = 20_000; + internal const int MaxReferenceLookupNamesPerLine = 512; + internal const int MaxReferenceContainerCandidates = 20_000; + internal const int MaxSwiftPropertyDefinitionsPerLine = MaxReferenceLookupNamesPerLine; + internal static readonly IReadOnlyList ReferenceSafetyCapDiagnosticKinds = + [ + "reference_all_definition_lookup_symbol_budget_exceeded", + "reference_container_candidate_budget_exceeded", + "reference_csharp_xml_doc_scope_candidate_budget_exceeded", + "reference_definition_lookup_line_budget_exceeded", + "reference_definition_lookup_line_name_budget_exceeded", + "reference_definition_lookup_symbol_budget_exceeded", + "reference_enclosing_type_candidate_budget_exceeded", + "reference_scientific_native_dependency_name_budget_exceeded", + ShaderReferenceExtractor.LineNameBudgetDiagnosticKind, + ShaderReferenceExtractor.TrackedNameBudgetDiagnosticKind, + "reference_swift_property_line_budget_exceeded", + "reference_swift_property_line_name_budget_exceeded", + "reference_swift_property_symbol_budget_exceeded", + ]; + private static readonly HashSet ReferenceSafetyCapDiagnosticKindSet = + new(ReferenceSafetyCapDiagnosticKinds, StringComparer.Ordinal); + private static readonly AsyncLocal SafetyLimitsOverride = new(); + private const int ReferenceListInitialCapacityLineThreshold = 128; + private const int ReferenceListInitialCapacityMax = 1024; + private static readonly IReadOnlySet EmptyDefinitionNameSet = new HashSet(StringComparer.Ordinal); + private static readonly IReadOnlyDictionary> EmptyDefinitionNamesByLine = + new Dictionary>(); + private static readonly string[] AdditionalReferenceLanguages = + [ + "vue", + "svelte", + "razor", + "blazor", + "cshtml", + ]; + + private static string[] SplitContentLines(string content) => + content.IndexOf('\n', StringComparison.Ordinal) < 0 ? [content] : content.Split('\n'); + + internal static ReferenceExtractionSafetyLimits? SafetyLimitsForTesting + { + get => SafetyLimitsOverride.Value; + set => SafetyLimitsOverride.Value = value; + } + + public static ReferenceExtractionSafetyLimits GetSafetyLimits() + => SafetyLimitsOverride.Value ?? new ReferenceExtractionSafetyLimits + { + MaxLookupSymbols = MaxReferenceLookupSymbols, + MaxLookupLines = MaxReferenceLookupLines, + MaxNamesPerLine = MaxReferenceLookupNamesPerLine, + MaxContainerCandidates = MaxReferenceContainerCandidates, + }; + + internal static bool IsSafetyCapDiagnosticKind(string kind) + => ReferenceSafetyCapDiagnosticKindSet.Contains(kind); + + // THREAD-SAFETY: Reference extraction is stateless per call. Shared Regex instances and + // lookup tables are initialized once and then read concurrently; language-specific state + // must be created per extraction call (for example via CreateState helpers) rather than + // stored in mutable static fields. + private static readonly HashSet SharedIgnoredCallNames = new(StringComparer.Ordinal) + { + // Control flow / 制御フロー + "if", "else", "for", "foreach", "while", "switch", "catch", "lock", "do", "try", "when", + // Keywords that look like calls / 呼び出しに見えるキーワード + "sizeof", "typeof", "return", "throw", "nameof", "await", "using", "new", + // Type/member keywords / 型・メンバーキーワード + "class", "struct", "record", "interface", "enum", "delegate", "event", "namespace", + "def", "function", "func", + }; + private static readonly HashSet SharedIgnoredCallNamesCaseInsensitive = new(SharedIgnoredCallNames, StringComparer.OrdinalIgnoreCase); + private static readonly HashSet MethodGroupContextTargetIgnoreNames = new(StringComparer.OrdinalIgnoreCase) + { + "if", "else", "for", "foreach", "while", "switch", "catch", "lock", "do", "try", "nameof", + "typeof", "sizeof", "using", "return", "throw", "checked", "unchecked", "default", "stackalloc", + "fixed", "await", "yield", "when", + }; + + private static readonly HashSet TypeScriptTypeQueryContextTokens = new(StringComparer.Ordinal) + { + "extends", + "implements", + "satisfies", + "as", + "type", + }; + + private static readonly HashSet TypeScriptTypeQueryDisqualifyingTokens = new(StringComparer.Ordinal) + { + "if", + "else", + "for", + "foreach", + "while", + "switch", + "case", + "do", + "try", + "catch", + "return", + "throw", + "new", + "delete", + "void", + "await", + "yield", + "in", + "instanceof", + "=>", + "?", + }; + + private static bool IsFunctionLikeSymbolKind(string kind) + => kind is "function" or "operator" or "lambda" or "async_function" or "generator" or "async_generator"; + + private static readonly Dictionary> LanguageSpecificIgnoredCallNames = new(StringComparer.Ordinal) + { + // C# contextual keywords and common false positives / C# 文脈キーワードとよくある偽陽性 + ["csharp"] = new HashSet(StringComparer.Ordinal) + { + "is", "as", "in", "var", "base", "this", "value", "get", "set", "init", "where", + "from", "select", "orderby", "group", "into", "join", "let", "on", "equals", + "async", "yield", "checked", "unchecked", "default", "stackalloc", "fixed", + }, + // Java contextual keywords / Java 文脈キーワード + // `this` is listed so generic CallRegex does not emit a phantom `call this` edge + // after JavaReferenceExtractor rewrites the chain to the owning class. + // `this` も含めることで、連鎖書き換え後の generic CallRegex が `call this` を二重に出すのを防ぐ。 + ["java"] = new HashSet(StringComparer.Ordinal) + { + "instanceof", "super", "this", "assert", "throws", "extends", "implements", "synchronized", + }, + // Kotlin constructor delegation is rewritten by KotlinReferenceExtractor, so suppress the + // declaration/delegation keywords that generic CallRegex would otherwise index as calls. + // Kotlin の constructor 委譲は KotlinReferenceExtractor で書き換えるため、 + // 汎用 CallRegex が拾う宣言・委譲 keyword 自体は call として残さない。 + ["kotlin"] = new HashSet(StringComparer.Ordinal) + { + "constructor", "super", "this", + }, + // Rust macro declaration keywords / Rust マクロ宣言キーワード + // `macro_rules!` declarations will be seen by the Rust macro-call regex below, but they are + // declaration sites rather than call sites, so suppress the keyword itself. + // `macro_rules!` 宣言は下の Rust macro-call regex でも見えてしまうが、これは呼び出しではなく + // 宣言なのでキーワード自体を抑止する。 + ["rust"] = new HashSet(StringComparer.Ordinal) + { + "macro_rules", + }, + ["c"] = new HashSet(StringComparer.Ordinal) + { + "auto", "break", "case", "const", "continue", "default", "extern", "goto", + "inline", "register", "restrict", "static", "switch", "typedef", "volatile", + }, + ["cpp"] = new HashSet(StringComparer.Ordinal) + { + "alignas", "auto", "break", "case", "catch", "concept", "const", "constexpr", + "consteval", "constinit", "continue", "co_await", "co_return", "co_yield", + "decltype", "default", "delete", "explicit", "extern", "friend", "inline", + "mutable", "noexcept", "operator", "override", "private", "protected", "public", + "requires", "static", "template", "this", "typedef", "typename", "using", "virtual", + "volatile", + }, + // GPU-language metadata is declarative, even when its surface syntax uses parentheses. + // GPU 言語のメタデータは括弧を使う構文でも宣言であり、呼び出しではない。 + ["cuda"] = new HashSet(StringComparer.Ordinal) + { + "alignas", "auto", "break", "case", "catch", "concept", "const", "constexpr", + "consteval", "constinit", "continue", "co_await", "co_return", "co_yield", + "decltype", "default", "delete", "explicit", "extern", "friend", "inline", + "mutable", "noexcept", "operator", "override", "private", "protected", "public", + "requires", "static", "template", "this", "typedef", "typename", "using", "virtual", + "volatile", + "__global__", "__device__", "__host__", "__shared__", "__constant__", + "__launch_bounds__", "__align__", "__device_builtin__", + }, + ["glsl"] = new HashSet(StringComparer.Ordinal) + { + "layout", + }, + ["hlsl"] = new HashSet(StringComparer.Ordinal) + { + "register", "packoffset", "numthreads", "domain", "partitioning", + "outputtopology", "outputcontrolpoints", "patchconstantfunc", "maxtessfactor", + }, + ["metal"] = new HashSet(StringComparer.Ordinal) + { + "buffer", "texture", "sampler", "threadgroup", "stage_in", + "thread_position_in_grid", "threads_per_threadgroup", + }, + ["go"] = new HashSet(StringComparer.Ordinal) + { + "append", "cap", "close", "copy", "delete", "len", "make", "new", "panic", "recover", + "chan", "defer", "fallthrough", "func", "go", "interface", "map", "package", + "range", "select", "type", "var", + }, + ["dart"] = new HashSet(StringComparer.Ordinal) + { + "abstract", "assert", "async", "base", "const", "covariant", "deferred", "dynamic", + "export", "extends", "extension", "external", "factory", "final", "hide", "implements", + "import", "late", "library", "mixin", "on", "operator", "part", "required", "show", + "typedef", "void", "with", + }, + ["elixir"] = new HashSet(StringComparer.Ordinal) + { + "alias", "after", "behaviour", "case", "catch", "cond", "def", "defdelegate", + "defguard", "defguardp", "defimpl", "defmacro", "defmacrop", "defmodule", "defp", + "defprotocol", "defstruct", "do", "else", "end", "for", "fn", "if", "impl", + "import", "quote", "receive", "require", "rescue", "try", "unless", "unquote", + "use", "with", + }, + ["lua"] = new HashSet(StringComparer.Ordinal) + { + "and", "break", "do", "else", "elseif", "end", "false", "for", "function", "if", + "in", "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", + "while", + }, + // JavaScript / TypeScript contextual keywords / JavaScript / TypeScript 文脈キーワード + ["javascript"] = new HashSet(StringComparer.Ordinal) + { + "import", "super", "yield", + }, + ["typescript"] = new HashSet(StringComparer.Ordinal) + { + "import", "super", "yield", + }, + // Python contextual keywords / Python の文脈キーワード + ["python"] = new HashSet(StringComparer.Ordinal) + { + "raise", "yield", "from", "super", + }, + // Ruby contextual keywords / Ruby の文脈キーワード + ["ruby"] = new HashSet(StringComparer.Ordinal) + { + "raise", "yield", "super", "include", "extend", "prepend", "refine", "alias", "alias_method", "describe", + "resource", "resources", "create_table", "attribute", "serialize", + "private_constant", "public_constant", "module_function", "rescue_from", "gem", "composed_of", + "accepts_nested_attributes_for", + "unless", "case", "begin", "until", "module", "rescue", "ensure", + }, + ["perl"] = new HashSet(StringComparer.Ordinal) + { + "use", "require", "package", "sub", "my", "our", "local", "state", + "if", "elsif", "unless", "while", "until", "foreach", "for", "given", "when", + "print", "say", "die", "warn", "open", "close", "defined", "exists", "delete", + "bless", "ref", "scalar", "wantarray", "eval", "do", + }, + ["ambiguous_pl"] = new HashSet(StringComparer.Ordinal) + { + "use", "require", "package", "sub", "my", "our", "local", "state", + "if", "elsif", "unless", "while", "until", "foreach", "for", "given", "when", + "print", "say", "die", "warn", "open", "close", "defined", "exists", "delete", + "bless", "ref", "scalar", "wantarray", "eval", "do", + "module", "use_module", "library", "initialization", "dynamic", "multifile", + "discontiguous", "op", + }, + ["crystal"] = new HashSet(StringComparer.Ordinal) + { + "abstract", "alias", "annotation", "begin", "case", "class", "def", "do", "else", + "elsif", "end", "ensure", "enum", "extend", "for", "fun", "if", "include", "lib", + "macro", "module", "next", "of", "private", "protected", "require", "rescue", + "return", "select", "struct", "then", "unless", "until", "when", "while", "with", "yield", + "as", "alignof", "instance_alignof", "instance_sizeof", "is_a?", "offsetof", "pointerof", + "responds_to?", + }, + ["groovy"] = new HashSet(StringComparer.Ordinal) + { + "apply", "as", "assert", "break", "case", "catch", "class", "continue", "def", "do", + "else", "enum", "extends", "finally", "for", "if", "implements", "import", "in", + "instanceof", "interface", "new", "package", "return", "super", "switch", "synchronized", + "this", "throw", "throws", "trait", "try", "while", + }, + ["tcl"] = new HashSet(StringComparer.Ordinal) + { + "append", "array", "break", "catch", "concat", "continue", "dict", "error", "eval", + "expr", "for", "foreach", "global", "if", "incr", "info", "lappend", "lindex", + "list", "namespace", "oo::class", "package", "proc", "rename", "return", "set", + "string", "switch", "unset", "upvar", "uplevel", "variable", "while", + }, + ["prolog"] = new HashSet(StringComparer.Ordinal) + { + "module", "use_module", "library", "initialization", "dynamic", "multifile", + "discontiguous", "op", "true", "fail", "false", "is", "not", + }, + // F# contextual keywords / F# 文脈キーワード + ["fsharp"] = new HashSet(StringComparer.Ordinal) + { + "match", "with", "member", "override", "abstract", "mutable", "rec", "fun", "open", + "module", "type", "of", "then", "elif", "done", "begin", "end", + "let", "use", "if", "else", "do", "try", "finally", "in", "for", "while", "return", "yield", + "assert", "to", "downto", "lazy", "raise", "upcast", "downcast", + }, + // PHP include/require constructs / PHP の include/require 構文 + ["php"] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "require", "require_once", "include", "include_once", + "echo", "print", "exit", "die", "eval", "unset", "isset", "empty", + }, + // SQL keywords. Case-insensitive because SQL is written both upper- and lowercase in real code, + // and the `EXEC|EXECUTE|CALL` extractor preserves the original casing of the captured name. + // The entries themselves stay uppercase for readability. + // SQL のキーワード。実コードでは大文字・小文字が混在するうえ、`EXEC|EXECUTE|CALL` 抽出が + // 元のケースをそのまま保持するため、比較は大文字小文字非依存にする(リストは読みやすさのため大文字表記)。 + ["sql"] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "JOIN", "INTO", + "VALUES", "ORDER", "GROUP", "HAVING", "LIMIT", "OFFSET", "UNION", + "EXISTS", "BETWEEN", "LIKE", "CASE", "WHEN", "THEN", "ELSE", + "AS", "ON", "AND", "OR", "NOT", "NULL", "IN", "IS", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "IF", + // `EXECUTE IMMEDIATE 'dynamic SQL'` (Oracle / PL/pgSQL) — `IMMEDIATE` is not a call target. + // `EXECUTE IMMEDIATE '動的SQL'` (Oracle / PL/pgSQL) — `IMMEDIATE` は呼び出し対象ではない。 + "IMMEDIATE", + // The keywords that introduce a stored-procedure call themselves. The no-parens form is + // captured by SqlProcCallRegex; the rare `EXEC(@sql)` / `EXEC('...')` dynamic-SQL form has + // no identifier argument, so the generic CallRegex would otherwise emit a phantom + // `call EXEC` / `call EXECUTE` / `call CALL` edge pointing at the keyword itself. + // ストアドプロシージャ呼び出しを導入するキーワード自身。括弧なし形は SqlProcCallRegex で捕捉し、 + // 動的 SQL 形の `EXEC(@sql)` / `EXEC('...')` は識別子を持たないため、汎用 CallRegex に任せると + // キーワード自体を指す `call EXEC` / `call EXECUTE` / `call CALL` の幽霊エッジが生まれる。 + "EXEC", "EXECUTE", "CALL", + }, + // R keywords / R キーワード + ["r"] = new HashSet(StringComparer.Ordinal) + { + "library", "cat", "paste", "paste0", "sprintf", "stop", "warning", "message", + "invisible", "tryCatch", "withCallingHandlers", "requireNamespace", "next", "break", "repeat", + "import", "importFrom", "export", "exportClasses", "exportMethods", "S3method", "useDynLib", + }, + // PowerShell keywords / PowerShell キーワード + ["powershell"] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "function", "filter", "configuration", "workflow", "class", "enum", + "param", "begin", "process", "end", "dynamicparam", + "if", "else", "elseif", "for", "foreach", "while", "do", "until", "switch", + "try", "catch", "finally", "trap", "return", "throw", "break", "continue", + "using", "data", "in", "Write", + }, + // Shell keywords / Shell キーワード + ["shell"] = new HashSet(StringComparer.Ordinal) + { + "if", "then", "else", "elif", "fi", "do", "done", "while", "until", "case", "esac", "time", + }, + // Haskell keywords / Haskell キーワード + ["haskell"] = new HashSet(StringComparer.Ordinal) + { + "data", "newtype", "instance", "deriving", "infixl", "infixr", "infix", + "qualified", "hiding", "forall", "Just", "Nothing", "Left", "Right", "True", "False", + "case", "class", "default", "foreign", "import", "let", "module", "of", "type", "where", + "putStrLn", "putStr", "print", + }, + ["vb"] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "AddHandler", "AddressOf", "Alias", "And", "AndAlso", "As", "ByRef", "ByVal", + "Call", "CallByName", "Case", "Catch", "CBool", "CByte", "CChar", "CDate", "CDbl", "CDec", + "CInt", "CLng", "CObj", "CSByte", "CShort", "CSng", "CStr", "CType", "CUInt", "CULng", "CUShort", + "DirectCast", "End", "Erase", "Exit", "Get", "GetType", + "GetXMLNamespace", "Global", "Handles", "Inherits", "Implements", "Imports", "Me", + "Module", "MustInherit", "MustOverride", "MyBase", "MyClass", "Namespace", "Narrowing", + "NameOf", "New", "Next", "Not", "Nothing", "Of", "On", "Operator", "Option", "Or", "OrElse", + "Overloads", "Overrides", "ParamArray", "Partial", "RaiseEvent", "ReadOnly", + "RemoveHandler", "Resume", "Return", "Select", "Set", "Shadows", "Shared", "Static", + "Step", "Stop", "SyncLock", "Then", "TryCast", "Using", "When", "Widening", "With", + "WithEvents", "WriteOnly", "Xor", + }, + ["fortran"] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "allocatable", "allocate", "associate", "call", "case", "class", "contains", "cycle", + "deallocate", "do", "elemental", "else", "elseif", "end", "entry", "equivalence", + "exit", "function", "if", "implicit", "include", "intent", "interface", "intrinsic", + "module", "namelist", "none", "only", "operator", "optional", "parameter", "pointer", + "private", "procedure", "program", "public", "pure", "recursive", "result", "return", + "select", "submodule", "subroutine", "then", "type", "use", "where", + }, + ["pascal"] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "and", "array", "begin", "case", "class", "const", "constructor", "destructor", "div", + "do", "downto", "else", "end", "except", "exports", "file", "finally", "for", + "function", "goto", "if", "implementation", "in", "inherited", "interface", "is", + "label", "mod", "nil", "not", "object", "of", "or", "packed", "private", "procedure", + "program", "property", "protected", "public", "published", "raise", "record", "repeat", + "set", "shl", "shr", "then", "threadvar", "to", "try", "type", "unit", "until", + "uses", "var", "while", "with", "xor", + }, + ["objc"] = new HashSet(StringComparer.Ordinal) + { + "BOOL", "Class", "YES", "NO", "Nil", "SEL", "alloc", "autorelease", "copy", "id", + "init", "nonatomic", "nullable", "nonnull", "readwrite", "readonly", "retain", + "self", "strong", "super", "weak", + }, + ["smalltalk"] = new HashSet(StringComparer.Ordinal) + { + "false", "nil", "self", "super", "thisContext", "true", + }, + ["ada"] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "accept", "begin", "case", "declare", "delay", "else", "elsif", "end", "entry", + "exception", "exit", "function", "generic", "if", "loop", "package", "pragma", + "procedure", "raise", "record", "renames", "return", "select", "task", "terminate", + "type", "use", "when", "while", "with", + }, + ["cython"] = new HashSet(StringComparer.Ordinal) + { + "cdef", "cpdef", "ctypedef", "cimport", "def", "extern", "gil", "include", + "nogil", "property", + }, + ["d"] = new HashSet(StringComparer.Ordinal) + { + "__traits", "assert", "cast", "debug", "extern", "is", "mixin", "pragma", "scope", + "static", "unittest", "version", + }, + ["julia"] = new HashSet(StringComparer.Ordinal) + { + "abstract", "baremodule", "begin", "do", "export", "finally", "function", "import", + "let", "macro", "module", "mutable", "primitive", "quote", "struct", "using", "where", + }, + ["matlab"] = new HashSet(StringComparer.Ordinal) + { + "arguments", "case", "catch", "classdef", "elseif", "end", "function", "import", + "methods", "otherwise", "parfor", "properties", "spmd", + }, + ["nim"] = new HashSet(StringComparer.Ordinal) + { + "block", "case", "concept", "converter", "defer", "discard", "distinct", "from", + "func", "import", "include", "iterator", "macro", "method", "mixin", "object", + "proc", "template", "type", "when", + }, + // Gradle/Groovy keywords / Gradle/Groovy キーワード + ["gradle"] = new HashSet(StringComparer.Ordinal) + { + "apply", "plugins", "dependencies", "repositories", "allprojects", "subprojects", + "task", "buildscript", "ext", "group", "version", "description", + }, + // Terraform keywords / Terraform キーワード + ["terraform"] = new HashSet(StringComparer.Ordinal) + { + "resource", "data", "variable", "output", "locals", "module", "provider", + "terraform", "required_providers", "backend", + }, + // Makefile keywords / Makefile キーワード + ["makefile"] = new HashSet(StringComparer.Ordinal) + { + "all", "clean", "install", "build", "run", "help", + }, + // Sass/Stylus accept CSS function syntax without separators; keep common CSS built-ins + // from flowing through the shared CallRegex after the language-specific extractors skip them. + // Sass/Stylus は CSS 関数構文を区切りなしで受け付けるため、言語専用 extractor で除外した + // 代表的な CSS built-in を共有 CallRegex 側でも call として残さない。 + ["sass"] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "url", "var", "calc", "rgb", "rgba", "hsl", "hsla", + }, + ["stylus"] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "url", "var", "calc", "rgb", "rgba", "hsl", "hsla", + }, + }; + private static readonly Dictionary> LanguageSpecificCallNameKeeps = new(StringComparer.Ordinal) + { + // Rust uses `new` / `default` as ordinary method names (`Type::new`, `Default::default`). + // Rust では `new` / `default` は通常のメソッド名 (`Type::new`, `Default::default`)。 + ["rust"] = new HashSet(StringComparer.Ordinal) + { + "new", "default", + }, + }; + + // JavaScript / TypeScript tokens that legally sit immediately before a template literal + // without being a tag identifier: unary / binary operators (`void \`...\``, + // `delete \`...\``, `foo in \`...\``, `foo instanceof \`...\``), switch-case label + // (`case \`...\`:`), and clause / statement keywords (`export default \`...\``, + // `try {} finally \`...\``). Without this gate the tagged-template scanner (issue #268) + // emits phantom call rows for those keywords. This set is intentionally applied ONLY at + // the tagged-template emit site, not to the shared `CallRegex` path, so legitimate + // member calls like `api.in()` / `api.instanceof()` / `api.delete()` / `api.case()` / + // `api.void()` / `promise.finally()` remain captured. The denylist is also bypassed + // when the hit's `IsMemberAccess` flag is set — `obj.default\`x\`` and + // `obj.finally\`y\`` are legal tagged-template calls because every reserved word is a + // legal property name in JS/TS, and the masker's member-access detection reports those + // hits separately from bare-keyword hits. `of` is intentionally NOT listed because it + // is an unreserved identifier — `const of = ...; of\`x\`` is a legal tagged-template + // call. The narrower `for (...of \`...\`)` header suppression lives in + // `StructuralLineMasker.FilterJsForOfHeaderHits`. + // JS/TS でタグ無しテンプレート直前に現れてタグではないトークン: 単項/二項演算子 + // (`void \`...\`` / `delete \`...\`` / `foo in \`...\`` / `foo instanceof \`...\``)、 + // switch-case ラベル (`case \`...\`:`)、clause/statement キーワード + // (`export default \`...\`` / `try {} finally \`...\``)。汎用 CallRegex には適用せず + // タグ付きテンプレート発行時だけに限定するため、`api.in()` / `api.instanceof()` / + // `api.delete()` / `api.case()` / `api.void()` / `promise.finally()` のような正当な + // メンバー呼び出しは引き続き捕捉される。さらに hit の `IsMemberAccess` が立って + // いる場合もこの denylist を迂回する — JS/TS ではすべての予約語が property 名に + // なれるため `obj.default\`x\`` や `obj.finally\`y\`` は正当なタグ呼び出しで、 + // masker 側でメンバーアクセス判定が済んでいる。`of` は予約語ではなく + // `const of = ...; of\`x\`` が正当なタグ呼び出しになりうるためここには含めない。 + // `for (...of \`...\`)` ヘッダの抑止は + // `StructuralLineMasker.FilterJsForOfHeaderHits` 側で扱う。 + private static readonly HashSet JsTaggedTemplateOperatorNames = new(StringComparer.Ordinal) + { + "void", "case", "delete", "in", "instanceof", "default", "finally", + }; + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.Definitions.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.Definitions.cs new file mode 100644 index 000000000..101f88a3f --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.Definitions.cs @@ -0,0 +1,507 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static IReadOnlyDictionary> BuildDefinitionNamesByLine( + string language, + IReadOnlyList symbols, + Action? reportDiagnostic) + { + if (symbols.Count == 0) + return EmptyDefinitionNamesByLine; + + var limits = GetSafetyLimits(); + var definitionNamesComparer = GetDefinitionNamesComparer(language); + var namesByLine = new Dictionary>(); + var lineBudgetReported = false; + var lineNameBudgetReported = false; + for (var index = 0; index < symbols.Count; index++) + { + if (index >= limits.MaxLookupSymbols) + { + ReportReferenceLookupBudgetHit( + reportDiagnostic, + "reference_definition_lookup_symbol_budget_exceeded", + $"Reference definition-name lookup used the first {limits.MaxLookupSymbols:N0} symbols and skipped additional symbols."); + break; + } + + var symbol = symbols[index]; + if (!namesByLine.TryGetValue(symbol.Line, out var names)) + { + if (namesByLine.Count >= limits.MaxLookupLines) + { + if (!lineBudgetReported) + { + ReportReferenceLookupBudgetHit( + reportDiagnostic, + "reference_definition_lookup_line_budget_exceeded", + $"Reference definition-name lookup used the first {limits.MaxLookupLines:N0} definition lines and skipped additional lines."); + lineBudgetReported = true; + } + + continue; + } + + names = new HashSet(definitionNamesComparer); + namesByLine[symbol.Line] = names; + } + + if (names.Count >= limits.MaxNamesPerLine && !names.Contains(symbol.Name)) + { + if (!lineNameBudgetReported) + { + ReportReferenceLookupBudgetHit( + reportDiagnostic, + "reference_definition_lookup_line_name_budget_exceeded", + $"Reference definition-name lookup retained at most {limits.MaxNamesPerLine:N0} names per line and skipped additional names."); + lineNameBudgetReported = true; + } + + continue; + } + + names.Add(symbol.Name); + if (language == "sql") + SqlReferenceExtractor.AddDefinitionNameAliases(names, symbol); + } + + return namesByLine; + } + + private static IReadOnlyDictionary>>? + BuildScientificDefinitionNameIndicesByLine( + string language, + IReadOnlyList lines, + IReadOnlyList symbols, + IReadOnlyDictionary> definitionNamesByLine) + { + if (!ScientificNativeReferenceExtractor.Supports(language) || symbols.Count == 0) + return null; + + var limits = GetSafetyLimits(); + var comparer = GetDefinitionNamesComparer(language); + var comparison = comparer == StringComparer.OrdinalIgnoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + var indicesByLine = new Dictionary>>(); + for (var symbolIndex = 0; + symbolIndex < symbols.Count && symbolIndex < limits.MaxLookupSymbols; + symbolIndex++) + { + var symbol = symbols[symbolIndex]; + if (symbol.Line <= 0 + || symbol.Line > lines.Count + || !definitionNamesByLine.TryGetValue(symbol.Line, out var retainedNames) + || !retainedNames.Contains(symbol.Name)) + { + continue; + } + + var line = lines[symbol.Line - 1]; + var searchStart = Math.Clamp(symbol.StartColumn ?? 0, 0, line.Length); + var definitionIndex = FindScientificDefinitionNameIndex( + line, + symbol.Name, + searchStart, + comparison); + if (definitionIndex < 0) + continue; + + if (!indicesByLine.TryGetValue(symbol.Line, out var indicesByName)) + { + indicesByName = new Dictionary>(comparer); + indicesByLine[symbol.Line] = indicesByName; + } + + AddScientificDefinitionNameIndex( + indicesByName, + symbol.Name, + definitionIndex); + + var leafSeparatorIndex = symbol.Name.LastIndexOf('.'); + if (leafSeparatorIndex >= 0 && leafSeparatorIndex + 1 < symbol.Name.Length) + { + AddScientificDefinitionNameIndex( + indicesByName, + symbol.Name[(leafSeparatorIndex + 1)..], + definitionIndex + leafSeparatorIndex + 1); + } + } + + return indicesByLine; + } + + private static int FindScientificDefinitionNameIndex( + string line, + string name, + int searchStart, + StringComparison comparison) + { + while (searchStart <= line.Length - name.Length) + { + var index = line.IndexOf(name, searchStart, comparison); + if (index < 0) + return -1; + + var beforeIsBoundary = index == 0 + || !IsScientificDefinitionIdentifierChar(line[index - 1]); + var end = index + name.Length; + var afterIsBoundary = end == line.Length + || !IsScientificDefinitionIdentifierChar(line[end]); + if (beforeIsBoundary && afterIsBoundary) + return index; + + searchStart = index + 1; + } + + return -1; + } + + private static bool IsScientificDefinitionIdentifierChar(char value) + => char.IsLetterOrDigit(value) || value is '_' or '!' or '?' or '$'; + + private static void AddScientificDefinitionNameIndex( + Dictionary> indicesByName, + string name, + int index) + { + if (!indicesByName.TryGetValue(name, out var indices)) + { + indices = []; + indicesByName[name] = indices; + } + + indices.Add(index); + } + + private static IReadOnlySet BuildAllDefinitionNames( + string language, + IReadOnlyList symbols, + Action? reportDiagnostic) + { + if (symbols.Count == 0) + return EmptyDefinitionNameSet; + + var limits = GetSafetyLimits(); + var names = new HashSet(GetDefinitionNamesComparer(language)); + for (var index = 0; index < symbols.Count; index++) + { + if (index >= limits.MaxLookupSymbols) + { + ReportReferenceLookupBudgetHit( + reportDiagnostic, + "reference_all_definition_lookup_symbol_budget_exceeded", + $"Reference all-definition lookup used the first {limits.MaxLookupSymbols:N0} symbols and skipped additional symbols."); + break; + } + + var symbol = symbols[index]; + names.Add(symbol.Name); + if (language == "sql") + SqlReferenceExtractor.AddDefinitionNameAliases(names, symbol); + } + + return names; + } + + private static IReadOnlySet BuildFileDefinitionNames(IReadOnlyList symbols) + { + if (symbols.Count == 0) + return EmptyDefinitionNameSet; + + var names = new HashSet(symbols.Count, StringComparer.Ordinal); + foreach (var symbol in symbols) + names.Add(symbol.Name); + return names; + } + + private static IReadOnlyList? BuildCobolCallableSymbols(IReadOnlyList symbols) + { + List<(SymbolRecord Symbol, int OriginalIndex)>? callableSymbols = null; + for (var index = 0; index < symbols.Count; index++) + { + var symbol = symbols[index]; + if (symbol.Kind == "function") + (callableSymbols ??= []).Add((symbol, index)); + } + + if (callableSymbols is not { Count: > 0 }) + return null; + + callableSymbols.Sort(CompareCobolCallableSymbolEntries); + + var sorted = new List(callableSymbols.Count); + foreach (var entry in callableSymbols) + sorted.Add(entry.Symbol); + return sorted; + } + + private static int CompareCobolCallableSymbolEntries( + (SymbolRecord Symbol, int OriginalIndex) left, + (SymbolRecord Symbol, int OriginalIndex) right) + { + var lineComparison = left.Symbol.Line.CompareTo(right.Symbol.Line); + if (lineComparison != 0) + return lineComparison; + + var startLineComparison = left.Symbol.StartLine.CompareTo(right.Symbol.StartLine); + if (startLineComparison != 0) + return startLineComparison; + + var nameComparison = string.Compare(left.Symbol.Name, right.Symbol.Name, StringComparison.OrdinalIgnoreCase); + return nameComparison != 0 + ? nameComparison + : left.OriginalIndex.CompareTo(right.OriginalIndex); + } + + private static IReadOnlyList? BuildRustEnumCandidates(IReadOnlyList symbols) + { + List<(SymbolRecord Symbol, int OriginalIndex)>? candidates = null; + for (var index = 0; index < symbols.Count; index++) + { + var symbol = symbols[index]; + if (symbol.Kind == "enum" && symbol.BodyStartLine != null && symbol.BodyEndLine != null) + (candidates ??= []).Add((symbol, index)); + } + + if (candidates is not { Count: > 0 }) + return null; + + candidates.Sort(CompareRustEnumCandidateEntries); + + var sorted = new List(candidates.Count); + foreach (var entry in candidates) + sorted.Add(entry.Symbol); + return sorted; + } + + private static int CompareRustEnumCandidateEntries( + (SymbolRecord Symbol, int OriginalIndex) left, + (SymbolRecord Symbol, int OriginalIndex) right) + { + var spanComparison = GetRustEnumCandidateSpan(left.Symbol).CompareTo(GetRustEnumCandidateSpan(right.Symbol)); + return spanComparison != 0 + ? spanComparison + : left.OriginalIndex.CompareTo(right.OriginalIndex); + } + + private static int GetRustEnumCandidateSpan(SymbolRecord symbol) + => (symbol.BodyEndLine ?? symbol.EndLine) - (symbol.BodyStartLine ?? symbol.StartLine); + + private static StringComparer GetDefinitionNamesComparer(string language) + => language is "sql" or "ada" + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + private static IReadOnlyList BuildReferenceContainerCandidates( + IReadOnlyList symbols, + Action? reportDiagnostic) + => BuildBoundedContainerCandidates( + symbols, + symbol => symbol.BodyStartLine != null && symbol.BodyEndLine != null && + (IsFunctionLikeSymbolKind(symbol.Kind) || symbol.Kind == "hook" || symbol.Kind == "accessor" || symbol.Kind == "class" + || symbol.Kind == "struct" || symbol.Kind == "namespace" + || symbol.Kind == "object" || symbol.Kind == "property" || symbol.Kind == "heading" || symbol.Kind == "class_hook"), + "reference_container_candidate_budget_exceeded", + "Reference container lookup retained the highest-priority bounded candidate set and skipped additional candidates.", + reportDiagnostic); + + private static IReadOnlyList? BuildCSharpXmlDocAttachmentScopeCandidates( + string language, + IReadOnlyList symbols, + Action? reportDiagnostic) + => language == "csharp" + ? BuildBoundedContainerCandidates( + symbols, + symbol => symbol.BodyStartLine != null && symbol.BodyEndLine != null + && symbol.Kind is "class" or "struct" or "interface" or "enum" or "namespace", + "reference_csharp_xml_doc_scope_candidate_budget_exceeded", + "C# XML documentation scope lookup retained the highest-priority bounded candidate set and skipped additional candidates.", + reportDiagnostic) + : null; + + private static IReadOnlyList BuildEnclosingTypeCandidates( + IReadOnlyList symbols, + Action? reportDiagnostic) + => BuildBoundedContainerCandidates( + symbols, + symbol => symbol.BodyStartLine != null && symbol.BodyEndLine != null && + (symbol.Kind == "class" || symbol.Kind == "struct" || symbol.Kind == "interface" || symbol.Kind == "enum"), + "reference_enclosing_type_candidate_budget_exceeded", + "Reference enclosing-type lookup retained the highest-priority bounded candidate set and skipped additional candidates.", + reportDiagnostic); + + private static IReadOnlyDictionary? BuildSwiftPropertyDefinitionsByLine( + string language, + IReadOnlyList symbols, + Action? reportDiagnostic) + { + if (language != "swift") + return null; + + var limits = GetSafetyLimits(); + Dictionary>? byLine = null; + var lineBudgetReported = false; + var perLineBudgetReported = false; + for (var index = 0; index < symbols.Count && index < limits.MaxLookupSymbols; index++) + { + var symbol = symbols[index]; + if (symbol.Kind != "property") + continue; + + var lookup = byLine ??= new Dictionary>(); + if (!lookup.TryGetValue(symbol.Line, out var lineSymbols)) + { + if (lookup.Count >= limits.MaxLookupLines) + { + if (!lineBudgetReported) + { + ReportReferenceLookupBudgetHit( + reportDiagnostic, + "reference_swift_property_line_budget_exceeded", + $"Swift property lookup retained at most {limits.MaxLookupLines:N0} definition lines and skipped additional lines."); + lineBudgetReported = true; + } + + continue; + } + + lineSymbols = []; + lookup[symbol.Line] = lineSymbols; + } + + if (lineSymbols.Count >= limits.MaxNamesPerLine) + { + if (!perLineBudgetReported) + { + ReportReferenceLookupBudgetHit( + reportDiagnostic, + "reference_swift_property_line_name_budget_exceeded", + $"Swift property lookup retained at most {limits.MaxNamesPerLine:N0} properties per line and skipped additional properties."); + perLineBudgetReported = true; + } + + continue; + } + + lineSymbols.Add(symbol); + } + + if (symbols.Count > limits.MaxLookupSymbols) + { + ReportReferenceLookupBudgetHit( + reportDiagnostic, + "reference_swift_property_symbol_budget_exceeded", + $"Swift property lookup used the first {limits.MaxLookupSymbols:N0} symbols and skipped additional symbols."); + } + + if (byLine is not { Count: > 0 }) + return null; + + var result = new Dictionary(byLine.Count); + foreach (var pair in byLine) + result.Add(pair.Key, SortSwiftPropertyDefinitionCandidates(pair.Value)); + + return result; + } + + private static SymbolRecord[] SortSwiftPropertyDefinitionCandidates(IReadOnlyList candidates) + { + if (candidates.Count == 1) + return [candidates[0]]; + + var entries = new List<(SymbolRecord Symbol, int OriginalIndex)>(candidates.Count); + for (var index = 0; index < candidates.Count; index++) + entries.Add((candidates[index], index)); + + entries.Sort(CompareSwiftPropertyDefinitionCandidateEntries); + + var sorted = new SymbolRecord[entries.Count]; + for (var index = 0; index < entries.Count; index++) + sorted[index] = entries[index].Symbol; + return sorted; + } + + private static int CompareSwiftPropertyDefinitionCandidateEntries( + (SymbolRecord Symbol, int OriginalIndex) left, + (SymbolRecord Symbol, int OriginalIndex) right) + { + var startColumnComparison = (right.Symbol.StartColumn ?? 0).CompareTo(left.Symbol.StartColumn ?? 0); + return startColumnComparison != 0 + ? startColumnComparison + : left.OriginalIndex.CompareTo(right.OriginalIndex); + } + + private static IReadOnlyList BuildBoundedContainerCandidates( + IReadOnlyList symbols, + Func predicate, + string diagnosticKind, + string diagnosticMessage, + Action? reportDiagnostic) + { + var limit = GetSafetyLimits().MaxContainerCandidates; + List? candidates = null; + var truncated = false; + for (var symbolIndex = 0; symbolIndex < symbols.Count; symbolIndex++) + { + var symbol = symbols[symbolIndex]; + if (!predicate(symbol)) + continue; + + if ((candidates?.Count ?? 0) >= limit) + { + truncated = true; + continue; + } + + (candidates ??= new List( + Math.Min(symbols.Count, limit))).Add(new ReferenceContainerCandidateSortEntry( + symbol, + GetReferenceContainerCandidateSpanLength(symbol), + symbolIndex)); + } + + if (truncated) + ReportReferenceLookupBudgetHit(reportDiagnostic, diagnosticKind, diagnosticMessage); + + if (candidates is not { Count: > 0 }) + return Array.Empty(); + + candidates.Sort(CompareReferenceContainerCandidateSortEntries); + + var sorted = new SymbolRecord[candidates.Count]; + for (var index = 0; index < candidates.Count; index++) + sorted[index] = candidates[index].Symbol; + + return sorted; + } + + private readonly record struct ReferenceContainerCandidateSortEntry(SymbolRecord Symbol, int SpanLength, int OriginalIndex); + + private static int CompareReferenceContainerCandidateSortEntries( + ReferenceContainerCandidateSortEntry left, + ReferenceContainerCandidateSortEntry right) + { + var compare = left.SpanLength.CompareTo(right.SpanLength); + return compare != 0 + ? compare + : left.OriginalIndex.CompareTo(right.OriginalIndex); + } + + private static int GetReferenceContainerCandidateSpanLength(SymbolRecord symbol) + => (symbol.BodyEndLine ?? symbol.EndLine) - (symbol.BodyStartLine ?? symbol.StartLine); + + private static void ReportReferenceLookupBudgetHit( + Action? reportDiagnostic, + string kind, + string message) + => reportDiagnostic?.Invoke(new ReferenceExtractionDiagnostic(kind, message)); + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.LanguageSupport.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.LanguageSupport.cs new file mode 100644 index 000000000..676223da8 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.LanguageSupport.cs @@ -0,0 +1,197 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + public static IReadOnlyCollection GetSupportedLanguages() + => GetSupportedLanguages(workspaceRoot: null); + + internal static IReadOnlyCollection GetSupportedLanguages(string? workspaceRoot) + { + var pluginLanguages = ExtractorPluginRegistry.GetReferenceLanguages(workspaceRoot); + var capacity = RegisteredLanguages.Count + AdditionalReferenceLanguages.Length + pluginLanguages.Count; + var languages = new List(capacity); + var seen = new HashSet(capacity, StringComparer.Ordinal); + + AddSupportedLanguages(RegisteredLanguages, languages, seen); + AddSupportedLanguages(AdditionalReferenceLanguages, languages, seen); + AddSupportedLanguages(pluginLanguages, languages, seen); + return languages.ToArray(); + } + + private static void AddSupportedLanguages( + IEnumerable candidates, + List languages, + HashSet seen) + { + foreach (var language in candidates) + { + if (seen.Add(language)) + languages.Add(language); + } + } + + /// + /// Registered language keys for reference extraction. + /// 参照抽出に登録されている言語キー。 + /// + public static IReadOnlyCollection RegisteredLanguages => BuiltInLanguages; + + private static string? NormalizeLanguage(string? lang) + { + if (lang is null) + return null; + + var trimmed = lang.AsSpan().Trim(); + if (trimmed.IsEmpty) + return null; + + if (trimmed.Equals("vue", StringComparison.OrdinalIgnoreCase) + || trimmed.Equals("svelte", StringComparison.OrdinalIgnoreCase)) + { + return "typescript"; + } + + if (trimmed.Equals("razor", StringComparison.OrdinalIgnoreCase) + || trimmed.Equals("blazor", StringComparison.OrdinalIgnoreCase) + || trimmed.Equals("cshtml", StringComparison.OrdinalIgnoreCase)) + { + return "csharp"; + } + + return NormalizeLanguageKey(lang, trimmed); + } + + private static string? NormalizePluginLanguage(string? lang) + { + if (lang is null) + return null; + + var trimmed = lang.AsSpan().Trim(); + return trimmed.IsEmpty ? null : NormalizeLanguageKey(lang, trimmed); + } + + private static string NormalizeLanguageKey(string original, ReadOnlySpan trimmed) + { + for (var i = 0; i < trimmed.Length; i++) + { + if (char.ToLowerInvariant(trimmed[i]) != trimmed[i]) + return trimmed.ToString().ToLowerInvariant(); + } + + return trimmed.Length == original.Length && trimmed.SequenceEqual(original.AsSpan()) + ? original + : trimmed.ToString(); + } + + public static bool SupportsLanguage(string? lang) + => SupportsLanguage(lang, GetSupportedLanguages(workspaceRoot: null)); + + internal static bool SupportsLanguage( + string? lang, + IReadOnlyCollection supportedLanguages) + { + var normalized = NormalizeLanguage(lang); + if (normalized != null && supportedLanguages.Contains(normalized, StringComparer.Ordinal)) + return true; + + return NormalizePluginLanguage(lang) is string pluginLanguage + && supportedLanguages.Contains(pluginLanguage, StringComparer.Ordinal); + } + + /// + /// Returns the registered reference extractor for a supported language. + /// 対応言語の登録済み参照抽出器を返す。 + /// + public static bool TryGetExtractor(string? lang, out IReferenceExtractor extractor) + => TryGetExtractor(lang, out extractor, out _); + + private static bool TryGetExtractor(string? lang, out IReferenceExtractor extractor, out string? normalized) + { + normalized = NormalizeLanguage(lang); + if (normalized != null && Extractors.TryGetValue(normalized, out extractor!)) + return true; + + extractor = null!; + return false; + } + + public static bool? SupportsSymbolGraph(string? lang, string? kind, string? containerKind) + { + if (lang == null) + return null; + + return SupportsLanguage(lang); + } + + internal static bool? SupportsSymbolGraph( + string? lang, + string? kind, + string? containerKind, + IReadOnlyCollection supportedLanguages) + { + if (lang == null) + return null; + + return SupportsLanguage(lang, supportedLanguages); + } + + public static string? GetUnsupportedSymbolKind(string? lang, string? kind, string? containerKind) + { + return null; + } + + /// + /// Build a human-readable reason explaining graph-support status for the given language. + /// Returns null when neither language nor support status is known. + /// 指定言語の graph 対応状況を人間向けに説明する文字列を返す。言語も対応状況も不明なら null。 + /// + public static string? BuildGraphSupportReason(string? lang, bool? graphSupported, string? kind = null, string? containerKind = null) + { + if (lang == null || graphSupported == null) + return null; + + if (graphSupported.Value) + return $"Call-graph extraction is indexed for '{lang}'."; + + return $"Call-graph extraction is not indexed for '{lang}'. Use search, definition, excerpt, or files instead."; + } + + public static string? BuildGraphSupportReasonWithUnsupportedEnumMemberGap(string? lang, bool? graphSupported, bool hasUnsupportedEnumMember, bool hasSupportedGraphDefinition) + { + var baseReason = BuildGraphSupportReason(lang, graphSupported); + if (!hasUnsupportedEnumMember) + return baseReason; + + var enumGapReason = hasSupportedGraphDefinition + ? "Exact results also include C# enum members whose access edges are not indexed yet." + : BuildGraphSupportReason("csharp", true, "enum", "enum"); + + if (!hasSupportedGraphDefinition) + return enumGapReason; + + if (string.IsNullOrWhiteSpace(baseReason)) + return enumGapReason; + if (string.IsNullOrWhiteSpace(enumGapReason) || string.Equals(baseReason, enumGapReason, StringComparison.Ordinal)) + return baseReason; + + return $"{baseReason} {enumGapReason}"; + } + + private static string NormalizeKotlinBacktickIdentifier(string name) + { + if (name.Length >= 2 && name[0] == '`' && name[^1] == '`') + return name[1..^1]; + return name; + } + + /// + /// Extract indexed references for supported languages. + /// 対応言語向けにインデックス化する参照を抽出する。 + /// +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.Patterns.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.Patterns.cs new file mode 100644 index 000000000..45c3faaf5 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.Patterns.cs @@ -0,0 +1,417 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static readonly Regex StringLiteralRegex = new( + "\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|`(?:\\\\.|[^`\\\\])*`", + RegexOptions.Compiled); + private static readonly Regex NonBacktickStringLiteralRegex = new( + "\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'", + RegexOptions.Compiled); + private static readonly Regex InlineBlockCommentRegex = new(@"/\*.*?\*/", RegexOptions.Compiled); + internal const string CSharpIdentifierPattern = @"@?[_\p{L}]\w*"; + private const string FunctionalIdentifierPattern = @"@?[_\p{L}\$][\w$]*"; + private const string CSharpTypeExpressionPattern = + @"(?:global::)?(?:" + + CSharpIdentifierPattern + + @"\s*(?:(?:\.|::)\s*" + + CSharpIdentifierPattern + + @")*)(?:\s*<[^)\];{}]+>)?(?:\s*\[[^\]\n]*\])*"; + private static readonly Regex CSharpLocalDeclarationRegex = new( + $@"(?{CSharpIdentifierPattern})\s*(?=[=;,\)])", + RegexOptions.Compiled); + private static readonly Regex CSharpLambdaRegex = new( + $@"(?\([^)]*\)|{CSharpIdentifierPattern})\s*=>\s*(?.*)$", + RegexOptions.Compiled); + // The `(?:\?\.)?` segment captures JavaScript / TypeScript optional chaining calls such as + // `callback?.()` and `callback?.()`. Without it the `?.` stops the regex from reaching the + // trailing `(`, and the call reference to `callback` is silently dropped. Other supported + // languages that use `?.` (C# / Kotlin / Swift / Dart) place an identifier between `?.` and + // `(`, so their existing call sites continue to match via the identifier itself. See issue #294. + // `(?:\?\.)?` は JavaScript / TypeScript の optional chaining 呼び出し (`callback?.()` や + // `callback?.()`) を捕捉するための segment。これが無いと `?.` の存在で末尾 `(` に到達できず、 + // `callback` への call 参照が黙って欠落する。C# / Kotlin / Swift / Dart などの `?.` は後ろに + // 識別子が続くため、従来通り識別子自身が CallRegex にマッチして影響を受けない。issue #294 参照。 + // Nested generic call sites such as `Foo>()` / `new Dict>()` are + // recovered by a depth-aware fallback scanner because the flat `<[^>\n]+>` segment cannot + // balance the closing `>>`. See issue #263. + // `Foo>()` や `new Dict>()` のようなネスト generic 呼び出しは、 + // 平坦な `<[^>\n]+>` では末尾 `>>` を釣り合わせられないため、depth-aware な fallback scanner + // で補完する。issue #263 参照。 + private static readonly Regex CallRegex = new($@"(?{CSharpIdentifierPattern})(?:\?\.)?(?:::)?(?:<[^>\n]+>)?\s*\(", RegexOptions.Compiled); + // Method-group / method-reference handoffs do not have a trailing `(`, so the shared + // CallRegex cannot see them. C# / JS / TS use a context gate plus a callable-name allowlist, + // while Java / Kotlin / Scala use the unique `::` sigil. + // `(` を持たない method-group / method-reference handoff は共通 CallRegex では拾えないため、 + // C# / JS / TS は文脈ゲート+ callable-name allowlist、Java / Kotlin / Scala は `::` sigil で拾う。 + private static readonly Regex MethodGroupReferenceRegex = new( + $@"(?\s+|(?{FunctionalIdentifierPattern})(?:<[^>\n]+>)?\s*\(\s*))(?:(?:this|base|{FunctionalIdentifierPattern}(?:\.{FunctionalIdentifierPattern})*)\s*\.\s*)?(?{FunctionalIdentifierPattern})(?!\s*\()(?!\s*`)(?=\s*(?:[;,)\]]|$))", + RegexOptions.Compiled); + // JSX / TSX component element open tags. Capitalized tag names are treated as component + // call sites, while lowercase intrinsic HTML tags stay excluded by design. + // JSX / TSX の component open tag。大文字始まりの tag 名だけを component 呼び出しとして扱い、 + // 小文字始まりの intrinsic HTML tag は意図的に除外する。 + private static readonly Regex JsxElementOpenRegex = new( + @"<(?[A-Z][\w$]*(?:\.[A-Za-z_$][\w$]*)*)", + RegexOptions.Compiled); + // SQL stored-procedure call without parentheses: T-SQL `EXEC` / `EXECUTE` and MySQL / MariaDB `CALL`. + // The shared CallRegex requires a trailing `(`, which misses the dominant real-world form such as + // `EXEC dbo.sp_Target;`, `EXEC dbo.sp_Target @x = 1, @y = 2;`, `CALL sp_Helper;`, and the bracketed + // form `EXEC [dbo].[sp_Target]`. The regex captures only the final identifier (schema prefixes are + // consumed as a prefix) and tolerates the optional T-SQL return-value assignment + // `EXEC @retval = dbo.sp_Target ...`. Bracket handling is done at emission time so `[sp_Target]` + // is normalized back to `sp_Target`. See issue #232. + // SQL のストアドプロシージャを `(` なしで呼び出す T-SQL `EXEC` / `EXECUTE` と MySQL / MariaDB `CALL`。 + // 共通 CallRegex は末尾 `(` を要求するため、`EXEC dbo.sp_Target;` など実運用で圧倒的に多い形を取りこぼす。 + // 先頭側の schema prefix は吸収し、末端の識別子だけを `name` として捕捉する。T-SQL 固有の + // `EXEC @retval = dbo.sp_Target ...` 形にも対応し、`[sp_Target]` のような角括弧識別子は発行時に除去する。 + // Bracketed identifiers inside the qualifier and name groups accept any character except `[`, + // `]`, or a line terminator. T-SQL allows `#` (temp procedure), `-` (hyphenated names), + // spaces, Unicode symbols, and punctuation inside bracket quoting, and the narrower `[\w ]+` + // would silently drop `EXEC [#tempProc]`, `EXEC [dbo].[proc-name]`, and similar legitimate + // forms while falsely misattributing the qualifier `[dbo]` as the proc name. + // Qualifier segments are optional (the inner `?`) so SQL Server's linked-server form with + // an omitted database or schema part — `EXEC AdventureWorks..sp_GetCustomer;` / + // `EXEC [AdventureWorks]..[proc-name];` — terminates on the real procedure name instead of + // falling back to the first segment. Identifier alternatives also accept backtick-quoted + // C# event subscription/unsubscription: Click += OnClick — both LHS and RHS must be PascalCase identifiers + // C# イベント購読・解除: Click += OnClick — LHS と RHS の両方が PascalCase 識別子のみ + private static readonly Regex EventSubscriptionRegex = new(@"(?[A-Z]\w*)\s*[+-]=\s*(?:new\s+)?[A-Z]\w*", RegexOptions.Compiled); + // C# / Java parenless object / collection / dictionary / array initializer such as + // `new Foo { X = 1 }`, `new List { 1, 2, 3 }`, `new Dictionary { [k] = v }`, + // `new Foo[] { ... }`, `new Foo[N] { ... }`, `new Foo[,] { ... }`, `new Foo[][] { ... }`, + // and qualified type names like `new N.Foo { X = 1 }` / `new global::N.Foo { X = 1 }`. + // CallRegex requires a trailing `(`, so these forms are otherwise dropped from the + // reference table even though the type is genuinely instantiated. Anonymous types + // (`new { Name = ... }`), target-typed `new()`, and collection expressions (`new[] { ... }`) + // intentionally do not match because they have no named target. Nested generics deeper than + // one `<...>` level (e.g. `new Dictionary> { ... }`) follow the same + // limitation as the existing CallRegex generics handling. See issue #286. + // C# / Java の括弧省略インスタンス化(オブジェクト / コレクション / ディクショナリ / + // 配列イニシャライザ)。CallRegex は `(` が必須なため取りこぼすが、実体は型のインスタンス化なので + // `instantiate` として拾う。匿名型 `new { ... }`、target-typed `new()`、 + // collection expression `new[] { ... }` は対象を持たないため意図的にマッチさせない。 + // 1 段を超えるネストした generic(`Dictionary>` 等)は既存 CallRegex と同様の制限。issue #286 参照。 + private static readonly Regex CSharpJavaInitializerRegex = new( + $@"\bnew\s+(?:global::)?(?:{CSharpIdentifierPattern}(?:\s*::\s*|\s*\.\s*))*(?{CSharpIdentifierPattern})(?:\s*<[^>\n]+>)?(?:\s*\[[^\[\]\n]*\])*\s*\{{", + RegexOptions.Compiled); + // Allman-style C# / Java parenless initializer where `{` sits on the next non-empty + // line. The trailing regex captures `new ` ending the current line (with optional + // generic + array shape), and the caller peeks forward to confirm the next non-blank + // prepared line begins with `{` before emitting an `instantiate` edge. See issue #286. + // Allman スタイルの多行 parenless initializer。`new ` が行末で終わり、次の非空 prepared line が + // `{` から始まる場合にだけ `instantiate` を発行する。issue #286 参照。 + private static readonly Regex CSharpJavaInitializerTrailingRegex = new( + $@"\bnew\s+(?:global::)?(?:{CSharpIdentifierPattern}(?:\s*::\s*|\s*\.\s*))*(?{CSharpIdentifierPattern})(?:\s*<[^>\n]+>)?(?:\s*\[[^\[\]\n]*\])*\s*$", + RegexOptions.Compiled); + private static readonly Regex CSharpUsingAliasRegex = new( + @"^\s*(?:global\s+)?using\s+(?!static\b)(?@?[A-Za-z_]\w*)\s*=\s*(?[^;]+)", + RegexOptions.Compiled); + private static readonly Regex CSharpUsingNamespaceRegex = new( + @"^\s*(?:global\s+)?using\s+(?!static\b)(?[^;=]+?)\s*;?\s*$", + RegexOptions.Compiled); + private static readonly Regex CSharpUsingStaticRegex = new( + @"^\s*(?:global\s+)?using\s+static\s+(?[^;]+)", + RegexOptions.Compiled); + private static readonly Regex CSharpLocalValueNameRegex = new( + @"(?:^\s*|[;{}]\s*)(?:(?:(?:await\s+)?using\s+var)|var|(?:(?:const\s+)?[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))\s+(?@?[A-Za-z_]\w*)\s*(?==|;|,)", + RegexOptions.Compiled); + private static readonly Regex CSharpForeachValueNameRegex = new( + @"\bforeach\s*\(\s*(?:var|(?:[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))\s+(?@?[A-Za-z_]\w*)\s+in\b", + RegexOptions.Compiled); + private static readonly Regex CSharpQueryRangeValueNameRegex = new( + @"\b(?:from|join)\s+(?@?[A-Za-z_]\w*)\s+in\b|\blet\s+(?@?[A-Za-z_]\w*)\s*=|\binto\s+(?@?[A-Za-z_]\w*)\b", + RegexOptions.Compiled); + private const string CSharpDeclarationPatternTypeRegex = @"(?:var|(?:[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))"; + private const string CSharpRecursivePatternClauseRegex = @"(?:\s*\{[^\n]*\})?"; + private static readonly Regex CSharpDeclarationPatternValueNameRegex = new( + @"\bis\s+" + CSharpDeclarationPatternTypeRegex + CSharpRecursivePatternClauseRegex + @"\s+(?@?[A-Za-z_]\w*)\b", + RegexOptions.Compiled); + private static readonly Regex CSharpSwitchExpressionDeclarationPatternValueNameRegex = new( + @"^\s*(?" + CSharpDeclarationPatternTypeRegex + @")\s+(?@?[A-Za-z_]\w*)\s*$", + RegexOptions.Compiled); + private static readonly Regex CSharpCaseDeclarationPatternValueNameRegex = new( + @"\bcase\s+" + CSharpDeclarationPatternTypeRegex + CSharpRecursivePatternClauseRegex + @"\s+(?@?[A-Za-z_]\w*)\b(?=\s*(?::|\bwhen\b))", + RegexOptions.Compiled); + private static readonly Regex CSharpOutValueNameRegex = new( + @"\bout\s+(?:var|(?:[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))\s+(?@?[A-Za-z_]\w*)(?=\s*[\),])", + RegexOptions.Compiled); + private static readonly Regex CSharpCatchValueNameRegex = new( + @"\bcatch\s*\(\s*(?:[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*)\s+(?@?[A-Za-z_]\w*)", + RegexOptions.Compiled); + private static readonly Regex CSharpUsingStatementValueNameRegex = new( + @"\busing\s*\(\s*(?:var|(?:[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))\s+(?@?[A-Za-z_]\w*)\s*=", + RegexOptions.Compiled); + private static readonly Regex CSharpFixedValueNameRegex = new( + @"\bfixed\s*\(\s*(?:var|(?:[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))\s+(?@?[A-Za-z_]\w*)\s*=", + RegexOptions.Compiled); + private static readonly Regex CSharpStaticModifierRegex = new(@"\bstatic\b", RegexOptions.Compiled); + // Inline `where` constraint in a C# type header; used to trim base-list parsing + // C# 型ヘッダーの where 制約句。base-list 解析の終端として使用 + private static readonly Regex CSharpWhereClauseRegex = new(@"\s+where\s+(?[\w?.]+)\s*:", RegexOptions.Compiled); + // C# record declaration with a primary-constructor parameter list. + // Used to synthesize a function-kind container for primary-ctor base calls + // (e.g. `record Child(int x) : Parent(x)`), so `callers` / `callees` / `impact` + // can attribute the `Parent(x)` edge to the record's synthetic constructor. + // C# record のプライマリーコンストラクタ宣言を検出し、base primary-ctor 呼び出しの + // 参照を record の合成コンストラクタに紐付けるために使う。 + private static readonly Regex CSharpRecordPrimaryCtorSignatureRegex = new( + $@"\brecord\s+(?:class\s+|struct\s+)?{CSharpIdentifierPattern}(?:<[^>]+>)?\s*\(", + RegexOptions.Compiled); + // Same intent as CSharpRecordPrimaryCtorSignatureRegex but applied to the joined multi-line + // header produced by CollectCSharpRecordHeader, so split-line forms like + // `public record Child\n(\n int Value\n)\n : Parent(Value);` still match. + // Also covers C# 12 `class` / `struct` primary constructors such as + // `public class Child(int value) : Parent(value) { }` and + // `public struct Child(int value) : IParent { }` so their `Parent(value)` chain edges are + // also attributed to the synthetic function-kind container named after the declaring type. + // CollectCSharpRecordHeader で連結された複数行ヘッダーに対しても当てるため、`record` / `class` / + // `struct` と `(` が別行に分かれる書式でも primary-ctor 宣言と判定できるようにする。 + // C# 12 以降の class / struct primary constructor にも同じ合成コンテナ経路を適用する。 + private static readonly Regex CSharpPrimaryCtorHeaderRegex = new( + $@"\b(?:record\s+(?:class\s+|struct\s+)?|class\s+|struct\s+){CSharpIdentifierPattern}(?:\s*<[^>]+>)?\s*\(", + RegexOptions.Compiled); + // C# compile-time type/member references: `nameof(X.Y)`, `typeof(T)`, `sizeof(T)`, `default(T)`. + // Keywords are in SharedIgnoredCallNames so CallRegex skips them, but their arguments have no + // trailing `(` and therefore slip through. Captured here as a dedicated "type_reference" kind + // so callers/callees (which exclude type_reference by default) stay unaffected while + // references and impact see the edge. See issue #253. + // The regex only locates the keyword and opening `(`; the argument itself is walked by + // ExtractCSharpTypeKeywordSegments so generic `<...>`, array `[...]`, and `global::` qualifiers + // are handled without truncating the real type path. + // C# の nameof/typeof/sizeof/default は、キーワード自体が SharedIgnoredCallNames にあるため + // CallRegex では読み飛ばされ、引数の識別子も末尾に `(` が無いため通常経路では捕捉できない。 + // ここで type_reference として拾い、callers/callees(既定で type_reference を除外)に影響せず + // references と impact だけに edge を届ける。issue #253 参照。 + // 正規表現はキーワードと `(` の位置だけを捕捉し、引数本体の走査は ExtractCSharpTypeKeywordSegments + // に任せる。これにより generic `<...>`、配列 `[...]`、`global::` 等を途中で切らない。 + private static readonly Regex CSharpTypeKeywordIntroRegex = new( + @"(?nameof|typeof|sizeof|default)\s*\(", + RegexOptions.Compiled); + // Reflection member-name lookups such as `GetMethod("Run")` carry a real member reference + // even though the symbol name appears as string data. Emit only literal or literal-concat + // first arguments so dynamic names stay conservative. + private static readonly Regex CSharpReflectionNameApiIntroRegex = new( + @"(?GetMethod|GetField|GetProperty|GetEvent|GetMember|GetNestedType)\s*\(", + RegexOptions.Compiled); + // C# type tests (`o is Base`, `o is not Base`, `o as Base`). + // `is` / `is not` / `as` の型位置 (`o is Base`, `o is not Base`, `o as Base`)。 + private static readonly Regex CSharpIsAsTypeTestRegex = new( + $@"(?{CSharpTypeExpressionPattern})", + RegexOptions.Compiled, + ExtractionRegexTimeout); + internal static readonly Regex CSharpTrailingIsAsTypePatternIntroRegex = new( + @"(?{CSharpTypeExpressionPattern})", + RegexOptions.Compiled, + ExtractionRegexTimeout); + // C# XML-doc cross-reference (``, ``). + // C# XML doc の `` / ``。 + private static readonly Regex CSharpDocCrefRegex = new( + @"<(?:see|seealso)\s+cref\s*=\s*""(?[^""]+)""", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + // Javadoc / KDoc cross-reference links (`{@link Foo#bar}`, `@see Foo`, `[Foo.bar]`). + // Javadoc / KDoc の cross-reference link。 + private static readonly Regex JvmDocInlineLinkRegex = new( + @"\{@(?:link|linkplain|value)\s+(?[^\s}]+)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex JvmDocSeeReferenceRegex = new( + @"(?:^|\s)@(?:see|throws|exception)\s+(?[^\s}]+)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex KDocBracketLinkRegex = new( + @"\[(?#?(?:[_\p{L}][\w$]*|`[^`\r\n]+`)(?:(?:\.|#)(?:[_\p{L}][\w$]*|`[^`\r\n]+`))*)\](?!\s*(?:\(|\[))", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + // Java primitive type names that can precede `.class` (e.g. `int.class`, `void.class`). + // Skipped from reference rows because they are language-level keywords, not indexed types. + // `int.class` 等に現れる Java のプリミティブ型。インデックス対象の型ではないため除外する。 + private static readonly HashSet JavaPrimitiveTypeNames = new(StringComparer.Ordinal) + { + "int", "long", "short", "boolean", "byte", "char", "float", "double", "void", + }; + + // C# predefined type aliases / void / dynamic / var. They resolve to BCL primitives that are + // not indexed as user-defined symbols, so emitting them as `type_reference` just pollutes + // references/inspect output without ever linking to a real definition. + // C# の built-in 型 alias / void / dynamic / var。ユーザー定義シンボルに解決しないため + // type_reference として残すとノイズにしかならない。issue #253 のレビュー指摘により除外。 + private static readonly HashSet CSharpBuiltInTypeNames = new(StringComparer.Ordinal) + { + "bool", "byte", "sbyte", "short", "ushort", "int", "uint", "long", "ulong", + "nint", "nuint", "char", "float", "double", "decimal", + "string", "object", "void", "dynamic", "var", + }; + private static readonly HashSet CSharpWhereConstraintIgnoredSegments = new(StringComparer.Ordinal) + { + "allows", "default", "notnull", "ref", "unmanaged", + }; + private static readonly Dictionary> LanguageBuiltInTypeNames = new(StringComparer.Ordinal) + { + ["typescript"] = new HashSet(StringComparer.Ordinal) + { + "any", "bigint", "boolean", "false", "never", "null", "number", "object", "string", + "infer", "keyof", "readonly", "symbol", "true", "undefined", "unique", "unknown", "void", + }, + ["kotlin"] = new HashSet(StringComparer.Ordinal) + { + "Any", "Boolean", "Byte", "Char", "Double", "Float", "Int", "Long", "Nothing", + "Short", "String", "Unit", + }, + ["swift"] = new HashSet(StringComparer.Ordinal) + { + "Any", "Bool", "Character", "Double", "Float", "Int", "Int8", "Int16", "Int32", "Int64", + "Never", "Self", "String", "UInt", "UInt8", "UInt16", "UInt32", "UInt64", "Void", + "any", "async", "borrowing", "consuming", "each", "inout", "isolated", "repeat", "rethrows", + "sending", "some", "throws", + }, + ["rust"] = new HashSet(StringComparer.Ordinal) + { + "Self", "bool", "char", "const", "dyn", "f32", "f64", "for", "i8", "i16", "i32", "i64", "i128", + "impl", "isize", "mut", "ref", "static", "str", "u8", "u16", "u32", "u64", "u128", "usize", + }, + ["c"] = new HashSet(StringComparer.Ordinal) + { + "_Atomic", "bool", "char", "const", "double", "enum", "float", "int", "long", + "restrict", "short", "signed", "size_t", "ssize_t", "struct", "uint8_t", + "uint16_t", "uint32_t", "uint64_t", "union", "unsigned", "void", "volatile", + }, + ["cpp"] = new HashSet(StringComparer.Ordinal) + { + "bool", "char", "char8_t", "char16_t", "char32_t", "double", "float", "int", "long", + "short", "signed", "size_t", "ssize_t", "std", "string", "unsigned", "void", + "wchar_t", + }, + ["go"] = new HashSet(StringComparer.Ordinal) + { + "any", "bool", "byte", "comparable", "complex64", "complex128", "error", "float32", + "float64", "int", "int8", "int16", "int32", "int64", "rune", "string", "uint", + "uint8", "uint16", "uint32", "uint64", "uintptr", "chan", "func", "interface", + "map", "struct", + }, + ["dart"] = new HashSet(StringComparer.Ordinal) + { + "bool", "double", "dynamic", "Function", "int", "Never", "Null", "num", "Object", + "String", "void", + }, + ["vb"] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Boolean", "Byte", "Char", "Date", "Decimal", "Double", "Integer", "Long", "Object", + "SByte", "Short", "Single", "String", "UInteger", "ULong", "UShort", "Void", + }, + ["fortran"] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "character", "complex", "double", "integer", "logical", "precision", "real", + }, + ["pascal"] = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "AnsiString", "Boolean", "Byte", "Cardinal", "Char", "Double", "Extended", "Integer", + "LongInt", "LongWord", "Pointer", "Real", "ShortInt", "Single", "SmallInt", "String", + "Variant", "WideString", "Word", + }, + ["objc"] = new HashSet(StringComparer.Ordinal) + { + "BOOL", "Class", "CGFloat", "NSInteger", "NSUInteger", "SEL", "bool", "char", "double", + "float", "id", "instancetype", "int", "long", "short", "void", + }, + ["haskell"] = new HashSet(StringComparer.Ordinal) + { + "Bool", "Char", "Double", "Either", "False", "Float", "IO", "Int", "Integer", "Maybe", + "Nothing", "String", "True", + }, + }; + // C# pattern-only keywords / literals that can appear after `is` / `case not` but are never + // real user-defined types. Filter them before AddTypeExpressionSegments so `is not null`, + // `is default`, and similar constant patterns do not surface phantom `type_reference` rows. + // `is` / `case not` の後ろに現れうるが、実在型ではない C# のパターン専用キーワード / リテラル。 + // AddTypeExpressionSegments 前に落とし、`is not null` や `is default` などの定数パターンから + // phantom な `type_reference` 行が出ないようにする。 + private static readonly HashSet CSharpNonTypePatternTokens = new(StringComparer.Ordinal) + { + "default", "false", "not", "null", "true", + }; + + // No-arg C# attribute name (`[Serializable]`, `[assembly: CLSCompliant]`, `[System.Obsolete]`, + // `[global::System.Obsolete]`, `[Alias::MyAttr]`, `[Required, Key]`, and their multi-line + // variants where `[` / `]` sit on separate lines). CallRegex only matches identifiers followed + // by `(`, so no-arg attributes would otherwise never be indexed. The pattern refuses to match + // when the identifier is followed by `(` (handled by CallRegex + TryClassifyMetadataReference) + // or a qualifier continuation (`.` / `::`). The match is gated downstream by + // `IsInsideCSharpAttributeRange`, so it is safe to relax the `[` / `,` left-anchor in favor of + // a word-boundary lookbehind — that lets a bare identifier on a line like ` Serializable` + // inside a multi-line attribute section still be recognized. + // 引数なしの C# attribute 名用 regex。`[Serializable]` などは CallRegex では拾えないため専用の + // 入口で捕捉する。`global::System.Obsolete` や `Alias::MyAttr` のように `::` 修飾子の付く形も + // 許容する。`[` / `,` / `]` が別行にある複数行形(例: `[\n Serializable\n]`)も取り込むため、 + // 左側は `[` / `,` ではなく単語境界だけでアンカーする。属性以外の位置で誤検出しないよう、 + // マッチ後は `IsInsideCSharpAttributeRange` で属性レンジ内かどうかを確認する。後続が `(` + // (CallRegex 経路)や `.` / `::`(qualifier 継続)なら名前を確定させず、行末(`$`)・`]`・`,` + // のいずれかで初めて採用する。 + private static readonly Regex CSharpNoArgAttributeRegex = new( + $@"(?{CSharpIdentifierPattern})(?:\s*<[^\n]+?>)?\s*(?=[\],]|$)", + RegexOptions.Compiled); + + // No-arg Java-family annotation (`@Deprecated`, `@Override`, `@org.junit.Test`, `@field:Deprecated`). + // CallRegex only catches `@Name(` forms; this pattern fills the bare `@Name` gap. The leading + // lookbehind `(?[A-Za-z_]\w*)\b(?!\s*[.(])", + RegexOptions.Compiled); + private static readonly Regex KotlinBacktickAnnotationRegex = new( + @"(?`[^`\r\n]+`)(?:\s*\([^)\r\n]*\))?", + RegexOptions.Compiled); + + + // Languages whose `@Decorator(args)` / `@Annotation(args)` / `@Attribute(args)` syntax + // should produce `annotation` reference rows rather than `call` rows (issue #293). + // Swift uses `@available(...)`, `@objc`, `@MainActor`, etc. as compile-time metadata; + // Gradle/Groovy uses `@CompileStatic`, `@TaskAction`, etc. the same way. Without this + // reclassification, `callers` / `callees` / `hotspots` / `impact` on those languages + // get polluted with metadata edges. + // `@Decorator(args)` / `@Annotation(args)` / `@Attribute(args)` を `call` ではなく + // `annotation` として記録すべき言語 (issue #293)。Swift の `@available(...)` / `@objc` / + // `@MainActor` や、Gradle/Groovy の `@CompileStatic` / `@TaskAction` も compile-time + // metadata なので同じ扱いにする。再分類しないと `callers` / `callees` / `hotspots` / + // `impact` に metadata edge が混入する。 + private static readonly HashSet AnnotationLanguages = new(StringComparer.Ordinal) + { + "java", "kotlin", "scala", "typescript", "javascript", "swift", "gradle", "groovy", "dart", + }; + + // Kotlin use-site target prefixes for annotations (e.g. `@field:Deprecated("msg")`, + // `@file:JvmName("Foo")`). Keep aligned with the Kotlin language spec use-site targets. + // Kotlin の use-site target 付き注釈用の接頭辞。 + private static readonly HashSet KotlinAnnotationTargets = new(StringComparer.Ordinal) + { + "field", "get", "set", "param", "setparam", "property", "receiver", "file", "delegate", "all", + }; + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.PythonLogicalLines.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.PythonLogicalLines.cs new file mode 100644 index 000000000..7e45b0689 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.PythonLogicalLines.cs @@ -0,0 +1,446 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private readonly record struct PythonLogicalHeaderReferenceLine( + string Text, + int SinglePhysicalLine, + int SinglePhysicalColumn, + int[]? PhysicalLines, + int[]? PhysicalColumns); + + private static bool TryBuildPythonLogicalHeaderReferenceLine( + string[] lines, + int startLineIndex, + int startColumn, + out PythonLogicalHeaderReferenceLine header) + { + var builder = new StringBuilder(GetPythonLogicalLineInitialCapacity(lines, startLineIndex, startColumn)); + List? physicalLines = null; + List? physicalColumns = null; + var singlePhysicalLine = -1; + var singlePhysicalColumn = 0; + var parenDepth = 0; + var bracketDepth = 0; + var inString = false; + var quote = '\0'; + + for (var lineIndex = startLineIndex; lineIndex < lines.Length; lineIndex++) + { + var line = lines[lineIndex]; + var column = lineIndex == startLineIndex ? startColumn : FindFirstNonWhitespaceColumn(line); + var fragmentEndColumn = FindPythonCommentColumn(line, column); + if (column < fragmentEndColumn) + { + if (builder.Length > 0) + { + if (!TryAppendPythonLogicalReferenceChar(builder, ref singlePhysicalLine, ref singlePhysicalColumn, ref physicalLines, ref physicalColumns, ' ', lineIndex, column, out header)) + return false; + } + + for (var fragmentColumn = column; fragmentColumn < fragmentEndColumn; fragmentColumn++) + { + var fragmentChar = line[fragmentColumn]; + if (fragmentChar == '\\' && fragmentColumn == fragmentEndColumn - 1) + break; + + if (!TryAppendPythonLogicalReferenceChar(builder, ref singlePhysicalLine, ref singlePhysicalColumn, ref physicalLines, ref physicalColumns, fragmentChar, lineIndex, fragmentColumn, out header)) + return false; + } + } + + for (var scan = column; scan < line.Length; scan++) + { + var ch = line[scan]; + if (inString) + { + if (ch == '\\') + { + scan++; + continue; + } + + if (ch == quote) + inString = false; + continue; + } + + if (ch is '\'' or '"') + { + inString = true; + quote = ch; + continue; + } + + if (ch == '#') + break; + if (ch == '(') + parenDepth++; + else if (ch == ')' && parenDepth > 0) + parenDepth--; + else if (ch == '[') + bracketDepth++; + else if (ch == ']' && bracketDepth > 0) + bracketDepth--; + else if (ch == ':' && parenDepth == 0 && bracketDepth == 0) + { + header = CreatePythonLogicalHeaderReferenceLine(builder, singlePhysicalLine, singlePhysicalColumn, physicalLines, physicalColumns); + return header.Text.Length > 0; + } + } + + if (parenDepth == 0 && bracketDepth == 0 && !HasPythonLineContinuationBackslash(line)) + break; + } + + header = CreatePythonLogicalHeaderReferenceLine(builder, singlePhysicalLine, singlePhysicalColumn, physicalLines, physicalColumns); + return header.Text.Length > 0; + } + + private static bool TryBuildPythonLogicalStatementReferenceLine( + string[] lines, + int startLineIndex, + int startColumn, + out PythonLogicalHeaderReferenceLine header) + { + var builder = new StringBuilder(GetPythonLogicalLineInitialCapacity(lines, startLineIndex, startColumn)); + List? physicalLines = null; + List? physicalColumns = null; + var singlePhysicalLine = -1; + var singlePhysicalColumn = 0; + var parenDepth = 0; + var bracketDepth = 0; + var inString = false; + var quote = '\0'; + + for (var lineIndex = startLineIndex; lineIndex < lines.Length; lineIndex++) + { + var line = lines[lineIndex]; + var column = lineIndex == startLineIndex ? startColumn : FindFirstNonWhitespaceColumn(line); + var fragmentEndColumn = FindPythonCommentColumn(line, column); + if (column < fragmentEndColumn) + { + if (builder.Length > 0) + { + if (!TryAppendPythonLogicalReferenceChar(builder, ref singlePhysicalLine, ref singlePhysicalColumn, ref physicalLines, ref physicalColumns, ' ', lineIndex, column, out header)) + return false; + } + + for (var fragmentColumn = column; fragmentColumn < fragmentEndColumn; fragmentColumn++) + { + var fragmentChar = line[fragmentColumn]; + if (fragmentChar == '\\' && fragmentColumn == fragmentEndColumn - 1) + break; + + if (!TryAppendPythonLogicalReferenceChar(builder, ref singlePhysicalLine, ref singlePhysicalColumn, ref physicalLines, ref physicalColumns, fragmentChar, lineIndex, fragmentColumn, out header)) + return false; + } + } + + for (var scan = column; scan < line.Length; scan++) + { + var ch = line[scan]; + if (inString) + { + if (ch == '\\') + { + scan++; + continue; + } + + if (ch == quote) + inString = false; + continue; + } + + if (ch is '\'' or '"') + { + inString = true; + quote = ch; + continue; + } + + if (ch == '#') + break; + if (ch == '(') + parenDepth++; + else if (ch == ')' && parenDepth > 0) + parenDepth--; + else if (ch == '[') + bracketDepth++; + else if (ch == ']' && bracketDepth > 0) + bracketDepth--; + } + + if (parenDepth == 0 && bracketDepth == 0 && !HasPythonLineContinuationBackslash(line)) + break; + } + + header = CreatePythonLogicalHeaderReferenceLine(builder, singlePhysicalLine, singlePhysicalColumn, physicalLines, physicalColumns); + return header.Text.Length > 0; + } + + private static PythonLogicalHeaderReferenceLine CreatePythonLogicalHeaderReferenceLine( + StringBuilder builder, + int singlePhysicalLine, + int singlePhysicalColumn, + List? physicalLines, + List? physicalColumns) + { + if (physicalLines == null || physicalColumns == null) + return new PythonLogicalHeaderReferenceLine(builder.ToString(), singlePhysicalLine, singlePhysicalColumn, null, null); + + return new PythonLogicalHeaderReferenceLine( + builder.ToString(), + singlePhysicalLine, + singlePhysicalColumn, + physicalLines.ToArray(), + physicalColumns.ToArray()); + } + + private static int GetPythonLogicalLineInitialCapacity(string[] lines, int startLineIndex, int startColumn) + { + if (startLineIndex < 0 || startLineIndex >= lines.Length) + return 0; + + return Math.Min(256, Math.Max(0, lines[startLineIndex].Length - startColumn)); + } + + private static bool HasPythonLineContinuationBackslash(string line) + { + for (var index = line.Length - 1; index >= 0; index--) + { + if (char.IsWhiteSpace(line[index])) + continue; + + return line[index] == '\\'; + } + + return false; + } + + private static bool TryAppendPythonLogicalReferenceChar( + StringBuilder builder, + ref int singlePhysicalLine, + ref int singlePhysicalColumn, + ref List? physicalLines, + ref List? physicalColumns, + char value, + int physicalLine, + int physicalColumn, + out PythonLogicalHeaderReferenceLine header) + { + if (builder.Length >= MaxPythonLogicalReferenceLineLength) + { + header = default; + return false; + } + + if (builder.Length == 0) + { + singlePhysicalLine = physicalLine; + singlePhysicalColumn = physicalColumn; + } + else if (physicalLines == null + && (physicalLine != singlePhysicalLine + || physicalColumn != singlePhysicalColumn + builder.Length)) + { + physicalLines = new List(builder.Length + 1); + physicalColumns = new List(builder.Length + 1); + for (var index = 0; index < builder.Length; index++) + { + physicalLines.Add(singlePhysicalLine); + physicalColumns.Add(singlePhysicalColumn + index); + } + } + + builder.Append(value); + if (physicalLines != null) + { + physicalLines.Add(physicalLine); + physicalColumns!.Add(physicalColumn); + } + + header = default; + return true; + } + + private static int FindPythonCommentColumn(string line, int startColumn) + { + var inString = false; + var quote = '\0'; + for (var index = startColumn; index < line.Length; index++) + { + var ch = line[index]; + if (inString) + { + if (ch == '\\') + { + index++; + continue; + } + + if (ch == quote) + inString = false; + continue; + } + + if (ch is '\'' or '"') + { + inString = true; + quote = ch; + continue; + } + + if (ch == '#') + return index; + } + + return line.Length; + } + + private static int FindFirstNonWhitespaceColumn(string line) + { + var index = 0; + while (index < line.Length && char.IsWhiteSpace(line[index])) + index++; + return index; + } + + private static void RemapPythonLogicalHeaderReferences( + List references, + int startIndex, + PythonLogicalHeaderReferenceLine header, + string[] lines) + { + for (var i = startIndex; i < references.Count; i++) + { + var logicalIndex = references[i].Column - 1; + var logicalLength = header.PhysicalLines?.Length ?? header.Text.Length; + if (logicalIndex < 0 || logicalIndex >= logicalLength) + continue; + + var physicalLineIndex = header.SinglePhysicalLine; + var physicalColumn = header.SinglePhysicalColumn + logicalIndex; + if (header.PhysicalLines is { } physicalLines && header.PhysicalColumns is { } physicalColumns) + { + physicalLineIndex = physicalLines[logicalIndex]; + physicalColumn = physicalColumns[logicalIndex]; + } + + if (physicalLineIndex < 0) + continue; + + references[i].Line = physicalLineIndex + 1; + references[i].Column = physicalColumn + 1; + references[i].Context = lines[physicalLineIndex].Trim(); + } + } + + private static ( + IReadOnlyDictionary<(int Line, string Kind), SymbolRecord>? DefinitionContainersByLineAndKind, + IReadOnlyDictionary? HeaderSymbolsByLine) BuildPythonSymbolLookups(IReadOnlyList symbols) + { + Dictionary<(int Line, string Kind), SymbolRecord>? containers = null; + Dictionary? symbolsByLine = null; + foreach (var symbol in symbols) + { + if (symbol.Kind is "class" or "function") + (containers ??= []).TryAdd((symbol.Line, symbol.Kind), symbol); + + if (symbol.Signature == null + || symbol.Kind is not ("function" or "class" or "property" or "class_hook")) + continue; + + (symbolsByLine ??= []).TryAdd(symbol.Line, symbol); + } + + return (containers, symbolsByLine); + } + + private static bool IsJsxFilePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return false; + + var extension = Path.GetExtension(path.AsSpan()); + return extension.Equals(".jsx".AsSpan(), StringComparison.OrdinalIgnoreCase) + || extension.Equals(".tsx".AsSpan(), StringComparison.OrdinalIgnoreCase); + } + + private static bool TrySkipTypeScriptJsxTypeArguments(string preparedLine, ref int scan) + { + if (scan >= preparedLine.Length || preparedLine[scan] != '<') + return false; + + var depth = 0; + while (scan < preparedLine.Length) + { + var ch = preparedLine[scan++]; + if (ch == '\'' || ch == '"') + { + while (scan < preparedLine.Length) + { + var quoted = preparedLine[scan++]; + if (quoted == '\\') + { + scan = Math.Min(scan + 1, preparedLine.Length); + continue; + } + + if (quoted == ch) + break; + } + + continue; + } + + if (ch == '=' && scan < preparedLine.Length && preparedLine[scan] == '>') + { + scan++; + continue; + } + + if (ch == '<') + { + depth++; + } + else if (ch == '>') + { + depth--; + if (depth == 0) + return true; + if (depth < 0) + return false; + } + } + + return false; + } + + private static bool IsRazorFilePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return false; + + var extension = Path.GetExtension(path.AsSpan()); + return extension.Equals(".razor".AsSpan(), StringComparison.OrdinalIgnoreCase) + || extension.Equals(".cshtml".AsSpan(), StringComparison.OrdinalIgnoreCase); + } + + private static bool IsObjCSelectorLiteralCall(string line, string name, int nameIndex) => + string.Equals(NormalizeAtPrefixedIdentifier(name), "selector", StringComparison.Ordinal) + && (name.StartsWith('@') || nameIndex > 0 && line[nameIndex - 1] == '@'); + + /// + /// Emit one `type_reference` row per dot-segment of a captured argument. Columns are + /// computed relative to the original line so tooling can jump to the exact identifier. + /// 捕捉した引数の dot-segment ごとに `type_reference` 行を発行する。列位置は元の行基準で計算する。 + /// +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.ReferenceRecords.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.ReferenceRecords.cs new file mode 100644 index 000000000..55f451cb8 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.ReferenceRecords.cs @@ -0,0 +1,540 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static void EmitPhpLinePreambleReferences( + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + int lineNumber, + Func getLineContainer, + ref bool inDocblock, + ref SymbolRecord? docblockContainer, + ref HashSet? docblockPropertyNames) + { + if (originalLine.Contains("#[", StringComparison.Ordinal)) + { + var attributeContext = originalLine.Trim(); + if (attributeContext.Length > 0) + { + PhpReferenceExtractor.EmitAttributeReferences( + originalLine, + references, + seen, + fileId, + attributeContext, + lineNumber, + getLineContainer()); + } + } + + if (originalLine.IndexOf("/**", StringComparison.Ordinal) >= 0) + { + inDocblock = true; + docblockContainer = getLineContainer(); + docblockPropertyNames = null; + } + + var docblockContext = originalLine.Trim(); + if (docblockContext.Length > 0) + { + if (originalLine.Contains("param", StringComparison.OrdinalIgnoreCase)) + { + PhpReferenceExtractor.EmitDocblockParamTypeReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); + } + + if (originalLine.Contains("return", StringComparison.OrdinalIgnoreCase)) + { + PhpReferenceExtractor.EmitDocblockReturnTypeReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); + } + + if (originalLine.Contains("var", StringComparison.OrdinalIgnoreCase)) + { + PhpReferenceExtractor.EmitDocblockVarTypeReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); + } + + if (originalLine.Contains("@throws", StringComparison.OrdinalIgnoreCase)) + { + PhpReferenceExtractor.EmitDocblockThrowsTypeReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); + } + + if (originalLine.Contains("extends", StringComparison.OrdinalIgnoreCase)) + { + PhpReferenceExtractor.EmitDocblockExtendsTypeReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); + } + + if (originalLine.Contains("implements", StringComparison.OrdinalIgnoreCase)) + { + PhpReferenceExtractor.EmitDocblockImplementsTypeReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); + } + + if (originalLine.Contains("@mixin", StringComparison.OrdinalIgnoreCase)) + { + PhpReferenceExtractor.EmitDocblockMixinTypeReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); + } + + if (originalLine.Contains("property", StringComparison.OrdinalIgnoreCase)) + { + PhpReferenceExtractor.EmitDocblockPropertyTypeReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer), + inDocblock, + ref docblockPropertyNames); + } + + if (originalLine.Contains("@method", StringComparison.OrdinalIgnoreCase)) + { + PhpReferenceExtractor.EmitDocblockMethodReturnTypeReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); + PhpReferenceExtractor.EmitDocblockMethodParameterTypeReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); + } + + if (originalLine.Contains("@template", StringComparison.OrdinalIgnoreCase)) + { + PhpReferenceExtractor.EmitDocblockTemplateBoundTypeReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); + } + + if (originalLine.Contains("type", StringComparison.OrdinalIgnoreCase)) + { + PhpReferenceExtractor.EmitDocblockTypeAliasTargetReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); + PhpReferenceExtractor.EmitDocblockImportTypeSourceReferences( + originalLine, + references, + seen, + fileId, + docblockContext, + lineNumber, + ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); + } + } + + if (inDocblock && originalLine.IndexOf("*/", StringComparison.Ordinal) >= 0) + { + inDocblock = false; + docblockContainer = null; + docblockPropertyNames = null; + } + } + + private static SymbolRecord? ResolvePhpDocblockContainer( + bool inDocblock, + SymbolRecord? docblockContainer, + Func getLineContainer) + => inDocblock ? docblockContainer : getLineContainer(); + + internal static void AddReference( + List references, + ReferenceDedupeSet seen, + long fileId, + Match match, + string referenceKind, + string context, + int lineNumber, + SymbolRecord? container, + string? language = null, + string? targetQualifier = null) + { + AddReference( + references, + seen, + fileId, + match.Groups["name"].Value, + match.Groups["name"].Index, + referenceKind, + context, + lineNumber, + container, + language, + targetQualifier); + } + + internal static void AddReference( + List references, + ReferenceDedupeSet seen, + long fileId, + string name, + int nameIndex, + string referenceKind, + string context, + int lineNumber, + SymbolRecord? container, + string? language = null, + string? targetQualifier = null) + { + var column = nameIndex + 1; + var dedupeKey = CreateReferenceDedupeKey(fileId, language, lineNumber, column, referenceKind, name, container); + if (!seen.Add(dedupeKey)) + return; + var currentContainerReceiver = string.Equals( + targetQualifier, + ScientificNativeReferenceExtractor.CurrentContainerReceiverMarker, + StringComparison.Ordinal); + + TryAddReference(references, new ReferenceRecord + { + FileId = fileId, + SymbolName = name, + IdentitySymbolNameFolded = language == "nim" + ? NimIdentifierIdentity.Fold(name) + : null, + ReferenceKind = referenceKind, + Line = lineNumber, + Column = column, + Context = context, + ContainerKind = container?.Kind, + ContainerName = container?.Name, + IdentityContainerNameFolded = language == "nim" + ? NimIdentifierIdentity.Fold(container?.Name) + : null, + TargetQualifier = currentContainerReceiver ? null : targetQualifier, + SuppressInferredTargetQualifier = currentContainerReceiver, + IsSelfReference = (targetQualifier == null || currentContainerReceiver) + && IsSameReferenceName(container?.Name, name), + }); + } + + internal static string BuildReferenceDedupeKey( + long fileId, + string? language, + int lineNumber, + int column, + string referenceKind, + string name, + SymbolRecord? container) + => CreateReferenceDedupeKey(fileId, language, lineNumber, column, referenceKind, name, container).ToString(); + + internal static ReferenceDedupeKey CreateReferenceDedupeKey( + long fileId, + string? language, + int lineNumber, + int column, + string referenceKind, + string name, + SymbolRecord? container) + => CreateReferenceDedupeKey( + fileId, + language, + lineNumber, + column, + referenceKind, + name, + container?.Kind, + container?.Name); + + internal static ReferenceDedupeKey CreateReferenceDedupeKey( + long fileId, + string? language, + int lineNumber, + int column, + string referenceKind, + string name, + string? containerKind, + string? containerName) + => new( + fileId, + string.IsNullOrWhiteSpace(language) ? "-" : language, + lineNumber, + column, + referenceKind, + string.IsNullOrWhiteSpace(containerKind) ? "-" : containerKind, + string.IsNullOrWhiteSpace(containerName) ? "-" : containerName, + name); + + internal static void CompactCSharpUsingAliasReferences(List references, string language) + { + var referenceCount = references.Count; + var deduped = new HashSet(referenceCount); + var writeIndex = 0; + for (var readIndex = 0; readIndex < referenceCount; readIndex++) + { + var reference = references[readIndex]; + var key = CreateReferenceDedupeKey( + reference.FileId, + language, + reference.Line, + reference.Column, + reference.ReferenceKind, + reference.SymbolName, + reference.ContainerKind, + reference.ContainerName); + if (!deduped.Add(key)) + continue; + + if (writeIndex != readIndex) + references[writeIndex] = reference; + writeIndex++; + } + + if (writeIndex < referenceCount) + references.RemoveRange(writeIndex, referenceCount - writeIndex); + } + + private static void EmitCSharpLambdaCaptureReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Dictionary>? localNamesByFunction) + { + if (container?.Kind != "function" + || localNamesByFunction == null + || !localNamesByFunction.TryGetValue(GetCSharpContainerLocalScopeKey(container), out var localNames) + || localNames.Count == 0) + { + return; + } + + foreach (Match lambda in BoundedRegex.EnumerateMatches(CSharpLambdaRegex, preparedLine)) + { + var body = lambda.Groups["body"].Value; + if (string.IsNullOrWhiteSpace(body)) + continue; + + var parameterNames = CollectCSharpLambdaParameterNames(lambda.Groups["params"].Value); + foreach (var localName in localNames) + { + if (parameterNames.Contains(localName)) + continue; + if (!ContainsCSharpIdentifier(body, localName, out var bodyRelativeIndex)) + continue; + + AddReference( + references, + seen, + fileId, + localName, + lambda.Groups["body"].Index + bodyRelativeIndex, + "capture", + context, + lineNumber, + container, + "csharp"); + } + } + } + + private static HashSet CollectCSharpLambdaParameterNames(string parameterText) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (Match match in BoundedRegex.EnumerateMatches(parameterText, CSharpIdentifierPattern)) + { + var name = NormalizeAtPrefixedIdentifier(match.Value); + if (!IsIgnoredCallName("csharp", name)) + names.Add(name); + } + + return names; + } + + private static bool ContainsCSharpIdentifier(string text, string name, out int index) + { + index = -1; + var normalizedName = NormalizeAtPrefixedIdentifier(name); + foreach (Match match in BoundedRegex.EnumerateMatches(text, CSharpIdentifierPattern)) + { + if (string.Equals(NormalizeAtPrefixedIdentifier(match.Value), normalizedName, StringComparison.Ordinal)) + { + index = match.Index; + return true; + } + } + + return false; + } + + private static void TrackCSharpLocalDeclarations( + string preparedLine, + SymbolRecord? container, + Dictionary>? localNamesByFunction) + { + if (container?.Kind != "function" || localNamesByFunction == null) + return; + if (preparedLine.Contains("=>", StringComparison.Ordinal)) + return; + + foreach (Match match in CSharpLocalDeclarationRegex.Matches(preparedLine)) + { + var name = NormalizeAtPrefixedIdentifier(match.Groups["name"].Value); + if (IsIgnoredCallName("csharp", name)) + continue; + + var scopeKey = GetCSharpContainerLocalScopeKey(container); + if (!localNamesByFunction.TryGetValue(scopeKey, out var localNames)) + { + localNames = new HashSet(StringComparer.Ordinal); + localNamesByFunction[scopeKey] = localNames; + } + + localNames.Add(name); + } + } + + private static string GetCSharpContainerLocalScopeKey(SymbolRecord container) + => $"{container.Kind}:{container.ContainerQualifiedName}:{container.ContainerKind}:{container.ContainerName}:{container.Name}:{container.StartLine}:{container.EndLine}:{container.BodyStartLine}:{container.BodyEndLine}:{container.StartColumn}"; + + internal static void MarkMutualRecursionReferences(List references) + { + var edges = new HashSet<(string Caller, string Callee)>(); + Dictionary? normalizedNames = null; + foreach (var reference in references) + { + if (!IsCallGraphLikeReferenceKind(reference.ReferenceKind) + || string.IsNullOrWhiteSpace(reference.ContainerName) + || string.IsNullOrWhiteSpace(reference.SymbolName) + || reference.IsSelfReference) + { + continue; + } + + edges.Add(( + GetCachedNormalizedReferenceCycleName(reference.ContainerName, ref normalizedNames), + GetCachedNormalizedReferenceCycleName(reference.SymbolName, ref normalizedNames))); + } + + if (edges.Count == 0) + return; + + foreach (var reference in references) + { + if (!IsCallGraphLikeReferenceKind(reference.ReferenceKind) + || string.IsNullOrWhiteSpace(reference.ContainerName) + || string.IsNullOrWhiteSpace(reference.SymbolName) + || reference.IsSelfReference) + { + continue; + } + + var caller = GetCachedNormalizedReferenceCycleName(reference.ContainerName, ref normalizedNames); + var callee = GetCachedNormalizedReferenceCycleName(reference.SymbolName, ref normalizedNames); + if (edges.Contains((callee, caller))) + reference.IsMutualRecursion = true; + } + } + + private static string GetCachedNormalizedReferenceCycleName( + string name, + ref Dictionary? normalizedNames) + { + if (normalizedNames != null && normalizedNames.TryGetValue(name, out var normalizedName)) + return normalizedName; + + normalizedName = NormalizeReferenceCycleName(name); + if (ReferenceEquals(normalizedName, name)) + return normalizedName; + + normalizedNames ??= new Dictionary(StringComparer.Ordinal); + normalizedNames.Add(name, normalizedName); + return normalizedName; + } + + private static bool IsCallGraphLikeReferenceKind(string referenceKind) + => referenceKind is "call" or "instantiate" or "subscribe" or "unsubscribe" or "razor_event_binding"; + + private static bool IsSameReferenceName(string? left, string right) + => !string.IsNullOrWhiteSpace(left) + && string.Equals(NormalizeReferenceCycleName(left), NormalizeReferenceCycleName(right), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeReferenceCycleName(string name) + { + var trimmed = name.Trim(); + var dot = trimmed.LastIndexOf('.'); + if (dot >= 0 && dot + 1 < trimmed.Length) + return trimmed[(dot + 1)..]; + var colon = trimmed.LastIndexOf("::", StringComparison.Ordinal); + return colon >= 0 && colon + 2 < trimmed.Length ? trimmed[(colon + 2)..] : trimmed; + } + + private const int MaxPythonLogicalReferenceLineLength = 32_768; + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs index 8745ef574..05121be98 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs @@ -49,1103 +49,6 @@ internal ReferenceDedupeSet(int capacity = 0) /// public static partial class ReferenceExtractor { - private static readonly TimeSpan ExtractionRegexTimeout = TimeSpan.FromSeconds(2); - internal const int MaxReferenceLookupSymbols = 50_000; - internal const int MaxReferenceLookupLines = 20_000; - internal const int MaxReferenceLookupNamesPerLine = 512; - internal const int MaxReferenceContainerCandidates = 20_000; - internal const int MaxSwiftPropertyDefinitionsPerLine = MaxReferenceLookupNamesPerLine; - internal static readonly IReadOnlyList ReferenceSafetyCapDiagnosticKinds = - [ - "reference_all_definition_lookup_symbol_budget_exceeded", - "reference_container_candidate_budget_exceeded", - "reference_csharp_xml_doc_scope_candidate_budget_exceeded", - "reference_definition_lookup_line_budget_exceeded", - "reference_definition_lookup_line_name_budget_exceeded", - "reference_definition_lookup_symbol_budget_exceeded", - "reference_enclosing_type_candidate_budget_exceeded", - "reference_scientific_native_dependency_name_budget_exceeded", - ShaderReferenceExtractor.LineNameBudgetDiagnosticKind, - ShaderReferenceExtractor.TrackedNameBudgetDiagnosticKind, - "reference_swift_property_line_budget_exceeded", - "reference_swift_property_line_name_budget_exceeded", - "reference_swift_property_symbol_budget_exceeded", - ]; - private static readonly HashSet ReferenceSafetyCapDiagnosticKindSet = - new(ReferenceSafetyCapDiagnosticKinds, StringComparer.Ordinal); - private static readonly AsyncLocal SafetyLimitsOverride = new(); - private const int ReferenceListInitialCapacityLineThreshold = 128; - private const int ReferenceListInitialCapacityMax = 1024; - private static readonly IReadOnlySet EmptyDefinitionNameSet = new HashSet(StringComparer.Ordinal); - private static readonly IReadOnlyDictionary> EmptyDefinitionNamesByLine = - new Dictionary>(); - private static readonly string[] AdditionalReferenceLanguages = - [ - "vue", - "svelte", - "razor", - "blazor", - "cshtml", - ]; - - private static string[] SplitContentLines(string content) => - content.IndexOf('\n', StringComparison.Ordinal) < 0 ? [content] : content.Split('\n'); - - internal static ReferenceExtractionSafetyLimits? SafetyLimitsForTesting - { - get => SafetyLimitsOverride.Value; - set => SafetyLimitsOverride.Value = value; - } - - public static ReferenceExtractionSafetyLimits GetSafetyLimits() - => SafetyLimitsOverride.Value ?? new ReferenceExtractionSafetyLimits - { - MaxLookupSymbols = MaxReferenceLookupSymbols, - MaxLookupLines = MaxReferenceLookupLines, - MaxNamesPerLine = MaxReferenceLookupNamesPerLine, - MaxContainerCandidates = MaxReferenceContainerCandidates, - }; - - internal static bool IsSafetyCapDiagnosticKind(string kind) - => ReferenceSafetyCapDiagnosticKindSet.Contains(kind); - - // THREAD-SAFETY: Reference extraction is stateless per call. Shared Regex instances and - // lookup tables are initialized once and then read concurrently; language-specific state - // must be created per extraction call (for example via CreateState helpers) rather than - // stored in mutable static fields. - private static readonly HashSet SharedIgnoredCallNames = new(StringComparer.Ordinal) - { - // Control flow / 制御フロー - "if", "else", "for", "foreach", "while", "switch", "catch", "lock", "do", "try", "when", - // Keywords that look like calls / 呼び出しに見えるキーワード - "sizeof", "typeof", "return", "throw", "nameof", "await", "using", "new", - // Type/member keywords / 型・メンバーキーワード - "class", "struct", "record", "interface", "enum", "delegate", "event", "namespace", - "def", "function", "func", - }; - private static readonly HashSet SharedIgnoredCallNamesCaseInsensitive = new(SharedIgnoredCallNames, StringComparer.OrdinalIgnoreCase); - private static readonly HashSet MethodGroupContextTargetIgnoreNames = new(StringComparer.OrdinalIgnoreCase) - { - "if", "else", "for", "foreach", "while", "switch", "catch", "lock", "do", "try", "nameof", - "typeof", "sizeof", "using", "return", "throw", "checked", "unchecked", "default", "stackalloc", - "fixed", "await", "yield", "when", - }; - - private static readonly HashSet TypeScriptTypeQueryContextTokens = new(StringComparer.Ordinal) - { - "extends", - "implements", - "satisfies", - "as", - "type", - }; - - private static readonly HashSet TypeScriptTypeQueryDisqualifyingTokens = new(StringComparer.Ordinal) - { - "if", - "else", - "for", - "foreach", - "while", - "switch", - "case", - "do", - "try", - "catch", - "return", - "throw", - "new", - "delete", - "void", - "await", - "yield", - "in", - "instanceof", - "=>", - "?", - }; - - private static bool IsFunctionLikeSymbolKind(string kind) - => kind is "function" or "operator" or "lambda" or "async_function" or "generator" or "async_generator"; - - private static readonly Dictionary> LanguageSpecificIgnoredCallNames = new(StringComparer.Ordinal) - { - // C# contextual keywords and common false positives / C# 文脈キーワードとよくある偽陽性 - ["csharp"] = new HashSet(StringComparer.Ordinal) - { - "is", "as", "in", "var", "base", "this", "value", "get", "set", "init", "where", - "from", "select", "orderby", "group", "into", "join", "let", "on", "equals", - "async", "yield", "checked", "unchecked", "default", "stackalloc", "fixed", - }, - // Java contextual keywords / Java 文脈キーワード - // `this` is listed so generic CallRegex does not emit a phantom `call this` edge - // after JavaReferenceExtractor rewrites the chain to the owning class. - // `this` も含めることで、連鎖書き換え後の generic CallRegex が `call this` を二重に出すのを防ぐ。 - ["java"] = new HashSet(StringComparer.Ordinal) - { - "instanceof", "super", "this", "assert", "throws", "extends", "implements", "synchronized", - }, - // Kotlin constructor delegation is rewritten by KotlinReferenceExtractor, so suppress the - // declaration/delegation keywords that generic CallRegex would otherwise index as calls. - // Kotlin の constructor 委譲は KotlinReferenceExtractor で書き換えるため、 - // 汎用 CallRegex が拾う宣言・委譲 keyword 自体は call として残さない。 - ["kotlin"] = new HashSet(StringComparer.Ordinal) - { - "constructor", "super", "this", - }, - // Rust macro declaration keywords / Rust マクロ宣言キーワード - // `macro_rules!` declarations will be seen by the Rust macro-call regex below, but they are - // declaration sites rather than call sites, so suppress the keyword itself. - // `macro_rules!` 宣言は下の Rust macro-call regex でも見えてしまうが、これは呼び出しではなく - // 宣言なのでキーワード自体を抑止する。 - ["rust"] = new HashSet(StringComparer.Ordinal) - { - "macro_rules", - }, - ["c"] = new HashSet(StringComparer.Ordinal) - { - "auto", "break", "case", "const", "continue", "default", "extern", "goto", - "inline", "register", "restrict", "static", "switch", "typedef", "volatile", - }, - ["cpp"] = new HashSet(StringComparer.Ordinal) - { - "alignas", "auto", "break", "case", "catch", "concept", "const", "constexpr", - "consteval", "constinit", "continue", "co_await", "co_return", "co_yield", - "decltype", "default", "delete", "explicit", "extern", "friend", "inline", - "mutable", "noexcept", "operator", "override", "private", "protected", "public", - "requires", "static", "template", "this", "typedef", "typename", "using", "virtual", - "volatile", - }, - // GPU-language metadata is declarative, even when its surface syntax uses parentheses. - // GPU 言語のメタデータは括弧を使う構文でも宣言であり、呼び出しではない。 - ["cuda"] = new HashSet(StringComparer.Ordinal) - { - "alignas", "auto", "break", "case", "catch", "concept", "const", "constexpr", - "consteval", "constinit", "continue", "co_await", "co_return", "co_yield", - "decltype", "default", "delete", "explicit", "extern", "friend", "inline", - "mutable", "noexcept", "operator", "override", "private", "protected", "public", - "requires", "static", "template", "this", "typedef", "typename", "using", "virtual", - "volatile", - "__global__", "__device__", "__host__", "__shared__", "__constant__", - "__launch_bounds__", "__align__", "__device_builtin__", - }, - ["glsl"] = new HashSet(StringComparer.Ordinal) - { - "layout", - }, - ["hlsl"] = new HashSet(StringComparer.Ordinal) - { - "register", "packoffset", "numthreads", "domain", "partitioning", - "outputtopology", "outputcontrolpoints", "patchconstantfunc", "maxtessfactor", - }, - ["metal"] = new HashSet(StringComparer.Ordinal) - { - "buffer", "texture", "sampler", "threadgroup", "stage_in", - "thread_position_in_grid", "threads_per_threadgroup", - }, - ["go"] = new HashSet(StringComparer.Ordinal) - { - "append", "cap", "close", "copy", "delete", "len", "make", "new", "panic", "recover", - "chan", "defer", "fallthrough", "func", "go", "interface", "map", "package", - "range", "select", "type", "var", - }, - ["dart"] = new HashSet(StringComparer.Ordinal) - { - "abstract", "assert", "async", "base", "const", "covariant", "deferred", "dynamic", - "export", "extends", "extension", "external", "factory", "final", "hide", "implements", - "import", "late", "library", "mixin", "on", "operator", "part", "required", "show", - "typedef", "void", "with", - }, - ["elixir"] = new HashSet(StringComparer.Ordinal) - { - "alias", "after", "behaviour", "case", "catch", "cond", "def", "defdelegate", - "defguard", "defguardp", "defimpl", "defmacro", "defmacrop", "defmodule", "defp", - "defprotocol", "defstruct", "do", "else", "end", "for", "fn", "if", "impl", - "import", "quote", "receive", "require", "rescue", "try", "unless", "unquote", - "use", "with", - }, - ["lua"] = new HashSet(StringComparer.Ordinal) - { - "and", "break", "do", "else", "elseif", "end", "false", "for", "function", "if", - "in", "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", - "while", - }, - // JavaScript / TypeScript contextual keywords / JavaScript / TypeScript 文脈キーワード - ["javascript"] = new HashSet(StringComparer.Ordinal) - { - "import", "super", "yield", - }, - ["typescript"] = new HashSet(StringComparer.Ordinal) - { - "import", "super", "yield", - }, - // Python contextual keywords / Python の文脈キーワード - ["python"] = new HashSet(StringComparer.Ordinal) - { - "raise", "yield", "from", "super", - }, - // Ruby contextual keywords / Ruby の文脈キーワード - ["ruby"] = new HashSet(StringComparer.Ordinal) - { - "raise", "yield", "super", "include", "extend", "prepend", "refine", "alias", "alias_method", "describe", - "resource", "resources", "create_table", "attribute", "serialize", - "private_constant", "public_constant", "module_function", "rescue_from", "gem", "composed_of", - "accepts_nested_attributes_for", - "unless", "case", "begin", "until", "module", "rescue", "ensure", - }, - ["perl"] = new HashSet(StringComparer.Ordinal) - { - "use", "require", "package", "sub", "my", "our", "local", "state", - "if", "elsif", "unless", "while", "until", "foreach", "for", "given", "when", - "print", "say", "die", "warn", "open", "close", "defined", "exists", "delete", - "bless", "ref", "scalar", "wantarray", "eval", "do", - }, - ["ambiguous_pl"] = new HashSet(StringComparer.Ordinal) - { - "use", "require", "package", "sub", "my", "our", "local", "state", - "if", "elsif", "unless", "while", "until", "foreach", "for", "given", "when", - "print", "say", "die", "warn", "open", "close", "defined", "exists", "delete", - "bless", "ref", "scalar", "wantarray", "eval", "do", - "module", "use_module", "library", "initialization", "dynamic", "multifile", - "discontiguous", "op", - }, - ["crystal"] = new HashSet(StringComparer.Ordinal) - { - "abstract", "alias", "annotation", "begin", "case", "class", "def", "do", "else", - "elsif", "end", "ensure", "enum", "extend", "for", "fun", "if", "include", "lib", - "macro", "module", "next", "of", "private", "protected", "require", "rescue", - "return", "select", "struct", "then", "unless", "until", "when", "while", "with", "yield", - "as", "alignof", "instance_alignof", "instance_sizeof", "is_a?", "offsetof", "pointerof", - "responds_to?", - }, - ["groovy"] = new HashSet(StringComparer.Ordinal) - { - "apply", "as", "assert", "break", "case", "catch", "class", "continue", "def", "do", - "else", "enum", "extends", "finally", "for", "if", "implements", "import", "in", - "instanceof", "interface", "new", "package", "return", "super", "switch", "synchronized", - "this", "throw", "throws", "trait", "try", "while", - }, - ["tcl"] = new HashSet(StringComparer.Ordinal) - { - "append", "array", "break", "catch", "concat", "continue", "dict", "error", "eval", - "expr", "for", "foreach", "global", "if", "incr", "info", "lappend", "lindex", - "list", "namespace", "oo::class", "package", "proc", "rename", "return", "set", - "string", "switch", "unset", "upvar", "uplevel", "variable", "while", - }, - ["prolog"] = new HashSet(StringComparer.Ordinal) - { - "module", "use_module", "library", "initialization", "dynamic", "multifile", - "discontiguous", "op", "true", "fail", "false", "is", "not", - }, - // F# contextual keywords / F# 文脈キーワード - ["fsharp"] = new HashSet(StringComparer.Ordinal) - { - "match", "with", "member", "override", "abstract", "mutable", "rec", "fun", "open", - "module", "type", "of", "then", "elif", "done", "begin", "end", - "let", "use", "if", "else", "do", "try", "finally", "in", "for", "while", "return", "yield", - "assert", "to", "downto", "lazy", "raise", "upcast", "downcast", - }, - // PHP include/require constructs / PHP の include/require 構文 - ["php"] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "require", "require_once", "include", "include_once", - "echo", "print", "exit", "die", "eval", "unset", "isset", "empty", - }, - // SQL keywords. Case-insensitive because SQL is written both upper- and lowercase in real code, - // and the `EXEC|EXECUTE|CALL` extractor preserves the original casing of the captured name. - // The entries themselves stay uppercase for readability. - // SQL のキーワード。実コードでは大文字・小文字が混在するうえ、`EXEC|EXECUTE|CALL` 抽出が - // 元のケースをそのまま保持するため、比較は大文字小文字非依存にする(リストは読みやすさのため大文字表記)。 - ["sql"] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "JOIN", "INTO", - "VALUES", "ORDER", "GROUP", "HAVING", "LIMIT", "OFFSET", "UNION", - "EXISTS", "BETWEEN", "LIKE", "CASE", "WHEN", "THEN", "ELSE", - "AS", "ON", "AND", "OR", "NOT", "NULL", "IN", "IS", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "IF", - // `EXECUTE IMMEDIATE 'dynamic SQL'` (Oracle / PL/pgSQL) — `IMMEDIATE` is not a call target. - // `EXECUTE IMMEDIATE '動的SQL'` (Oracle / PL/pgSQL) — `IMMEDIATE` は呼び出し対象ではない。 - "IMMEDIATE", - // The keywords that introduce a stored-procedure call themselves. The no-parens form is - // captured by SqlProcCallRegex; the rare `EXEC(@sql)` / `EXEC('...')` dynamic-SQL form has - // no identifier argument, so the generic CallRegex would otherwise emit a phantom - // `call EXEC` / `call EXECUTE` / `call CALL` edge pointing at the keyword itself. - // ストアドプロシージャ呼び出しを導入するキーワード自身。括弧なし形は SqlProcCallRegex で捕捉し、 - // 動的 SQL 形の `EXEC(@sql)` / `EXEC('...')` は識別子を持たないため、汎用 CallRegex に任せると - // キーワード自体を指す `call EXEC` / `call EXECUTE` / `call CALL` の幽霊エッジが生まれる。 - "EXEC", "EXECUTE", "CALL", - }, - // R keywords / R キーワード - ["r"] = new HashSet(StringComparer.Ordinal) - { - "library", "cat", "paste", "paste0", "sprintf", "stop", "warning", "message", - "invisible", "tryCatch", "withCallingHandlers", "requireNamespace", "next", "break", "repeat", - "import", "importFrom", "export", "exportClasses", "exportMethods", "S3method", "useDynLib", - }, - // PowerShell keywords / PowerShell キーワード - ["powershell"] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "function", "filter", "configuration", "workflow", "class", "enum", - "param", "begin", "process", "end", "dynamicparam", - "if", "else", "elseif", "for", "foreach", "while", "do", "until", "switch", - "try", "catch", "finally", "trap", "return", "throw", "break", "continue", - "using", "data", "in", "Write", - }, - // Shell keywords / Shell キーワード - ["shell"] = new HashSet(StringComparer.Ordinal) - { - "if", "then", "else", "elif", "fi", "do", "done", "while", "until", "case", "esac", "time", - }, - // Haskell keywords / Haskell キーワード - ["haskell"] = new HashSet(StringComparer.Ordinal) - { - "data", "newtype", "instance", "deriving", "infixl", "infixr", "infix", - "qualified", "hiding", "forall", "Just", "Nothing", "Left", "Right", "True", "False", - "case", "class", "default", "foreign", "import", "let", "module", "of", "type", "where", - "putStrLn", "putStr", "print", - }, - ["vb"] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "AddHandler", "AddressOf", "Alias", "And", "AndAlso", "As", "ByRef", "ByVal", - "Call", "CallByName", "Case", "Catch", "CBool", "CByte", "CChar", "CDate", "CDbl", "CDec", - "CInt", "CLng", "CObj", "CSByte", "CShort", "CSng", "CStr", "CType", "CUInt", "CULng", "CUShort", - "DirectCast", "End", "Erase", "Exit", "Get", "GetType", - "GetXMLNamespace", "Global", "Handles", "Inherits", "Implements", "Imports", "Me", - "Module", "MustInherit", "MustOverride", "MyBase", "MyClass", "Namespace", "Narrowing", - "NameOf", "New", "Next", "Not", "Nothing", "Of", "On", "Operator", "Option", "Or", "OrElse", - "Overloads", "Overrides", "ParamArray", "Partial", "RaiseEvent", "ReadOnly", - "RemoveHandler", "Resume", "Return", "Select", "Set", "Shadows", "Shared", "Static", - "Step", "Stop", "SyncLock", "Then", "TryCast", "Using", "When", "Widening", "With", - "WithEvents", "WriteOnly", "Xor", - }, - ["fortran"] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "allocatable", "allocate", "associate", "call", "case", "class", "contains", "cycle", - "deallocate", "do", "elemental", "else", "elseif", "end", "entry", "equivalence", - "exit", "function", "if", "implicit", "include", "intent", "interface", "intrinsic", - "module", "namelist", "none", "only", "operator", "optional", "parameter", "pointer", - "private", "procedure", "program", "public", "pure", "recursive", "result", "return", - "select", "submodule", "subroutine", "then", "type", "use", "where", - }, - ["pascal"] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "and", "array", "begin", "case", "class", "const", "constructor", "destructor", "div", - "do", "downto", "else", "end", "except", "exports", "file", "finally", "for", - "function", "goto", "if", "implementation", "in", "inherited", "interface", "is", - "label", "mod", "nil", "not", "object", "of", "or", "packed", "private", "procedure", - "program", "property", "protected", "public", "published", "raise", "record", "repeat", - "set", "shl", "shr", "then", "threadvar", "to", "try", "type", "unit", "until", - "uses", "var", "while", "with", "xor", - }, - ["objc"] = new HashSet(StringComparer.Ordinal) - { - "BOOL", "Class", "YES", "NO", "Nil", "SEL", "alloc", "autorelease", "copy", "id", - "init", "nonatomic", "nullable", "nonnull", "readwrite", "readonly", "retain", - "self", "strong", "super", "weak", - }, - ["smalltalk"] = new HashSet(StringComparer.Ordinal) - { - "false", "nil", "self", "super", "thisContext", "true", - }, - ["ada"] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "accept", "begin", "case", "declare", "delay", "else", "elsif", "end", "entry", - "exception", "exit", "function", "generic", "if", "loop", "package", "pragma", - "procedure", "raise", "record", "renames", "return", "select", "task", "terminate", - "type", "use", "when", "while", "with", - }, - ["cython"] = new HashSet(StringComparer.Ordinal) - { - "cdef", "cpdef", "ctypedef", "cimport", "def", "extern", "gil", "include", - "nogil", "property", - }, - ["d"] = new HashSet(StringComparer.Ordinal) - { - "__traits", "assert", "cast", "debug", "extern", "is", "mixin", "pragma", "scope", - "static", "unittest", "version", - }, - ["julia"] = new HashSet(StringComparer.Ordinal) - { - "abstract", "baremodule", "begin", "do", "export", "finally", "function", "import", - "let", "macro", "module", "mutable", "primitive", "quote", "struct", "using", "where", - }, - ["matlab"] = new HashSet(StringComparer.Ordinal) - { - "arguments", "case", "catch", "classdef", "elseif", "end", "function", "import", - "methods", "otherwise", "parfor", "properties", "spmd", - }, - ["nim"] = new HashSet(StringComparer.Ordinal) - { - "block", "case", "concept", "converter", "defer", "discard", "distinct", "from", - "func", "import", "include", "iterator", "macro", "method", "mixin", "object", - "proc", "template", "type", "when", - }, - // Gradle/Groovy keywords / Gradle/Groovy キーワード - ["gradle"] = new HashSet(StringComparer.Ordinal) - { - "apply", "plugins", "dependencies", "repositories", "allprojects", "subprojects", - "task", "buildscript", "ext", "group", "version", "description", - }, - // Terraform keywords / Terraform キーワード - ["terraform"] = new HashSet(StringComparer.Ordinal) - { - "resource", "data", "variable", "output", "locals", "module", "provider", - "terraform", "required_providers", "backend", - }, - // Makefile keywords / Makefile キーワード - ["makefile"] = new HashSet(StringComparer.Ordinal) - { - "all", "clean", "install", "build", "run", "help", - }, - // Sass/Stylus accept CSS function syntax without separators; keep common CSS built-ins - // from flowing through the shared CallRegex after the language-specific extractors skip them. - // Sass/Stylus は CSS 関数構文を区切りなしで受け付けるため、言語専用 extractor で除外した - // 代表的な CSS built-in を共有 CallRegex 側でも call として残さない。 - ["sass"] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "url", "var", "calc", "rgb", "rgba", "hsl", "hsla", - }, - ["stylus"] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "url", "var", "calc", "rgb", "rgba", "hsl", "hsla", - }, - }; - private static readonly Dictionary> LanguageSpecificCallNameKeeps = new(StringComparer.Ordinal) - { - // Rust uses `new` / `default` as ordinary method names (`Type::new`, `Default::default`). - // Rust では `new` / `default` は通常のメソッド名 (`Type::new`, `Default::default`)。 - ["rust"] = new HashSet(StringComparer.Ordinal) - { - "new", "default", - }, - }; - - // JavaScript / TypeScript tokens that legally sit immediately before a template literal - // without being a tag identifier: unary / binary operators (`void \`...\``, - // `delete \`...\``, `foo in \`...\``, `foo instanceof \`...\``), switch-case label - // (`case \`...\`:`), and clause / statement keywords (`export default \`...\``, - // `try {} finally \`...\``). Without this gate the tagged-template scanner (issue #268) - // emits phantom call rows for those keywords. This set is intentionally applied ONLY at - // the tagged-template emit site, not to the shared `CallRegex` path, so legitimate - // member calls like `api.in()` / `api.instanceof()` / `api.delete()` / `api.case()` / - // `api.void()` / `promise.finally()` remain captured. The denylist is also bypassed - // when the hit's `IsMemberAccess` flag is set — `obj.default\`x\`` and - // `obj.finally\`y\`` are legal tagged-template calls because every reserved word is a - // legal property name in JS/TS, and the masker's member-access detection reports those - // hits separately from bare-keyword hits. `of` is intentionally NOT listed because it - // is an unreserved identifier — `const of = ...; of\`x\`` is a legal tagged-template - // call. The narrower `for (...of \`...\`)` header suppression lives in - // `StructuralLineMasker.FilterJsForOfHeaderHits`. - // JS/TS でタグ無しテンプレート直前に現れてタグではないトークン: 単項/二項演算子 - // (`void \`...\`` / `delete \`...\`` / `foo in \`...\`` / `foo instanceof \`...\``)、 - // switch-case ラベル (`case \`...\`:`)、clause/statement キーワード - // (`export default \`...\`` / `try {} finally \`...\``)。汎用 CallRegex には適用せず - // タグ付きテンプレート発行時だけに限定するため、`api.in()` / `api.instanceof()` / - // `api.delete()` / `api.case()` / `api.void()` / `promise.finally()` のような正当な - // メンバー呼び出しは引き続き捕捉される。さらに hit の `IsMemberAccess` が立って - // いる場合もこの denylist を迂回する — JS/TS ではすべての予約語が property 名に - // なれるため `obj.default\`x\`` や `obj.finally\`y\`` は正当なタグ呼び出しで、 - // masker 側でメンバーアクセス判定が済んでいる。`of` は予約語ではなく - // `const of = ...; of\`x\`` が正当なタグ呼び出しになりうるためここには含めない。 - // `for (...of \`...\`)` ヘッダの抑止は - // `StructuralLineMasker.FilterJsForOfHeaderHits` 側で扱う。 - private static readonly HashSet JsTaggedTemplateOperatorNames = new(StringComparer.Ordinal) - { - "void", "case", "delete", "in", "instanceof", "default", "finally", - }; - - private static readonly Regex StringLiteralRegex = new( - "\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|`(?:\\\\.|[^`\\\\])*`", - RegexOptions.Compiled); - private static readonly Regex NonBacktickStringLiteralRegex = new( - "\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'", - RegexOptions.Compiled); - private static readonly Regex InlineBlockCommentRegex = new(@"/\*.*?\*/", RegexOptions.Compiled); - internal const string CSharpIdentifierPattern = @"@?[_\p{L}]\w*"; - private const string FunctionalIdentifierPattern = @"@?[_\p{L}\$][\w$]*"; - private const string CSharpTypeExpressionPattern = - @"(?:global::)?(?:" - + CSharpIdentifierPattern - + @"\s*(?:(?:\.|::)\s*" - + CSharpIdentifierPattern - + @")*)(?:\s*<[^)\];{}]+>)?(?:\s*\[[^\]\n]*\])*"; - private static readonly Regex CSharpLocalDeclarationRegex = new( - $@"(?{CSharpIdentifierPattern})\s*(?=[=;,\)])", - RegexOptions.Compiled); - private static readonly Regex CSharpLambdaRegex = new( - $@"(?\([^)]*\)|{CSharpIdentifierPattern})\s*=>\s*(?.*)$", - RegexOptions.Compiled); - // The `(?:\?\.)?` segment captures JavaScript / TypeScript optional chaining calls such as - // `callback?.()` and `callback?.()`. Without it the `?.` stops the regex from reaching the - // trailing `(`, and the call reference to `callback` is silently dropped. Other supported - // languages that use `?.` (C# / Kotlin / Swift / Dart) place an identifier between `?.` and - // `(`, so their existing call sites continue to match via the identifier itself. See issue #294. - // `(?:\?\.)?` は JavaScript / TypeScript の optional chaining 呼び出し (`callback?.()` や - // `callback?.()`) を捕捉するための segment。これが無いと `?.` の存在で末尾 `(` に到達できず、 - // `callback` への call 参照が黙って欠落する。C# / Kotlin / Swift / Dart などの `?.` は後ろに - // 識別子が続くため、従来通り識別子自身が CallRegex にマッチして影響を受けない。issue #294 参照。 - // Nested generic call sites such as `Foo>()` / `new Dict>()` are - // recovered by a depth-aware fallback scanner because the flat `<[^>\n]+>` segment cannot - // balance the closing `>>`. See issue #263. - // `Foo>()` や `new Dict>()` のようなネスト generic 呼び出しは、 - // 平坦な `<[^>\n]+>` では末尾 `>>` を釣り合わせられないため、depth-aware な fallback scanner - // で補完する。issue #263 参照。 - private static readonly Regex CallRegex = new($@"(?{CSharpIdentifierPattern})(?:\?\.)?(?:::)?(?:<[^>\n]+>)?\s*\(", RegexOptions.Compiled); - // Method-group / method-reference handoffs do not have a trailing `(`, so the shared - // CallRegex cannot see them. C# / JS / TS use a context gate plus a callable-name allowlist, - // while Java / Kotlin / Scala use the unique `::` sigil. - // `(` を持たない method-group / method-reference handoff は共通 CallRegex では拾えないため、 - // C# / JS / TS は文脈ゲート+ callable-name allowlist、Java / Kotlin / Scala は `::` sigil で拾う。 - private static readonly Regex MethodGroupReferenceRegex = new( - $@"(?\s+|(?{FunctionalIdentifierPattern})(?:<[^>\n]+>)?\s*\(\s*))(?:(?:this|base|{FunctionalIdentifierPattern}(?:\.{FunctionalIdentifierPattern})*)\s*\.\s*)?(?{FunctionalIdentifierPattern})(?!\s*\()(?!\s*`)(?=\s*(?:[;,)\]]|$))", - RegexOptions.Compiled); - // JSX / TSX component element open tags. Capitalized tag names are treated as component - // call sites, while lowercase intrinsic HTML tags stay excluded by design. - // JSX / TSX の component open tag。大文字始まりの tag 名だけを component 呼び出しとして扱い、 - // 小文字始まりの intrinsic HTML tag は意図的に除外する。 - private static readonly Regex JsxElementOpenRegex = new( - @"<(?[A-Z][\w$]*(?:\.[A-Za-z_$][\w$]*)*)", - RegexOptions.Compiled); - // SQL stored-procedure call without parentheses: T-SQL `EXEC` / `EXECUTE` and MySQL / MariaDB `CALL`. - // The shared CallRegex requires a trailing `(`, which misses the dominant real-world form such as - // `EXEC dbo.sp_Target;`, `EXEC dbo.sp_Target @x = 1, @y = 2;`, `CALL sp_Helper;`, and the bracketed - // form `EXEC [dbo].[sp_Target]`. The regex captures only the final identifier (schema prefixes are - // consumed as a prefix) and tolerates the optional T-SQL return-value assignment - // `EXEC @retval = dbo.sp_Target ...`. Bracket handling is done at emission time so `[sp_Target]` - // is normalized back to `sp_Target`. See issue #232. - // SQL のストアドプロシージャを `(` なしで呼び出す T-SQL `EXEC` / `EXECUTE` と MySQL / MariaDB `CALL`。 - // 共通 CallRegex は末尾 `(` を要求するため、`EXEC dbo.sp_Target;` など実運用で圧倒的に多い形を取りこぼす。 - // 先頭側の schema prefix は吸収し、末端の識別子だけを `name` として捕捉する。T-SQL 固有の - // `EXEC @retval = dbo.sp_Target ...` 形にも対応し、`[sp_Target]` のような角括弧識別子は発行時に除去する。 - // Bracketed identifiers inside the qualifier and name groups accept any character except `[`, - // `]`, or a line terminator. T-SQL allows `#` (temp procedure), `-` (hyphenated names), - // spaces, Unicode symbols, and punctuation inside bracket quoting, and the narrower `[\w ]+` - // would silently drop `EXEC [#tempProc]`, `EXEC [dbo].[proc-name]`, and similar legitimate - // forms while falsely misattributing the qualifier `[dbo]` as the proc name. - // Qualifier segments are optional (the inner `?`) so SQL Server's linked-server form with - // an omitted database or schema part — `EXEC AdventureWorks..sp_GetCustomer;` / - // `EXEC [AdventureWorks]..[proc-name];` — terminates on the real procedure name instead of - // falling back to the first segment. Identifier alternatives also accept backtick-quoted - // C# event subscription/unsubscription: Click += OnClick — both LHS and RHS must be PascalCase identifiers - // C# イベント購読・解除: Click += OnClick — LHS と RHS の両方が PascalCase 識別子のみ - private static readonly Regex EventSubscriptionRegex = new(@"(?[A-Z]\w*)\s*[+-]=\s*(?:new\s+)?[A-Z]\w*", RegexOptions.Compiled); - // C# / Java parenless object / collection / dictionary / array initializer such as - // `new Foo { X = 1 }`, `new List { 1, 2, 3 }`, `new Dictionary { [k] = v }`, - // `new Foo[] { ... }`, `new Foo[N] { ... }`, `new Foo[,] { ... }`, `new Foo[][] { ... }`, - // and qualified type names like `new N.Foo { X = 1 }` / `new global::N.Foo { X = 1 }`. - // CallRegex requires a trailing `(`, so these forms are otherwise dropped from the - // reference table even though the type is genuinely instantiated. Anonymous types - // (`new { Name = ... }`), target-typed `new()`, and collection expressions (`new[] { ... }`) - // intentionally do not match because they have no named target. Nested generics deeper than - // one `<...>` level (e.g. `new Dictionary> { ... }`) follow the same - // limitation as the existing CallRegex generics handling. See issue #286. - // C# / Java の括弧省略インスタンス化(オブジェクト / コレクション / ディクショナリ / - // 配列イニシャライザ)。CallRegex は `(` が必須なため取りこぼすが、実体は型のインスタンス化なので - // `instantiate` として拾う。匿名型 `new { ... }`、target-typed `new()`、 - // collection expression `new[] { ... }` は対象を持たないため意図的にマッチさせない。 - // 1 段を超えるネストした generic(`Dictionary>` 等)は既存 CallRegex と同様の制限。issue #286 参照。 - private static readonly Regex CSharpJavaInitializerRegex = new( - $@"\bnew\s+(?:global::)?(?:{CSharpIdentifierPattern}(?:\s*::\s*|\s*\.\s*))*(?{CSharpIdentifierPattern})(?:\s*<[^>\n]+>)?(?:\s*\[[^\[\]\n]*\])*\s*\{{", - RegexOptions.Compiled); - // Allman-style C# / Java parenless initializer where `{` sits on the next non-empty - // line. The trailing regex captures `new ` ending the current line (with optional - // generic + array shape), and the caller peeks forward to confirm the next non-blank - // prepared line begins with `{` before emitting an `instantiate` edge. See issue #286. - // Allman スタイルの多行 parenless initializer。`new ` が行末で終わり、次の非空 prepared line が - // `{` から始まる場合にだけ `instantiate` を発行する。issue #286 参照。 - private static readonly Regex CSharpJavaInitializerTrailingRegex = new( - $@"\bnew\s+(?:global::)?(?:{CSharpIdentifierPattern}(?:\s*::\s*|\s*\.\s*))*(?{CSharpIdentifierPattern})(?:\s*<[^>\n]+>)?(?:\s*\[[^\[\]\n]*\])*\s*$", - RegexOptions.Compiled); - private static readonly Regex CSharpUsingAliasRegex = new( - @"^\s*(?:global\s+)?using\s+(?!static\b)(?@?[A-Za-z_]\w*)\s*=\s*(?[^;]+)", - RegexOptions.Compiled); - private static readonly Regex CSharpUsingNamespaceRegex = new( - @"^\s*(?:global\s+)?using\s+(?!static\b)(?[^;=]+?)\s*;?\s*$", - RegexOptions.Compiled); - private static readonly Regex CSharpUsingStaticRegex = new( - @"^\s*(?:global\s+)?using\s+static\s+(?[^;]+)", - RegexOptions.Compiled); - private static readonly Regex CSharpLocalValueNameRegex = new( - @"(?:^\s*|[;{}]\s*)(?:(?:(?:await\s+)?using\s+var)|var|(?:(?:const\s+)?[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))\s+(?@?[A-Za-z_]\w*)\s*(?==|;|,)", - RegexOptions.Compiled); - private static readonly Regex CSharpForeachValueNameRegex = new( - @"\bforeach\s*\(\s*(?:var|(?:[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))\s+(?@?[A-Za-z_]\w*)\s+in\b", - RegexOptions.Compiled); - private static readonly Regex CSharpQueryRangeValueNameRegex = new( - @"\b(?:from|join)\s+(?@?[A-Za-z_]\w*)\s+in\b|\blet\s+(?@?[A-Za-z_]\w*)\s*=|\binto\s+(?@?[A-Za-z_]\w*)\b", - RegexOptions.Compiled); - private const string CSharpDeclarationPatternTypeRegex = @"(?:var|(?:[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))"; - private const string CSharpRecursivePatternClauseRegex = @"(?:\s*\{[^\n]*\})?"; - private static readonly Regex CSharpDeclarationPatternValueNameRegex = new( - @"\bis\s+" + CSharpDeclarationPatternTypeRegex + CSharpRecursivePatternClauseRegex + @"\s+(?@?[A-Za-z_]\w*)\b", - RegexOptions.Compiled); - private static readonly Regex CSharpSwitchExpressionDeclarationPatternValueNameRegex = new( - @"^\s*(?" + CSharpDeclarationPatternTypeRegex + @")\s+(?@?[A-Za-z_]\w*)\s*$", - RegexOptions.Compiled); - private static readonly Regex CSharpCaseDeclarationPatternValueNameRegex = new( - @"\bcase\s+" + CSharpDeclarationPatternTypeRegex + CSharpRecursivePatternClauseRegex + @"\s+(?@?[A-Za-z_]\w*)\b(?=\s*(?::|\bwhen\b))", - RegexOptions.Compiled); - private static readonly Regex CSharpOutValueNameRegex = new( - @"\bout\s+(?:var|(?:[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))\s+(?@?[A-Za-z_]\w*)(?=\s*[\),])", - RegexOptions.Compiled); - private static readonly Regex CSharpCatchValueNameRegex = new( - @"\bcatch\s*\(\s*(?:[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*)\s+(?@?[A-Za-z_]\w*)", - RegexOptions.Compiled); - private static readonly Regex CSharpUsingStatementValueNameRegex = new( - @"\busing\s*\(\s*(?:var|(?:[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))\s+(?@?[A-Za-z_]\w*)\s*=", - RegexOptions.Compiled); - private static readonly Regex CSharpFixedValueNameRegex = new( - @"\bfixed\s*\(\s*(?:var|(?:[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))\s+(?@?[A-Za-z_]\w*)\s*=", - RegexOptions.Compiled); - private static readonly Regex CSharpStaticModifierRegex = new(@"\bstatic\b", RegexOptions.Compiled); - // Inline `where` constraint in a C# type header; used to trim base-list parsing - // C# 型ヘッダーの where 制約句。base-list 解析の終端として使用 - private static readonly Regex CSharpWhereClauseRegex = new(@"\s+where\s+(?[\w?.]+)\s*:", RegexOptions.Compiled); - // C# record declaration with a primary-constructor parameter list. - // Used to synthesize a function-kind container for primary-ctor base calls - // (e.g. `record Child(int x) : Parent(x)`), so `callers` / `callees` / `impact` - // can attribute the `Parent(x)` edge to the record's synthetic constructor. - // C# record のプライマリーコンストラクタ宣言を検出し、base primary-ctor 呼び出しの - // 参照を record の合成コンストラクタに紐付けるために使う。 - private static readonly Regex CSharpRecordPrimaryCtorSignatureRegex = new( - $@"\brecord\s+(?:class\s+|struct\s+)?{CSharpIdentifierPattern}(?:<[^>]+>)?\s*\(", - RegexOptions.Compiled); - // Same intent as CSharpRecordPrimaryCtorSignatureRegex but applied to the joined multi-line - // header produced by CollectCSharpRecordHeader, so split-line forms like - // `public record Child\n(\n int Value\n)\n : Parent(Value);` still match. - // Also covers C# 12 `class` / `struct` primary constructors such as - // `public class Child(int value) : Parent(value) { }` and - // `public struct Child(int value) : IParent { }` so their `Parent(value)` chain edges are - // also attributed to the synthetic function-kind container named after the declaring type. - // CollectCSharpRecordHeader で連結された複数行ヘッダーに対しても当てるため、`record` / `class` / - // `struct` と `(` が別行に分かれる書式でも primary-ctor 宣言と判定できるようにする。 - // C# 12 以降の class / struct primary constructor にも同じ合成コンテナ経路を適用する。 - private static readonly Regex CSharpPrimaryCtorHeaderRegex = new( - $@"\b(?:record\s+(?:class\s+|struct\s+)?|class\s+|struct\s+){CSharpIdentifierPattern}(?:\s*<[^>]+>)?\s*\(", - RegexOptions.Compiled); - // C# compile-time type/member references: `nameof(X.Y)`, `typeof(T)`, `sizeof(T)`, `default(T)`. - // Keywords are in SharedIgnoredCallNames so CallRegex skips them, but their arguments have no - // trailing `(` and therefore slip through. Captured here as a dedicated "type_reference" kind - // so callers/callees (which exclude type_reference by default) stay unaffected while - // references and impact see the edge. See issue #253. - // The regex only locates the keyword and opening `(`; the argument itself is walked by - // ExtractCSharpTypeKeywordSegments so generic `<...>`, array `[...]`, and `global::` qualifiers - // are handled without truncating the real type path. - // C# の nameof/typeof/sizeof/default は、キーワード自体が SharedIgnoredCallNames にあるため - // CallRegex では読み飛ばされ、引数の識別子も末尾に `(` が無いため通常経路では捕捉できない。 - // ここで type_reference として拾い、callers/callees(既定で type_reference を除外)に影響せず - // references と impact だけに edge を届ける。issue #253 参照。 - // 正規表現はキーワードと `(` の位置だけを捕捉し、引数本体の走査は ExtractCSharpTypeKeywordSegments - // に任せる。これにより generic `<...>`、配列 `[...]`、`global::` 等を途中で切らない。 - private static readonly Regex CSharpTypeKeywordIntroRegex = new( - @"(?nameof|typeof|sizeof|default)\s*\(", - RegexOptions.Compiled); - // Reflection member-name lookups such as `GetMethod("Run")` carry a real member reference - // even though the symbol name appears as string data. Emit only literal or literal-concat - // first arguments so dynamic names stay conservative. - private static readonly Regex CSharpReflectionNameApiIntroRegex = new( - @"(?GetMethod|GetField|GetProperty|GetEvent|GetMember|GetNestedType)\s*\(", - RegexOptions.Compiled); - // C# type tests (`o is Base`, `o is not Base`, `o as Base`). - // `is` / `is not` / `as` の型位置 (`o is Base`, `o is not Base`, `o as Base`)。 - private static readonly Regex CSharpIsAsTypeTestRegex = new( - $@"(?{CSharpTypeExpressionPattern})", - RegexOptions.Compiled, - ExtractionRegexTimeout); - internal static readonly Regex CSharpTrailingIsAsTypePatternIntroRegex = new( - @"(?{CSharpTypeExpressionPattern})", - RegexOptions.Compiled, - ExtractionRegexTimeout); - // C# XML-doc cross-reference (``, ``). - // C# XML doc の `` / ``。 - private static readonly Regex CSharpDocCrefRegex = new( - @"<(?:see|seealso)\s+cref\s*=\s*""(?[^""]+)""", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - // Javadoc / KDoc cross-reference links (`{@link Foo#bar}`, `@see Foo`, `[Foo.bar]`). - // Javadoc / KDoc の cross-reference link。 - private static readonly Regex JvmDocInlineLinkRegex = new( - @"\{@(?:link|linkplain|value)\s+(?[^\s}]+)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex JvmDocSeeReferenceRegex = new( - @"(?:^|\s)@(?:see|throws|exception)\s+(?[^\s}]+)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex KDocBracketLinkRegex = new( - @"\[(?#?(?:[_\p{L}][\w$]*|`[^`\r\n]+`)(?:(?:\.|#)(?:[_\p{L}][\w$]*|`[^`\r\n]+`))*)\](?!\s*(?:\(|\[))", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - // Java primitive type names that can precede `.class` (e.g. `int.class`, `void.class`). - // Skipped from reference rows because they are language-level keywords, not indexed types. - // `int.class` 等に現れる Java のプリミティブ型。インデックス対象の型ではないため除外する。 - private static readonly HashSet JavaPrimitiveTypeNames = new(StringComparer.Ordinal) - { - "int", "long", "short", "boolean", "byte", "char", "float", "double", "void", - }; - - // C# predefined type aliases / void / dynamic / var. They resolve to BCL primitives that are - // not indexed as user-defined symbols, so emitting them as `type_reference` just pollutes - // references/inspect output without ever linking to a real definition. - // C# の built-in 型 alias / void / dynamic / var。ユーザー定義シンボルに解決しないため - // type_reference として残すとノイズにしかならない。issue #253 のレビュー指摘により除外。 - private static readonly HashSet CSharpBuiltInTypeNames = new(StringComparer.Ordinal) - { - "bool", "byte", "sbyte", "short", "ushort", "int", "uint", "long", "ulong", - "nint", "nuint", "char", "float", "double", "decimal", - "string", "object", "void", "dynamic", "var", - }; - private static readonly HashSet CSharpWhereConstraintIgnoredSegments = new(StringComparer.Ordinal) - { - "allows", "default", "notnull", "ref", "unmanaged", - }; - private static readonly Dictionary> LanguageBuiltInTypeNames = new(StringComparer.Ordinal) - { - ["typescript"] = new HashSet(StringComparer.Ordinal) - { - "any", "bigint", "boolean", "false", "never", "null", "number", "object", "string", - "infer", "keyof", "readonly", "symbol", "true", "undefined", "unique", "unknown", "void", - }, - ["kotlin"] = new HashSet(StringComparer.Ordinal) - { - "Any", "Boolean", "Byte", "Char", "Double", "Float", "Int", "Long", "Nothing", - "Short", "String", "Unit", - }, - ["swift"] = new HashSet(StringComparer.Ordinal) - { - "Any", "Bool", "Character", "Double", "Float", "Int", "Int8", "Int16", "Int32", "Int64", - "Never", "Self", "String", "UInt", "UInt8", "UInt16", "UInt32", "UInt64", "Void", - "any", "async", "borrowing", "consuming", "each", "inout", "isolated", "repeat", "rethrows", - "sending", "some", "throws", - }, - ["rust"] = new HashSet(StringComparer.Ordinal) - { - "Self", "bool", "char", "const", "dyn", "f32", "f64", "for", "i8", "i16", "i32", "i64", "i128", - "impl", "isize", "mut", "ref", "static", "str", "u8", "u16", "u32", "u64", "u128", "usize", - }, - ["c"] = new HashSet(StringComparer.Ordinal) - { - "_Atomic", "bool", "char", "const", "double", "enum", "float", "int", "long", - "restrict", "short", "signed", "size_t", "ssize_t", "struct", "uint8_t", - "uint16_t", "uint32_t", "uint64_t", "union", "unsigned", "void", "volatile", - }, - ["cpp"] = new HashSet(StringComparer.Ordinal) - { - "bool", "char", "char8_t", "char16_t", "char32_t", "double", "float", "int", "long", - "short", "signed", "size_t", "ssize_t", "std", "string", "unsigned", "void", - "wchar_t", - }, - ["go"] = new HashSet(StringComparer.Ordinal) - { - "any", "bool", "byte", "comparable", "complex64", "complex128", "error", "float32", - "float64", "int", "int8", "int16", "int32", "int64", "rune", "string", "uint", - "uint8", "uint16", "uint32", "uint64", "uintptr", "chan", "func", "interface", - "map", "struct", - }, - ["dart"] = new HashSet(StringComparer.Ordinal) - { - "bool", "double", "dynamic", "Function", "int", "Never", "Null", "num", "Object", - "String", "void", - }, - ["vb"] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "Boolean", "Byte", "Char", "Date", "Decimal", "Double", "Integer", "Long", "Object", - "SByte", "Short", "Single", "String", "UInteger", "ULong", "UShort", "Void", - }, - ["fortran"] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "character", "complex", "double", "integer", "logical", "precision", "real", - }, - ["pascal"] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "AnsiString", "Boolean", "Byte", "Cardinal", "Char", "Double", "Extended", "Integer", - "LongInt", "LongWord", "Pointer", "Real", "ShortInt", "Single", "SmallInt", "String", - "Variant", "WideString", "Word", - }, - ["objc"] = new HashSet(StringComparer.Ordinal) - { - "BOOL", "Class", "CGFloat", "NSInteger", "NSUInteger", "SEL", "bool", "char", "double", - "float", "id", "instancetype", "int", "long", "short", "void", - }, - ["haskell"] = new HashSet(StringComparer.Ordinal) - { - "Bool", "Char", "Double", "Either", "False", "Float", "IO", "Int", "Integer", "Maybe", - "Nothing", "String", "True", - }, - }; - // C# pattern-only keywords / literals that can appear after `is` / `case not` but are never - // real user-defined types. Filter them before AddTypeExpressionSegments so `is not null`, - // `is default`, and similar constant patterns do not surface phantom `type_reference` rows. - // `is` / `case not` の後ろに現れうるが、実在型ではない C# のパターン専用キーワード / リテラル。 - // AddTypeExpressionSegments 前に落とし、`is not null` や `is default` などの定数パターンから - // phantom な `type_reference` 行が出ないようにする。 - private static readonly HashSet CSharpNonTypePatternTokens = new(StringComparer.Ordinal) - { - "default", "false", "not", "null", "true", - }; - - // No-arg C# attribute name (`[Serializable]`, `[assembly: CLSCompliant]`, `[System.Obsolete]`, - // `[global::System.Obsolete]`, `[Alias::MyAttr]`, `[Required, Key]`, and their multi-line - // variants where `[` / `]` sit on separate lines). CallRegex only matches identifiers followed - // by `(`, so no-arg attributes would otherwise never be indexed. The pattern refuses to match - // when the identifier is followed by `(` (handled by CallRegex + TryClassifyMetadataReference) - // or a qualifier continuation (`.` / `::`). The match is gated downstream by - // `IsInsideCSharpAttributeRange`, so it is safe to relax the `[` / `,` left-anchor in favor of - // a word-boundary lookbehind — that lets a bare identifier on a line like ` Serializable` - // inside a multi-line attribute section still be recognized. - // 引数なしの C# attribute 名用 regex。`[Serializable]` などは CallRegex では拾えないため専用の - // 入口で捕捉する。`global::System.Obsolete` や `Alias::MyAttr` のように `::` 修飾子の付く形も - // 許容する。`[` / `,` / `]` が別行にある複数行形(例: `[\n Serializable\n]`)も取り込むため、 - // 左側は `[` / `,` ではなく単語境界だけでアンカーする。属性以外の位置で誤検出しないよう、 - // マッチ後は `IsInsideCSharpAttributeRange` で属性レンジ内かどうかを確認する。後続が `(` - // (CallRegex 経路)や `.` / `::`(qualifier 継続)なら名前を確定させず、行末(`$`)・`]`・`,` - // のいずれかで初めて採用する。 - private static readonly Regex CSharpNoArgAttributeRegex = new( - $@"(?{CSharpIdentifierPattern})(?:\s*<[^\n]+?>)?\s*(?=[\],]|$)", - RegexOptions.Compiled); - - // No-arg Java-family annotation (`@Deprecated`, `@Override`, `@org.junit.Test`, `@field:Deprecated`). - // CallRegex only catches `@Name(` forms; this pattern fills the bare `@Name` gap. The leading - // lookbehind `(?[A-Za-z_]\w*)\b(?!\s*[.(])", - RegexOptions.Compiled); - private static readonly Regex KotlinBacktickAnnotationRegex = new( - @"(?`[^`\r\n]+`)(?:\s*\([^)\r\n]*\))?", - RegexOptions.Compiled); - - - // Languages whose `@Decorator(args)` / `@Annotation(args)` / `@Attribute(args)` syntax - // should produce `annotation` reference rows rather than `call` rows (issue #293). - // Swift uses `@available(...)`, `@objc`, `@MainActor`, etc. as compile-time metadata; - // Gradle/Groovy uses `@CompileStatic`, `@TaskAction`, etc. the same way. Without this - // reclassification, `callers` / `callees` / `hotspots` / `impact` on those languages - // get polluted with metadata edges. - // `@Decorator(args)` / `@Annotation(args)` / `@Attribute(args)` を `call` ではなく - // `annotation` として記録すべき言語 (issue #293)。Swift の `@available(...)` / `@objc` / - // `@MainActor` や、Gradle/Groovy の `@CompileStatic` / `@TaskAction` も compile-time - // metadata なので同じ扱いにする。再分類しないと `callers` / `callees` / `hotspots` / - // `impact` に metadata edge が混入する。 - private static readonly HashSet AnnotationLanguages = new(StringComparer.Ordinal) - { - "java", "kotlin", "scala", "typescript", "javascript", "swift", "gradle", "groovy", "dart", - }; - - // Kotlin use-site target prefixes for annotations (e.g. `@field:Deprecated("msg")`, - // `@file:JvmName("Foo")`). Keep aligned with the Kotlin language spec use-site targets. - // Kotlin の use-site target 付き注釈用の接頭辞。 - private static readonly HashSet KotlinAnnotationTargets = new(StringComparer.Ordinal) - { - "field", "get", "set", "param", "setparam", "property", "receiver", "file", "delegate", "all", - }; - - public static IReadOnlyCollection GetSupportedLanguages() - => GetSupportedLanguages(workspaceRoot: null); - - internal static IReadOnlyCollection GetSupportedLanguages(string? workspaceRoot) - { - var pluginLanguages = ExtractorPluginRegistry.GetReferenceLanguages(workspaceRoot); - var capacity = RegisteredLanguages.Count + AdditionalReferenceLanguages.Length + pluginLanguages.Count; - var languages = new List(capacity); - var seen = new HashSet(capacity, StringComparer.Ordinal); - - AddSupportedLanguages(RegisteredLanguages, languages, seen); - AddSupportedLanguages(AdditionalReferenceLanguages, languages, seen); - AddSupportedLanguages(pluginLanguages, languages, seen); - return languages.ToArray(); - } - - private static void AddSupportedLanguages( - IEnumerable candidates, - List languages, - HashSet seen) - { - foreach (var language in candidates) - { - if (seen.Add(language)) - languages.Add(language); - } - } - - /// - /// Registered language keys for reference extraction. - /// 参照抽出に登録されている言語キー。 - /// - public static IReadOnlyCollection RegisteredLanguages => BuiltInLanguages; - - private static string? NormalizeLanguage(string? lang) - { - if (lang is null) - return null; - - var trimmed = lang.AsSpan().Trim(); - if (trimmed.IsEmpty) - return null; - - if (trimmed.Equals("vue", StringComparison.OrdinalIgnoreCase) - || trimmed.Equals("svelte", StringComparison.OrdinalIgnoreCase)) - { - return "typescript"; - } - - if (trimmed.Equals("razor", StringComparison.OrdinalIgnoreCase) - || trimmed.Equals("blazor", StringComparison.OrdinalIgnoreCase) - || trimmed.Equals("cshtml", StringComparison.OrdinalIgnoreCase)) - { - return "csharp"; - } - - return NormalizeLanguageKey(lang, trimmed); - } - - private static string? NormalizePluginLanguage(string? lang) - { - if (lang is null) - return null; - - var trimmed = lang.AsSpan().Trim(); - return trimmed.IsEmpty ? null : NormalizeLanguageKey(lang, trimmed); - } - - private static string NormalizeLanguageKey(string original, ReadOnlySpan trimmed) - { - for (var i = 0; i < trimmed.Length; i++) - { - if (char.ToLowerInvariant(trimmed[i]) != trimmed[i]) - return trimmed.ToString().ToLowerInvariant(); - } - - return trimmed.Length == original.Length && trimmed.SequenceEqual(original.AsSpan()) - ? original - : trimmed.ToString(); - } - - public static bool SupportsLanguage(string? lang) - => SupportsLanguage(lang, GetSupportedLanguages(workspaceRoot: null)); - - internal static bool SupportsLanguage( - string? lang, - IReadOnlyCollection supportedLanguages) - { - var normalized = NormalizeLanguage(lang); - if (normalized != null && supportedLanguages.Contains(normalized, StringComparer.Ordinal)) - return true; - - return NormalizePluginLanguage(lang) is string pluginLanguage - && supportedLanguages.Contains(pluginLanguage, StringComparer.Ordinal); - } - - /// - /// Returns the registered reference extractor for a supported language. - /// 対応言語の登録済み参照抽出器を返す。 - /// - public static bool TryGetExtractor(string? lang, out IReferenceExtractor extractor) - => TryGetExtractor(lang, out extractor, out _); - - private static bool TryGetExtractor(string? lang, out IReferenceExtractor extractor, out string? normalized) - { - normalized = NormalizeLanguage(lang); - if (normalized != null && Extractors.TryGetValue(normalized, out extractor!)) - return true; - - extractor = null!; - return false; - } - - public static bool? SupportsSymbolGraph(string? lang, string? kind, string? containerKind) - { - if (lang == null) - return null; - - return SupportsLanguage(lang); - } - - internal static bool? SupportsSymbolGraph( - string? lang, - string? kind, - string? containerKind, - IReadOnlyCollection supportedLanguages) - { - if (lang == null) - return null; - - return SupportsLanguage(lang, supportedLanguages); - } - - public static string? GetUnsupportedSymbolKind(string? lang, string? kind, string? containerKind) - { - return null; - } - - /// - /// Build a human-readable reason explaining graph-support status for the given language. - /// Returns null when neither language nor support status is known. - /// 指定言語の graph 対応状況を人間向けに説明する文字列を返す。言語も対応状況も不明なら null。 - /// - public static string? BuildGraphSupportReason(string? lang, bool? graphSupported, string? kind = null, string? containerKind = null) - { - if (lang == null || graphSupported == null) - return null; - - if (graphSupported.Value) - return $"Call-graph extraction is indexed for '{lang}'."; - - return $"Call-graph extraction is not indexed for '{lang}'. Use search, definition, excerpt, or files instead."; - } - - public static string? BuildGraphSupportReasonWithUnsupportedEnumMemberGap(string? lang, bool? graphSupported, bool hasUnsupportedEnumMember, bool hasSupportedGraphDefinition) - { - var baseReason = BuildGraphSupportReason(lang, graphSupported); - if (!hasUnsupportedEnumMember) - return baseReason; - - var enumGapReason = hasSupportedGraphDefinition - ? "Exact results also include C# enum members whose access edges are not indexed yet." - : BuildGraphSupportReason("csharp", true, "enum", "enum"); - - if (!hasSupportedGraphDefinition) - return enumGapReason; - - if (string.IsNullOrWhiteSpace(baseReason)) - return enumGapReason; - if (string.IsNullOrWhiteSpace(enumGapReason) || string.Equals(baseReason, enumGapReason, StringComparison.Ordinal)) - return baseReason; - - return $"{baseReason} {enumGapReason}"; - } - - private static string NormalizeKotlinBacktickIdentifier(string name) - { - if (name.Length >= 2 && name[0] == '`' && name[^1] == '`') - return name[1..^1]; - return name; - } - - /// - /// Extract indexed references for supported languages. - /// 対応言語向けにインデックス化する参照を抽出する。 - /// public static List Extract( long fileId, string? lang, @@ -1397,2495 +300,6 @@ internal static bool TryAddReference(List references, Reference return true; } - private static IReadOnlyDictionary> BuildDefinitionNamesByLine( - string language, - IReadOnlyList symbols, - Action? reportDiagnostic) - { - if (symbols.Count == 0) - return EmptyDefinitionNamesByLine; - - var limits = GetSafetyLimits(); - var definitionNamesComparer = GetDefinitionNamesComparer(language); - var namesByLine = new Dictionary>(); - var lineBudgetReported = false; - var lineNameBudgetReported = false; - for (var index = 0; index < symbols.Count; index++) - { - if (index >= limits.MaxLookupSymbols) - { - ReportReferenceLookupBudgetHit( - reportDiagnostic, - "reference_definition_lookup_symbol_budget_exceeded", - $"Reference definition-name lookup used the first {limits.MaxLookupSymbols:N0} symbols and skipped additional symbols."); - break; - } - - var symbol = symbols[index]; - if (!namesByLine.TryGetValue(symbol.Line, out var names)) - { - if (namesByLine.Count >= limits.MaxLookupLines) - { - if (!lineBudgetReported) - { - ReportReferenceLookupBudgetHit( - reportDiagnostic, - "reference_definition_lookup_line_budget_exceeded", - $"Reference definition-name lookup used the first {limits.MaxLookupLines:N0} definition lines and skipped additional lines."); - lineBudgetReported = true; - } - - continue; - } - - names = new HashSet(definitionNamesComparer); - namesByLine[symbol.Line] = names; - } - - if (names.Count >= limits.MaxNamesPerLine && !names.Contains(symbol.Name)) - { - if (!lineNameBudgetReported) - { - ReportReferenceLookupBudgetHit( - reportDiagnostic, - "reference_definition_lookup_line_name_budget_exceeded", - $"Reference definition-name lookup retained at most {limits.MaxNamesPerLine:N0} names per line and skipped additional names."); - lineNameBudgetReported = true; - } - - continue; - } - - names.Add(symbol.Name); - if (language == "sql") - SqlReferenceExtractor.AddDefinitionNameAliases(names, symbol); - } - - return namesByLine; - } - - private static IReadOnlyDictionary>>? - BuildScientificDefinitionNameIndicesByLine( - string language, - IReadOnlyList lines, - IReadOnlyList symbols, - IReadOnlyDictionary> definitionNamesByLine) - { - if (!ScientificNativeReferenceExtractor.Supports(language) || symbols.Count == 0) - return null; - - var limits = GetSafetyLimits(); - var comparer = GetDefinitionNamesComparer(language); - var comparison = comparer == StringComparer.OrdinalIgnoreCase - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - var indicesByLine = new Dictionary>>(); - for (var symbolIndex = 0; - symbolIndex < symbols.Count && symbolIndex < limits.MaxLookupSymbols; - symbolIndex++) - { - var symbol = symbols[symbolIndex]; - if (symbol.Line <= 0 - || symbol.Line > lines.Count - || !definitionNamesByLine.TryGetValue(symbol.Line, out var retainedNames) - || !retainedNames.Contains(symbol.Name)) - { - continue; - } - - var line = lines[symbol.Line - 1]; - var searchStart = Math.Clamp(symbol.StartColumn ?? 0, 0, line.Length); - var definitionIndex = FindScientificDefinitionNameIndex( - line, - symbol.Name, - searchStart, - comparison); - if (definitionIndex < 0) - continue; - - if (!indicesByLine.TryGetValue(symbol.Line, out var indicesByName)) - { - indicesByName = new Dictionary>(comparer); - indicesByLine[symbol.Line] = indicesByName; - } - - AddScientificDefinitionNameIndex( - indicesByName, - symbol.Name, - definitionIndex); - - var leafSeparatorIndex = symbol.Name.LastIndexOf('.'); - if (leafSeparatorIndex >= 0 && leafSeparatorIndex + 1 < symbol.Name.Length) - { - AddScientificDefinitionNameIndex( - indicesByName, - symbol.Name[(leafSeparatorIndex + 1)..], - definitionIndex + leafSeparatorIndex + 1); - } - } - - return indicesByLine; - } - - private static int FindScientificDefinitionNameIndex( - string line, - string name, - int searchStart, - StringComparison comparison) - { - while (searchStart <= line.Length - name.Length) - { - var index = line.IndexOf(name, searchStart, comparison); - if (index < 0) - return -1; - - var beforeIsBoundary = index == 0 - || !IsScientificDefinitionIdentifierChar(line[index - 1]); - var end = index + name.Length; - var afterIsBoundary = end == line.Length - || !IsScientificDefinitionIdentifierChar(line[end]); - if (beforeIsBoundary && afterIsBoundary) - return index; - - searchStart = index + 1; - } - - return -1; - } - - private static bool IsScientificDefinitionIdentifierChar(char value) - => char.IsLetterOrDigit(value) || value is '_' or '!' or '?' or '$'; - - private static void AddScientificDefinitionNameIndex( - Dictionary> indicesByName, - string name, - int index) - { - if (!indicesByName.TryGetValue(name, out var indices)) - { - indices = []; - indicesByName[name] = indices; - } - - indices.Add(index); - } - - private static IReadOnlySet BuildAllDefinitionNames( - string language, - IReadOnlyList symbols, - Action? reportDiagnostic) - { - if (symbols.Count == 0) - return EmptyDefinitionNameSet; - - var limits = GetSafetyLimits(); - var names = new HashSet(GetDefinitionNamesComparer(language)); - for (var index = 0; index < symbols.Count; index++) - { - if (index >= limits.MaxLookupSymbols) - { - ReportReferenceLookupBudgetHit( - reportDiagnostic, - "reference_all_definition_lookup_symbol_budget_exceeded", - $"Reference all-definition lookup used the first {limits.MaxLookupSymbols:N0} symbols and skipped additional symbols."); - break; - } - - var symbol = symbols[index]; - names.Add(symbol.Name); - if (language == "sql") - SqlReferenceExtractor.AddDefinitionNameAliases(names, symbol); - } - - return names; - } - - private static IReadOnlySet BuildFileDefinitionNames(IReadOnlyList symbols) - { - if (symbols.Count == 0) - return EmptyDefinitionNameSet; - - var names = new HashSet(symbols.Count, StringComparer.Ordinal); - foreach (var symbol in symbols) - names.Add(symbol.Name); - return names; - } - - private static IReadOnlyList? BuildCobolCallableSymbols(IReadOnlyList symbols) - { - List<(SymbolRecord Symbol, int OriginalIndex)>? callableSymbols = null; - for (var index = 0; index < symbols.Count; index++) - { - var symbol = symbols[index]; - if (symbol.Kind == "function") - (callableSymbols ??= []).Add((symbol, index)); - } - - if (callableSymbols is not { Count: > 0 }) - return null; - - callableSymbols.Sort(CompareCobolCallableSymbolEntries); - - var sorted = new List(callableSymbols.Count); - foreach (var entry in callableSymbols) - sorted.Add(entry.Symbol); - return sorted; - } - - private static int CompareCobolCallableSymbolEntries( - (SymbolRecord Symbol, int OriginalIndex) left, - (SymbolRecord Symbol, int OriginalIndex) right) - { - var lineComparison = left.Symbol.Line.CompareTo(right.Symbol.Line); - if (lineComparison != 0) - return lineComparison; - - var startLineComparison = left.Symbol.StartLine.CompareTo(right.Symbol.StartLine); - if (startLineComparison != 0) - return startLineComparison; - - var nameComparison = string.Compare(left.Symbol.Name, right.Symbol.Name, StringComparison.OrdinalIgnoreCase); - return nameComparison != 0 - ? nameComparison - : left.OriginalIndex.CompareTo(right.OriginalIndex); - } - - private static IReadOnlyList? BuildRustEnumCandidates(IReadOnlyList symbols) - { - List<(SymbolRecord Symbol, int OriginalIndex)>? candidates = null; - for (var index = 0; index < symbols.Count; index++) - { - var symbol = symbols[index]; - if (symbol.Kind == "enum" && symbol.BodyStartLine != null && symbol.BodyEndLine != null) - (candidates ??= []).Add((symbol, index)); - } - - if (candidates is not { Count: > 0 }) - return null; - - candidates.Sort(CompareRustEnumCandidateEntries); - - var sorted = new List(candidates.Count); - foreach (var entry in candidates) - sorted.Add(entry.Symbol); - return sorted; - } - - private static int CompareRustEnumCandidateEntries( - (SymbolRecord Symbol, int OriginalIndex) left, - (SymbolRecord Symbol, int OriginalIndex) right) - { - var spanComparison = GetRustEnumCandidateSpan(left.Symbol).CompareTo(GetRustEnumCandidateSpan(right.Symbol)); - return spanComparison != 0 - ? spanComparison - : left.OriginalIndex.CompareTo(right.OriginalIndex); - } - - private static int GetRustEnumCandidateSpan(SymbolRecord symbol) - => (symbol.BodyEndLine ?? symbol.EndLine) - (symbol.BodyStartLine ?? symbol.StartLine); - - private static StringComparer GetDefinitionNamesComparer(string language) - => language is "sql" or "ada" - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - - private static IReadOnlyList BuildReferenceContainerCandidates( - IReadOnlyList symbols, - Action? reportDiagnostic) - => BuildBoundedContainerCandidates( - symbols, - symbol => symbol.BodyStartLine != null && symbol.BodyEndLine != null && - (IsFunctionLikeSymbolKind(symbol.Kind) || symbol.Kind == "hook" || symbol.Kind == "accessor" || symbol.Kind == "class" - || symbol.Kind == "struct" || symbol.Kind == "namespace" - || symbol.Kind == "object" || symbol.Kind == "property" || symbol.Kind == "heading" || symbol.Kind == "class_hook"), - "reference_container_candidate_budget_exceeded", - "Reference container lookup retained the highest-priority bounded candidate set and skipped additional candidates.", - reportDiagnostic); - - private static IReadOnlyList? BuildCSharpXmlDocAttachmentScopeCandidates( - string language, - IReadOnlyList symbols, - Action? reportDiagnostic) - => language == "csharp" - ? BuildBoundedContainerCandidates( - symbols, - symbol => symbol.BodyStartLine != null && symbol.BodyEndLine != null - && symbol.Kind is "class" or "struct" or "interface" or "enum" or "namespace", - "reference_csharp_xml_doc_scope_candidate_budget_exceeded", - "C# XML documentation scope lookup retained the highest-priority bounded candidate set and skipped additional candidates.", - reportDiagnostic) - : null; - - private static IReadOnlyList BuildEnclosingTypeCandidates( - IReadOnlyList symbols, - Action? reportDiagnostic) - => BuildBoundedContainerCandidates( - symbols, - symbol => symbol.BodyStartLine != null && symbol.BodyEndLine != null && - (symbol.Kind == "class" || symbol.Kind == "struct" || symbol.Kind == "interface" || symbol.Kind == "enum"), - "reference_enclosing_type_candidate_budget_exceeded", - "Reference enclosing-type lookup retained the highest-priority bounded candidate set and skipped additional candidates.", - reportDiagnostic); - - private static IReadOnlyDictionary? BuildSwiftPropertyDefinitionsByLine( - string language, - IReadOnlyList symbols, - Action? reportDiagnostic) - { - if (language != "swift") - return null; - - var limits = GetSafetyLimits(); - Dictionary>? byLine = null; - var lineBudgetReported = false; - var perLineBudgetReported = false; - for (var index = 0; index < symbols.Count && index < limits.MaxLookupSymbols; index++) - { - var symbol = symbols[index]; - if (symbol.Kind != "property") - continue; - - var lookup = byLine ??= new Dictionary>(); - if (!lookup.TryGetValue(symbol.Line, out var lineSymbols)) - { - if (lookup.Count >= limits.MaxLookupLines) - { - if (!lineBudgetReported) - { - ReportReferenceLookupBudgetHit( - reportDiagnostic, - "reference_swift_property_line_budget_exceeded", - $"Swift property lookup retained at most {limits.MaxLookupLines:N0} definition lines and skipped additional lines."); - lineBudgetReported = true; - } - - continue; - } - - lineSymbols = []; - lookup[symbol.Line] = lineSymbols; - } - - if (lineSymbols.Count >= limits.MaxNamesPerLine) - { - if (!perLineBudgetReported) - { - ReportReferenceLookupBudgetHit( - reportDiagnostic, - "reference_swift_property_line_name_budget_exceeded", - $"Swift property lookup retained at most {limits.MaxNamesPerLine:N0} properties per line and skipped additional properties."); - perLineBudgetReported = true; - } - - continue; - } - - lineSymbols.Add(symbol); - } - - if (symbols.Count > limits.MaxLookupSymbols) - { - ReportReferenceLookupBudgetHit( - reportDiagnostic, - "reference_swift_property_symbol_budget_exceeded", - $"Swift property lookup used the first {limits.MaxLookupSymbols:N0} symbols and skipped additional symbols."); - } - - if (byLine is not { Count: > 0 }) - return null; - - var result = new Dictionary(byLine.Count); - foreach (var pair in byLine) - result.Add(pair.Key, SortSwiftPropertyDefinitionCandidates(pair.Value)); - - return result; - } - - private static SymbolRecord[] SortSwiftPropertyDefinitionCandidates(IReadOnlyList candidates) - { - if (candidates.Count == 1) - return [candidates[0]]; - - var entries = new List<(SymbolRecord Symbol, int OriginalIndex)>(candidates.Count); - for (var index = 0; index < candidates.Count; index++) - entries.Add((candidates[index], index)); - - entries.Sort(CompareSwiftPropertyDefinitionCandidateEntries); - - var sorted = new SymbolRecord[entries.Count]; - for (var index = 0; index < entries.Count; index++) - sorted[index] = entries[index].Symbol; - return sorted; - } - - private static int CompareSwiftPropertyDefinitionCandidateEntries( - (SymbolRecord Symbol, int OriginalIndex) left, - (SymbolRecord Symbol, int OriginalIndex) right) - { - var startColumnComparison = (right.Symbol.StartColumn ?? 0).CompareTo(left.Symbol.StartColumn ?? 0); - return startColumnComparison != 0 - ? startColumnComparison - : left.OriginalIndex.CompareTo(right.OriginalIndex); - } - - private static IReadOnlyList BuildBoundedContainerCandidates( - IReadOnlyList symbols, - Func predicate, - string diagnosticKind, - string diagnosticMessage, - Action? reportDiagnostic) - { - var limit = GetSafetyLimits().MaxContainerCandidates; - List? candidates = null; - var truncated = false; - for (var symbolIndex = 0; symbolIndex < symbols.Count; symbolIndex++) - { - var symbol = symbols[symbolIndex]; - if (!predicate(symbol)) - continue; - - if ((candidates?.Count ?? 0) >= limit) - { - truncated = true; - continue; - } - - (candidates ??= new List( - Math.Min(symbols.Count, limit))).Add(new ReferenceContainerCandidateSortEntry( - symbol, - GetReferenceContainerCandidateSpanLength(symbol), - symbolIndex)); - } - - if (truncated) - ReportReferenceLookupBudgetHit(reportDiagnostic, diagnosticKind, diagnosticMessage); - - if (candidates is not { Count: > 0 }) - return Array.Empty(); - - candidates.Sort(CompareReferenceContainerCandidateSortEntries); - - var sorted = new SymbolRecord[candidates.Count]; - for (var index = 0; index < candidates.Count; index++) - sorted[index] = candidates[index].Symbol; - - return sorted; - } - - private readonly record struct ReferenceContainerCandidateSortEntry(SymbolRecord Symbol, int SpanLength, int OriginalIndex); - - private static int CompareReferenceContainerCandidateSortEntries( - ReferenceContainerCandidateSortEntry left, - ReferenceContainerCandidateSortEntry right) - { - var compare = left.SpanLength.CompareTo(right.SpanLength); - return compare != 0 - ? compare - : left.OriginalIndex.CompareTo(right.OriginalIndex); - } - - private static int GetReferenceContainerCandidateSpanLength(SymbolRecord symbol) - => (symbol.BodyEndLine ?? symbol.EndLine) - (symbol.BodyStartLine ?? symbol.StartLine); - - private static void ReportReferenceLookupBudgetHit( - Action? reportDiagnostic, - string kind, - string message) - => reportDiagnostic?.Invoke(new ReferenceExtractionDiagnostic(kind, message)); - - private static void EmitPhpLinePreambleReferences( - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - int lineNumber, - Func getLineContainer, - ref bool inDocblock, - ref SymbolRecord? docblockContainer, - ref HashSet? docblockPropertyNames) - { - if (originalLine.Contains("#[", StringComparison.Ordinal)) - { - var attributeContext = originalLine.Trim(); - if (attributeContext.Length > 0) - { - PhpReferenceExtractor.EmitAttributeReferences( - originalLine, - references, - seen, - fileId, - attributeContext, - lineNumber, - getLineContainer()); - } - } - - if (originalLine.IndexOf("/**", StringComparison.Ordinal) >= 0) - { - inDocblock = true; - docblockContainer = getLineContainer(); - docblockPropertyNames = null; - } - - var docblockContext = originalLine.Trim(); - if (docblockContext.Length > 0) - { - if (originalLine.Contains("param", StringComparison.OrdinalIgnoreCase)) - { - PhpReferenceExtractor.EmitDocblockParamTypeReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); - } - - if (originalLine.Contains("return", StringComparison.OrdinalIgnoreCase)) - { - PhpReferenceExtractor.EmitDocblockReturnTypeReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); - } - - if (originalLine.Contains("var", StringComparison.OrdinalIgnoreCase)) - { - PhpReferenceExtractor.EmitDocblockVarTypeReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); - } - - if (originalLine.Contains("@throws", StringComparison.OrdinalIgnoreCase)) - { - PhpReferenceExtractor.EmitDocblockThrowsTypeReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); - } - - if (originalLine.Contains("extends", StringComparison.OrdinalIgnoreCase)) - { - PhpReferenceExtractor.EmitDocblockExtendsTypeReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); - } - - if (originalLine.Contains("implements", StringComparison.OrdinalIgnoreCase)) - { - PhpReferenceExtractor.EmitDocblockImplementsTypeReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); - } - - if (originalLine.Contains("@mixin", StringComparison.OrdinalIgnoreCase)) - { - PhpReferenceExtractor.EmitDocblockMixinTypeReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); - } - - if (originalLine.Contains("property", StringComparison.OrdinalIgnoreCase)) - { - PhpReferenceExtractor.EmitDocblockPropertyTypeReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer), - inDocblock, - ref docblockPropertyNames); - } - - if (originalLine.Contains("@method", StringComparison.OrdinalIgnoreCase)) - { - PhpReferenceExtractor.EmitDocblockMethodReturnTypeReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); - PhpReferenceExtractor.EmitDocblockMethodParameterTypeReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); - } - - if (originalLine.Contains("@template", StringComparison.OrdinalIgnoreCase)) - { - PhpReferenceExtractor.EmitDocblockTemplateBoundTypeReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); - } - - if (originalLine.Contains("type", StringComparison.OrdinalIgnoreCase)) - { - PhpReferenceExtractor.EmitDocblockTypeAliasTargetReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); - PhpReferenceExtractor.EmitDocblockImportTypeSourceReferences( - originalLine, - references, - seen, - fileId, - docblockContext, - lineNumber, - ResolvePhpDocblockContainer(inDocblock, docblockContainer, getLineContainer)); - } - } - - if (inDocblock && originalLine.IndexOf("*/", StringComparison.Ordinal) >= 0) - { - inDocblock = false; - docblockContainer = null; - docblockPropertyNames = null; - } - } - - private static SymbolRecord? ResolvePhpDocblockContainer( - bool inDocblock, - SymbolRecord? docblockContainer, - Func getLineContainer) - => inDocblock ? docblockContainer : getLineContainer(); - - internal static void AddReference( - List references, - ReferenceDedupeSet seen, - long fileId, - Match match, - string referenceKind, - string context, - int lineNumber, - SymbolRecord? container, - string? language = null, - string? targetQualifier = null) - { - AddReference( - references, - seen, - fileId, - match.Groups["name"].Value, - match.Groups["name"].Index, - referenceKind, - context, - lineNumber, - container, - language, - targetQualifier); - } - - internal static void AddReference( - List references, - ReferenceDedupeSet seen, - long fileId, - string name, - int nameIndex, - string referenceKind, - string context, - int lineNumber, - SymbolRecord? container, - string? language = null, - string? targetQualifier = null) - { - var column = nameIndex + 1; - var dedupeKey = CreateReferenceDedupeKey(fileId, language, lineNumber, column, referenceKind, name, container); - if (!seen.Add(dedupeKey)) - return; - var currentContainerReceiver = string.Equals( - targetQualifier, - ScientificNativeReferenceExtractor.CurrentContainerReceiverMarker, - StringComparison.Ordinal); - - TryAddReference(references, new ReferenceRecord - { - FileId = fileId, - SymbolName = name, - IdentitySymbolNameFolded = language == "nim" - ? NimIdentifierIdentity.Fold(name) - : null, - ReferenceKind = referenceKind, - Line = lineNumber, - Column = column, - Context = context, - ContainerKind = container?.Kind, - ContainerName = container?.Name, - IdentityContainerNameFolded = language == "nim" - ? NimIdentifierIdentity.Fold(container?.Name) - : null, - TargetQualifier = currentContainerReceiver ? null : targetQualifier, - SuppressInferredTargetQualifier = currentContainerReceiver, - IsSelfReference = (targetQualifier == null || currentContainerReceiver) - && IsSameReferenceName(container?.Name, name), - }); - } - - internal static string BuildReferenceDedupeKey( - long fileId, - string? language, - int lineNumber, - int column, - string referenceKind, - string name, - SymbolRecord? container) - => CreateReferenceDedupeKey(fileId, language, lineNumber, column, referenceKind, name, container).ToString(); - - internal static ReferenceDedupeKey CreateReferenceDedupeKey( - long fileId, - string? language, - int lineNumber, - int column, - string referenceKind, - string name, - SymbolRecord? container) - => CreateReferenceDedupeKey( - fileId, - language, - lineNumber, - column, - referenceKind, - name, - container?.Kind, - container?.Name); - - internal static ReferenceDedupeKey CreateReferenceDedupeKey( - long fileId, - string? language, - int lineNumber, - int column, - string referenceKind, - string name, - string? containerKind, - string? containerName) - => new( - fileId, - string.IsNullOrWhiteSpace(language) ? "-" : language, - lineNumber, - column, - referenceKind, - string.IsNullOrWhiteSpace(containerKind) ? "-" : containerKind, - string.IsNullOrWhiteSpace(containerName) ? "-" : containerName, - name); - - internal static void CompactCSharpUsingAliasReferences(List references, string language) - { - var referenceCount = references.Count; - var deduped = new HashSet(referenceCount); - var writeIndex = 0; - for (var readIndex = 0; readIndex < referenceCount; readIndex++) - { - var reference = references[readIndex]; - var key = CreateReferenceDedupeKey( - reference.FileId, - language, - reference.Line, - reference.Column, - reference.ReferenceKind, - reference.SymbolName, - reference.ContainerKind, - reference.ContainerName); - if (!deduped.Add(key)) - continue; - - if (writeIndex != readIndex) - references[writeIndex] = reference; - writeIndex++; - } - - if (writeIndex < referenceCount) - references.RemoveRange(writeIndex, referenceCount - writeIndex); - } - - private static void EmitCSharpLambdaCaptureReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Dictionary>? localNamesByFunction) - { - if (container?.Kind != "function" - || localNamesByFunction == null - || !localNamesByFunction.TryGetValue(GetCSharpContainerLocalScopeKey(container), out var localNames) - || localNames.Count == 0) - { - return; - } - - foreach (Match lambda in BoundedRegex.EnumerateMatches(CSharpLambdaRegex, preparedLine)) - { - var body = lambda.Groups["body"].Value; - if (string.IsNullOrWhiteSpace(body)) - continue; - - var parameterNames = CollectCSharpLambdaParameterNames(lambda.Groups["params"].Value); - foreach (var localName in localNames) - { - if (parameterNames.Contains(localName)) - continue; - if (!ContainsCSharpIdentifier(body, localName, out var bodyRelativeIndex)) - continue; - - AddReference( - references, - seen, - fileId, - localName, - lambda.Groups["body"].Index + bodyRelativeIndex, - "capture", - context, - lineNumber, - container, - "csharp"); - } - } - } - - private static HashSet CollectCSharpLambdaParameterNames(string parameterText) - { - var names = new HashSet(StringComparer.Ordinal); - foreach (Match match in BoundedRegex.EnumerateMatches(parameterText, CSharpIdentifierPattern)) - { - var name = NormalizeAtPrefixedIdentifier(match.Value); - if (!IsIgnoredCallName("csharp", name)) - names.Add(name); - } - - return names; - } - - private static bool ContainsCSharpIdentifier(string text, string name, out int index) - { - index = -1; - var normalizedName = NormalizeAtPrefixedIdentifier(name); - foreach (Match match in BoundedRegex.EnumerateMatches(text, CSharpIdentifierPattern)) - { - if (string.Equals(NormalizeAtPrefixedIdentifier(match.Value), normalizedName, StringComparison.Ordinal)) - { - index = match.Index; - return true; - } - } - - return false; - } - - private static void TrackCSharpLocalDeclarations( - string preparedLine, - SymbolRecord? container, - Dictionary>? localNamesByFunction) - { - if (container?.Kind != "function" || localNamesByFunction == null) - return; - if (preparedLine.Contains("=>", StringComparison.Ordinal)) - return; - - foreach (Match match in CSharpLocalDeclarationRegex.Matches(preparedLine)) - { - var name = NormalizeAtPrefixedIdentifier(match.Groups["name"].Value); - if (IsIgnoredCallName("csharp", name)) - continue; - - var scopeKey = GetCSharpContainerLocalScopeKey(container); - if (!localNamesByFunction.TryGetValue(scopeKey, out var localNames)) - { - localNames = new HashSet(StringComparer.Ordinal); - localNamesByFunction[scopeKey] = localNames; - } - - localNames.Add(name); - } - } - - private static string GetCSharpContainerLocalScopeKey(SymbolRecord container) - => $"{container.Kind}:{container.ContainerQualifiedName}:{container.ContainerKind}:{container.ContainerName}:{container.Name}:{container.StartLine}:{container.EndLine}:{container.BodyStartLine}:{container.BodyEndLine}:{container.StartColumn}"; - - internal static void MarkMutualRecursionReferences(List references) - { - var edges = new HashSet<(string Caller, string Callee)>(); - Dictionary? normalizedNames = null; - foreach (var reference in references) - { - if (!IsCallGraphLikeReferenceKind(reference.ReferenceKind) - || string.IsNullOrWhiteSpace(reference.ContainerName) - || string.IsNullOrWhiteSpace(reference.SymbolName) - || reference.IsSelfReference) - { - continue; - } - - edges.Add(( - GetCachedNormalizedReferenceCycleName(reference.ContainerName, ref normalizedNames), - GetCachedNormalizedReferenceCycleName(reference.SymbolName, ref normalizedNames))); - } - - if (edges.Count == 0) - return; - - foreach (var reference in references) - { - if (!IsCallGraphLikeReferenceKind(reference.ReferenceKind) - || string.IsNullOrWhiteSpace(reference.ContainerName) - || string.IsNullOrWhiteSpace(reference.SymbolName) - || reference.IsSelfReference) - { - continue; - } - - var caller = GetCachedNormalizedReferenceCycleName(reference.ContainerName, ref normalizedNames); - var callee = GetCachedNormalizedReferenceCycleName(reference.SymbolName, ref normalizedNames); - if (edges.Contains((callee, caller))) - reference.IsMutualRecursion = true; - } - } - - private static string GetCachedNormalizedReferenceCycleName( - string name, - ref Dictionary? normalizedNames) - { - if (normalizedNames != null && normalizedNames.TryGetValue(name, out var normalizedName)) - return normalizedName; - - normalizedName = NormalizeReferenceCycleName(name); - if (ReferenceEquals(normalizedName, name)) - return normalizedName; - - normalizedNames ??= new Dictionary(StringComparer.Ordinal); - normalizedNames.Add(name, normalizedName); - return normalizedName; - } - - private static bool IsCallGraphLikeReferenceKind(string referenceKind) - => referenceKind is "call" or "instantiate" or "subscribe" or "unsubscribe" or "razor_event_binding"; - - private static bool IsSameReferenceName(string? left, string right) - => !string.IsNullOrWhiteSpace(left) - && string.Equals(NormalizeReferenceCycleName(left), NormalizeReferenceCycleName(right), StringComparison.OrdinalIgnoreCase); - - private static string NormalizeReferenceCycleName(string name) - { - var trimmed = name.Trim(); - var dot = trimmed.LastIndexOf('.'); - if (dot >= 0 && dot + 1 < trimmed.Length) - return trimmed[(dot + 1)..]; - var colon = trimmed.LastIndexOf("::", StringComparison.Ordinal); - return colon >= 0 && colon + 2 < trimmed.Length ? trimmed[(colon + 2)..] : trimmed; - } - - private const int MaxPythonLogicalReferenceLineLength = 32_768; - - private readonly record struct PythonLogicalHeaderReferenceLine( - string Text, - int SinglePhysicalLine, - int SinglePhysicalColumn, - int[]? PhysicalLines, - int[]? PhysicalColumns); - - private static bool TryBuildPythonLogicalHeaderReferenceLine( - string[] lines, - int startLineIndex, - int startColumn, - out PythonLogicalHeaderReferenceLine header) - { - var builder = new StringBuilder(GetPythonLogicalLineInitialCapacity(lines, startLineIndex, startColumn)); - List? physicalLines = null; - List? physicalColumns = null; - var singlePhysicalLine = -1; - var singlePhysicalColumn = 0; - var parenDepth = 0; - var bracketDepth = 0; - var inString = false; - var quote = '\0'; - - for (var lineIndex = startLineIndex; lineIndex < lines.Length; lineIndex++) - { - var line = lines[lineIndex]; - var column = lineIndex == startLineIndex ? startColumn : FindFirstNonWhitespaceColumn(line); - var fragmentEndColumn = FindPythonCommentColumn(line, column); - if (column < fragmentEndColumn) - { - if (builder.Length > 0) - { - if (!TryAppendPythonLogicalReferenceChar(builder, ref singlePhysicalLine, ref singlePhysicalColumn, ref physicalLines, ref physicalColumns, ' ', lineIndex, column, out header)) - return false; - } - - for (var fragmentColumn = column; fragmentColumn < fragmentEndColumn; fragmentColumn++) - { - var fragmentChar = line[fragmentColumn]; - if (fragmentChar == '\\' && fragmentColumn == fragmentEndColumn - 1) - break; - - if (!TryAppendPythonLogicalReferenceChar(builder, ref singlePhysicalLine, ref singlePhysicalColumn, ref physicalLines, ref physicalColumns, fragmentChar, lineIndex, fragmentColumn, out header)) - return false; - } - } - - for (var scan = column; scan < line.Length; scan++) - { - var ch = line[scan]; - if (inString) - { - if (ch == '\\') - { - scan++; - continue; - } - - if (ch == quote) - inString = false; - continue; - } - - if (ch is '\'' or '"') - { - inString = true; - quote = ch; - continue; - } - - if (ch == '#') - break; - if (ch == '(') - parenDepth++; - else if (ch == ')' && parenDepth > 0) - parenDepth--; - else if (ch == '[') - bracketDepth++; - else if (ch == ']' && bracketDepth > 0) - bracketDepth--; - else if (ch == ':' && parenDepth == 0 && bracketDepth == 0) - { - header = CreatePythonLogicalHeaderReferenceLine(builder, singlePhysicalLine, singlePhysicalColumn, physicalLines, physicalColumns); - return header.Text.Length > 0; - } - } - - if (parenDepth == 0 && bracketDepth == 0 && !HasPythonLineContinuationBackslash(line)) - break; - } - - header = CreatePythonLogicalHeaderReferenceLine(builder, singlePhysicalLine, singlePhysicalColumn, physicalLines, physicalColumns); - return header.Text.Length > 0; - } - - private static bool TryBuildPythonLogicalStatementReferenceLine( - string[] lines, - int startLineIndex, - int startColumn, - out PythonLogicalHeaderReferenceLine header) - { - var builder = new StringBuilder(GetPythonLogicalLineInitialCapacity(lines, startLineIndex, startColumn)); - List? physicalLines = null; - List? physicalColumns = null; - var singlePhysicalLine = -1; - var singlePhysicalColumn = 0; - var parenDepth = 0; - var bracketDepth = 0; - var inString = false; - var quote = '\0'; - - for (var lineIndex = startLineIndex; lineIndex < lines.Length; lineIndex++) - { - var line = lines[lineIndex]; - var column = lineIndex == startLineIndex ? startColumn : FindFirstNonWhitespaceColumn(line); - var fragmentEndColumn = FindPythonCommentColumn(line, column); - if (column < fragmentEndColumn) - { - if (builder.Length > 0) - { - if (!TryAppendPythonLogicalReferenceChar(builder, ref singlePhysicalLine, ref singlePhysicalColumn, ref physicalLines, ref physicalColumns, ' ', lineIndex, column, out header)) - return false; - } - - for (var fragmentColumn = column; fragmentColumn < fragmentEndColumn; fragmentColumn++) - { - var fragmentChar = line[fragmentColumn]; - if (fragmentChar == '\\' && fragmentColumn == fragmentEndColumn - 1) - break; - - if (!TryAppendPythonLogicalReferenceChar(builder, ref singlePhysicalLine, ref singlePhysicalColumn, ref physicalLines, ref physicalColumns, fragmentChar, lineIndex, fragmentColumn, out header)) - return false; - } - } - - for (var scan = column; scan < line.Length; scan++) - { - var ch = line[scan]; - if (inString) - { - if (ch == '\\') - { - scan++; - continue; - } - - if (ch == quote) - inString = false; - continue; - } - - if (ch is '\'' or '"') - { - inString = true; - quote = ch; - continue; - } - - if (ch == '#') - break; - if (ch == '(') - parenDepth++; - else if (ch == ')' && parenDepth > 0) - parenDepth--; - else if (ch == '[') - bracketDepth++; - else if (ch == ']' && bracketDepth > 0) - bracketDepth--; - } - - if (parenDepth == 0 && bracketDepth == 0 && !HasPythonLineContinuationBackslash(line)) - break; - } - - header = CreatePythonLogicalHeaderReferenceLine(builder, singlePhysicalLine, singlePhysicalColumn, physicalLines, physicalColumns); - return header.Text.Length > 0; - } - - private static PythonLogicalHeaderReferenceLine CreatePythonLogicalHeaderReferenceLine( - StringBuilder builder, - int singlePhysicalLine, - int singlePhysicalColumn, - List? physicalLines, - List? physicalColumns) - { - if (physicalLines == null || physicalColumns == null) - return new PythonLogicalHeaderReferenceLine(builder.ToString(), singlePhysicalLine, singlePhysicalColumn, null, null); - - return new PythonLogicalHeaderReferenceLine( - builder.ToString(), - singlePhysicalLine, - singlePhysicalColumn, - physicalLines.ToArray(), - physicalColumns.ToArray()); - } - - private static int GetPythonLogicalLineInitialCapacity(string[] lines, int startLineIndex, int startColumn) - { - if (startLineIndex < 0 || startLineIndex >= lines.Length) - return 0; - - return Math.Min(256, Math.Max(0, lines[startLineIndex].Length - startColumn)); - } - - private static bool HasPythonLineContinuationBackslash(string line) - { - for (var index = line.Length - 1; index >= 0; index--) - { - if (char.IsWhiteSpace(line[index])) - continue; - - return line[index] == '\\'; - } - - return false; - } - - private static bool TryAppendPythonLogicalReferenceChar( - StringBuilder builder, - ref int singlePhysicalLine, - ref int singlePhysicalColumn, - ref List? physicalLines, - ref List? physicalColumns, - char value, - int physicalLine, - int physicalColumn, - out PythonLogicalHeaderReferenceLine header) - { - if (builder.Length >= MaxPythonLogicalReferenceLineLength) - { - header = default; - return false; - } - - if (builder.Length == 0) - { - singlePhysicalLine = physicalLine; - singlePhysicalColumn = physicalColumn; - } - else if (physicalLines == null - && (physicalLine != singlePhysicalLine - || physicalColumn != singlePhysicalColumn + builder.Length)) - { - physicalLines = new List(builder.Length + 1); - physicalColumns = new List(builder.Length + 1); - for (var index = 0; index < builder.Length; index++) - { - physicalLines.Add(singlePhysicalLine); - physicalColumns.Add(singlePhysicalColumn + index); - } - } - - builder.Append(value); - if (physicalLines != null) - { - physicalLines.Add(physicalLine); - physicalColumns!.Add(physicalColumn); - } - - header = default; - return true; - } - - private static int FindPythonCommentColumn(string line, int startColumn) - { - var inString = false; - var quote = '\0'; - for (var index = startColumn; index < line.Length; index++) - { - var ch = line[index]; - if (inString) - { - if (ch == '\\') - { - index++; - continue; - } - - if (ch == quote) - inString = false; - continue; - } - - if (ch is '\'' or '"') - { - inString = true; - quote = ch; - continue; - } - - if (ch == '#') - return index; - } - - return line.Length; - } - - private static int FindFirstNonWhitespaceColumn(string line) - { - var index = 0; - while (index < line.Length && char.IsWhiteSpace(line[index])) - index++; - return index; - } - - private static void RemapPythonLogicalHeaderReferences( - List references, - int startIndex, - PythonLogicalHeaderReferenceLine header, - string[] lines) - { - for (var i = startIndex; i < references.Count; i++) - { - var logicalIndex = references[i].Column - 1; - var logicalLength = header.PhysicalLines?.Length ?? header.Text.Length; - if (logicalIndex < 0 || logicalIndex >= logicalLength) - continue; - - var physicalLineIndex = header.SinglePhysicalLine; - var physicalColumn = header.SinglePhysicalColumn + logicalIndex; - if (header.PhysicalLines is { } physicalLines && header.PhysicalColumns is { } physicalColumns) - { - physicalLineIndex = physicalLines[logicalIndex]; - physicalColumn = physicalColumns[logicalIndex]; - } - - if (physicalLineIndex < 0) - continue; - - references[i].Line = physicalLineIndex + 1; - references[i].Column = physicalColumn + 1; - references[i].Context = lines[physicalLineIndex].Trim(); - } - } - - private static ( - IReadOnlyDictionary<(int Line, string Kind), SymbolRecord>? DefinitionContainersByLineAndKind, - IReadOnlyDictionary? HeaderSymbolsByLine) BuildPythonSymbolLookups(IReadOnlyList symbols) - { - Dictionary<(int Line, string Kind), SymbolRecord>? containers = null; - Dictionary? symbolsByLine = null; - foreach (var symbol in symbols) - { - if (symbol.Kind is "class" or "function") - (containers ??= []).TryAdd((symbol.Line, symbol.Kind), symbol); - - if (symbol.Signature == null - || symbol.Kind is not ("function" or "class" or "property" or "class_hook")) - continue; - - (symbolsByLine ??= []).TryAdd(symbol.Line, symbol); - } - - return (containers, symbolsByLine); - } - - private static bool IsJsxFilePath(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - return false; - - var extension = Path.GetExtension(path.AsSpan()); - return extension.Equals(".jsx".AsSpan(), StringComparison.OrdinalIgnoreCase) - || extension.Equals(".tsx".AsSpan(), StringComparison.OrdinalIgnoreCase); - } - - private static bool TrySkipTypeScriptJsxTypeArguments(string preparedLine, ref int scan) - { - if (scan >= preparedLine.Length || preparedLine[scan] != '<') - return false; - - var depth = 0; - while (scan < preparedLine.Length) - { - var ch = preparedLine[scan++]; - if (ch == '\'' || ch == '"') - { - while (scan < preparedLine.Length) - { - var quoted = preparedLine[scan++]; - if (quoted == '\\') - { - scan = Math.Min(scan + 1, preparedLine.Length); - continue; - } - - if (quoted == ch) - break; - } - - continue; - } - - if (ch == '=' && scan < preparedLine.Length && preparedLine[scan] == '>') - { - scan++; - continue; - } - - if (ch == '<') - { - depth++; - } - else if (ch == '>') - { - depth--; - if (depth == 0) - return true; - if (depth < 0) - return false; - } - } - - return false; - } - - private static bool IsRazorFilePath(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - return false; - - var extension = Path.GetExtension(path.AsSpan()); - return extension.Equals(".razor".AsSpan(), StringComparison.OrdinalIgnoreCase) - || extension.Equals(".cshtml".AsSpan(), StringComparison.OrdinalIgnoreCase); - } - - private static bool IsObjCSelectorLiteralCall(string line, string name, int nameIndex) => - string.Equals(NormalizeAtPrefixedIdentifier(name), "selector", StringComparison.Ordinal) - && (name.StartsWith('@') || nameIndex > 0 && line[nameIndex - 1] == '@'); - - /// - /// Emit one `type_reference` row per dot-segment of a captured argument. Columns are - /// computed relative to the original line so tooling can jump to the exact identifier. - /// 捕捉した引数の dot-segment ごとに `type_reference` 行を発行する。列位置は元の行基準で計算する。 - /// - internal static void AddTypeReferenceSegments( - List references, - ReferenceDedupeSet seen, - long fileId, - string arg, - int argStartInLine, - string context, - int lineNumber, - SymbolRecord? container, - string language) - { - int offset = 0; - var segmentStart = 0; - while (segmentStart <= arg.Length) - { - var dotIndex = arg.IndexOf('.', segmentStart); - var segmentLength = dotIndex < 0 ? arg.Length - segmentStart : dotIndex - segmentStart; - if (segmentLength == 0) - { - offset += 1; // '.' separator / ドット区切り分 - if (dotIndex < 0) - break; - segmentStart = dotIndex + 1; - continue; - } - - var segment = arg.Substring(segmentStart, segmentLength); - var normalizedSegment = language == "csharp" ? NormalizeCSharpIdentifier(segment) : segment; - var isEscapedCSharpIdentifier = language == "csharp" && segment[0] == '@'; - if (!IsIgnoredTypeReferenceSegment(language, normalizedSegment, isEscapedCSharpIdentifier)) - { - int column = argStartInLine + offset + 1; // 1-based / 1始まり - var dedupeKey = CreateReferenceDedupeKey(fileId, language, lineNumber, column, "type_reference", normalizedSegment, container); - if (seen.Add(dedupeKey)) - { - if (!TryAddReference( - references, - new ReferenceRecord - { - FileId = fileId, - SymbolName = normalizedSegment, - ReferenceKind = "type_reference", - Line = lineNumber, - Column = column, - Context = context, - ContainerKind = container?.Kind, - ContainerName = container?.Name, - })) - { - return; - } - } - } - - offset += segment.Length + 1; // segment + '.' - if (dotIndex < 0) - break; - segmentStart = dotIndex + 1; - } - } - - private static bool IsIgnoredTypeReferenceSegment(string language, string segment, bool isEscapedCSharpIdentifier = false, IReadOnlySet? ignoredSegments = null) - { - if (isEscapedCSharpIdentifier) - return false; - if (ignoredSegments != null && ignoredSegments.Contains(segment)) - return true; - if (IsIgnoredCallName(language, segment)) - return true; - if (language == "java" && JavaPrimitiveTypeNames.Contains(segment)) - return true; - if (language == "csharp" && CSharpBuiltInTypeNames.Contains(segment)) - return true; - if (LanguageBuiltInTypeNames.TryGetValue(language, out var builtInTypes) - && builtInTypes.Contains(segment)) - { - return true; - } - - return false; - } - - /// - /// Walk the argument list of a C# nameof/typeof/sizeof/default starting at - /// (the char right after `(`). Emits one `type_reference` row - /// per identifier segment while handling generic `<...>`, array `[...]`, - /// parenthesized/tuple groups `(...)`, and `global::` / `Alias::` qualifier skipping so nested - /// paths like `nameof(List<int>.Count)`, `nameof(global::System.String)`, - /// and `typeof((Foo, Bar))` are indexed correctly. - /// C# の nameof/typeof/sizeof/default の引数を `(` 直後から lexer で走査し、 - /// generic `<...>`・配列 `[...]`・タプル `(...)` 群・`global::` / `Alias::` 修飾子を - /// 跨ぎながら識別子セグメントごとに type_reference を発行する。 - /// - private static void ExtractCSharpTypeKeywordSegments( - List references, - ReferenceDedupeSet seen, - long fileId, - string line, - int startIndex, - string context, - int lineNumber, - SymbolRecord? container, - string language, - IReadOnlySet? ignoredSegments = null) - { - int i = startIndex; - int parenDepth = 0; - int angleDepth = 0; - bool expectSegment = true; - while (i < line.Length) - { - char c = line[i]; - if (c == ')') - { - if (parenDepth == 0) - return; - parenDepth--; - i++; - expectSegment = false; - continue; - } - - if (c == ',') - { - if (parenDepth == 0 && angleDepth == 0) - return; - // Tuple or generic argument separator inside `typeof((Foo, Bar))` / - // `typeof(List)` — keep scanning. - // `typeof((Foo, Bar))` のタプル要素区切りや `typeof(List)` - // の generic 引数区切りは続けて走査する。 - i++; - expectSegment = true; - continue; - } - - if (char.IsWhiteSpace(c)) - { - i++; - continue; - } - - if (expectSegment && IsCSharpIdentifierStart(c)) - { - int segStart = i; - if (line[i] == '@') - i++; - while (i < line.Length && IsCSharpIdentifierPart(line[i])) - i++; - var rawSegment = line.Substring(segStart, i - segStart); - var segment = NormalizeCSharpIdentifier(rawSegment); - var isEscapedCSharpIdentifier = rawSegment.Length > 0 && rawSegment[0] == '@'; - // `Alias::Member` — the left-hand side is a namespace alias, not an indexed - // type. Drop it instead of emitting it, and treat what follows the `::` as a - // fresh segment head. - // `Alias::Member` の左辺はエイリアスであり型シンボルではないため発行せず、 - // `::` の右側を新しいセグメント先頭として読み直す。 - if (i + 1 < line.Length && line[i] == ':' && line[i + 1] == ':') - { - i += 2; - expectSegment = true; - continue; - } - - if (ignoredSegments?.Contains(segment) == true) - { - expectSegment = false; - continue; - } - - AddTypeReferenceSegment(references, seen, fileId, segment, segStart, context, lineNumber, container, language, isEscapedCSharpIdentifier); - expectSegment = false; - continue; - } - - if (c == '.') - { - i++; - expectSegment = true; - continue; - } - - if (c == '<') - { - angleDepth++; - i++; - expectSegment = true; - continue; - } - - if (c == '>') - { - if (angleDepth == 0) - return; - angleDepth--; - i++; - expectSegment = false; - continue; - } - - if (c == '[') - { - i = SkipBalanced(line, i, '[', ']'); - continue; - } - - if (c == '(') - { - // Track paren depth instead of skipping the body so tuple/parenthesized - // type groups like `typeof((Foo, Bar))` still yield inner segments. - // タプル型 `typeof((Foo, Bar))` の中身も拾えるよう、括弧はスキップせず - // 深さだけ追跡する。 - parenDepth++; - i++; - expectSegment = true; - continue; - } - - // Unknown token (operator, string start, etc.) — stop scanning this argument. - // 解釈できないトークンが来たら、このキーワード引数の走査を打ち切る。 - return; - } - } - - private static void ExtractCSharpReflectionNameLiteralReferences( - List references, - ReferenceDedupeSet seen, - long fileId, - string preparedLine, - string originalLine, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("Get", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('(') < 0 - || !CSharpReflectionNameApiIntroRegex.IsMatch(preparedLine)) - { - return; - } - - var codeLine = SanitizeCSharpCommentsForReflectionNameScan(originalLine); - foreach (Match match in CSharpReflectionNameApiIntroRegex.Matches(codeLine)) - { - if (IsInsideCSharpStringLiteral(codeLine, match.Index)) - continue; - if (!preparedLine.Contains(match.Groups["name"].Value, StringComparison.Ordinal)) - continue; - - var argStart = match.Index + match.Length; - if (!TryReadCSharpReflectionNameLiteral(originalLine, argStart, out var symbolName, out var nameIndex)) - continue; - if (!IsValidCSharpReflectionSymbolName(symbolName)) - continue; - - AddReference(references, seen, fileId, symbolName, nameIndex, "type_reference", context, lineNumber, container, "csharp"); - } - } - - private static bool TryReadCSharpReflectionNameLiteral(string line, int startIndex, out string symbolName, out int nameIndex) - { - symbolName = string.Empty; - nameIndex = -1; - var builder = new StringBuilder(Math.Min(256, Math.Max(0, line.Length - startIndex))); - var i = startIndex; - var sawLiteral = false; - var firstLiteralIndex = -1; - - while (i < line.Length) - { - SkipWhitespace(line, ref i); - if (!TryReadCSharpStringLiteral(line, ref i, out var value, out var literalContentIndex)) - return false; - - if (!sawLiteral) - firstLiteralIndex = literalContentIndex; - sawLiteral = true; - builder.Append(value); - - SkipWhitespace(line, ref i); - if (i >= line.Length) - return false; - if (line[i] == ',' || line[i] == ')') - { - symbolName = builder.ToString(); - nameIndex = firstLiteralIndex; - return sawLiteral && symbolName.Length > 0; - } - if (line[i] != '+') - return false; - - i++; - } - - return false; - } - - private static string SanitizeCSharpCommentsForReflectionNameScan(string line) - { - char[]? chars = null; - var inRegularString = false; - var inVerbatimString = false; - var inChar = false; - for (var i = 0; i < line.Length; i++) - { - var c = line[i]; - if (inRegularString) - { - if (c == '\\' && i + 1 < line.Length) - i++; - else if (c == '"') - inRegularString = false; - continue; - } - if (inVerbatimString) - { - if (c == '"' && i + 1 < line.Length && line[i + 1] == '"') - i++; - else if (c == '"') - inVerbatimString = false; - continue; - } - if (inChar) - { - if (c == '\\' && i + 1 < line.Length) - i++; - else if (c == '\'') - inChar = false; - continue; - } - - if (c == '/' && i + 1 < line.Length && line[i + 1] == '/') - return line[..i]; - if (c == '/' && i + 1 < line.Length && line[i + 1] == '*') - { - chars ??= line.ToCharArray(); - chars[i] = ' '; - chars[i + 1] = ' '; - i += 2; - while (i < line.Length) - { - chars[i] = ' '; - if (line[i] == '*' && i + 1 < line.Length && line[i + 1] == '/') - { - chars[i + 1] = ' '; - i++; - break; - } - i++; - } - continue; - } - if (c == '@' && i + 1 < line.Length && line[i + 1] == '"') - { - inVerbatimString = true; - i++; - continue; - } - if (c == '$' && i + 1 < line.Length && line[i + 1] == '"') - { - inRegularString = true; - i++; - continue; - } - if (c == '"') - inRegularString = true; - else if (c == '\'') - inChar = true; - } - - return chars == null ? line : new string(chars); - } - - private static bool IsInsideCSharpStringLiteral(string line, int targetIndex) - { - var inRegularString = false; - var inVerbatimString = false; - for (var i = 0; i < line.Length && i < targetIndex; i++) - { - var c = line[i]; - if (inRegularString) - { - if (c == '\\' && i + 1 < line.Length) - i++; - else if (c == '"') - inRegularString = false; - continue; - } - if (inVerbatimString) - { - if (c == '"' && i + 1 < line.Length && line[i + 1] == '"') - i++; - else if (c == '"') - inVerbatimString = false; - continue; - } - - if (c == '@' && i + 1 < line.Length && line[i + 1] == '"') - { - inVerbatimString = true; - i++; - } - else if (c == '$' && i + 1 < line.Length && line[i + 1] == '"') - { - inRegularString = true; - i++; - } - else if (c == '"') - { - inRegularString = true; - } - } - - return inRegularString || inVerbatimString; - } - - private static bool TryReadCSharpStringLiteral(string line, ref int index, out string value, out int contentIndex) - { - value = string.Empty; - contentIndex = -1; - var verbatim = false; - if (index + 1 < line.Length && line[index] == '@' && line[index + 1] == '"') - { - verbatim = true; - index++; - } - else if (index < line.Length && line[index] == '$') - { - return false; - } - - if (index >= line.Length || line[index] != '"') - return false; - - contentIndex = index + 1; - index++; - var builder = new StringBuilder(Math.Min(256, line.Length - contentIndex)); - while (index < line.Length) - { - var c = line[index]; - if (c == '"') - { - if (verbatim && index + 1 < line.Length && line[index + 1] == '"') - { - builder.Append('"'); - index += 2; - continue; - } - - index++; - value = builder.ToString(); - return true; - } - - if (!verbatim && c == '\\' && index + 1 < line.Length) - { - builder.Append(line[index + 1]); - index += 2; - continue; - } - - builder.Append(c); - index++; - } - - return false; - } - - private static void SkipWhitespace(string text, ref int index) - { - while (index < text.Length && char.IsWhiteSpace(text[index])) - index++; - } - - private static bool IsValidCSharpReflectionSymbolName(string symbolName) - { - if (symbolName.Length == 0 || !IsCSharpIdentifierStart(symbolName[0])) - return false; - for (var i = 1; i < symbolName.Length; i++) - { - if (!IsCSharpIdentifierPart(symbolName[i])) - return false; - } - return true; - } - - internal static void EmitCSharpTypePositionReferences( - string preparedLine, - string originalLine, - IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, - IReadOnlyDictionary> csharpQualifiedTypePatternLookup, - IReadOnlyList csharpUsingAliases, - IReadOnlyList csharpUsingStatics, - Func hasActiveSameFileCSharpTypeCandidate, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - SymbolRecord? container, - CSharpWhereConstraintState pendingWhereConstraint, - ref CSharpMultiLineTypePatternState pendingCSharpMultiLineTypePattern) - { - var csharpGenericParameterNames = CollectCSharpGenericParameterNamesForDeclaration(preparedLine); - TryEmitCSharpBaseListReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, csharpGenericParameterNames); - EmitCSharpWhereConstraintReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - csharpGenericParameterNames, - pendingWhereConstraint); - EmitDeclarationTypeReferences("csharp", preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, csharpGenericParameterNames); - - foreach (Match match in CSharpIsAsTypeTestRegex.Matches(preparedLine)) - { - var typeGroup = match.Groups["type"]; - int continuationIndex = SkipWhitespace(preparedLine, typeGroup.Index + typeGroup.Length); - if (TryEmitCSharpLogicalTypePatternHeads( - preparedLine, - typeGroup.Value, - typeGroup.Index, - continuationIndex, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpQualifiedTypePatternLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate, - (logicalTypeExpression, logicalTypeIndex) => AddTypeExpressionSegments( - references, - seen, - fileId, - logicalTypeExpression, - logicalTypeIndex, - context, - lineNumber, - resolveContainerForColumn(logicalTypeIndex), - "csharp", - csharpGenericParameterNames))) - { - continue; - } - - if (IsCSharpNonTypePatternExpression(typeGroup.Value) - || IsCSharpConstantPatternMemberHead( - typeGroup.Value, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate) - || IsCSharpLogicalConstantPatternAtCursor( - preparedLine, - typeGroup.Value, - continuationIndex, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpQualifiedTypePatternLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate)) - { - continue; - } - - AddTypeExpressionSegments( - references, - seen, - fileId, - typeGroup.Value, - typeGroup.Index, - context, - lineNumber, - resolveContainerForColumn(typeGroup.Index), - "csharp", - csharpGenericParameterNames); - } - - EmitCSharpCaseTypePatternReferences( - preparedLine, - originalLine, - csharpQualifiedConstantPatternMemberLookup, - csharpQualifiedTypePatternLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - ref pendingCSharpMultiLineTypePattern); - } - - internal static void AdvanceCSharpMultiLineTypePatternState( - string preparedLine, - string context, - int lineNumber, - Func resolveContainerForColumn, - IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, - IReadOnlyList csharpUsingAliases, - IReadOnlyList csharpUsingStatics, - Func hasActiveSameFileCSharpTypeCandidate, - List references, - ReferenceDedupeSet seen, - long fileId, - ref CSharpMultiLineTypePatternState state) - { - if (!state.WaitingForHead && state.PendingTypeExpression == null) - return; - - var cursor = SkipWhitespace(preparedLine, 0); - if (state.WaitingForHead) - { - if (!TryConsumeCSharpMultiLineTypePatternHead( - preparedLine, - context, - lineNumber, - resolveContainerForColumn, - ref cursor, - ref state)) - { - if (IsStandaloneCSharpMultiLinePatternNegation(preparedLine)) - return; - - state = default; - return; - } - } - else if (!TryConsumeCSharpLogicalPatternKeyword(preparedLine, cursor, out cursor)) - { - FlushPendingCSharpMultiLineTypePatternReference( - ref state, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate, - references, - seen, - fileId); - return; - } - else - { - FlushPendingCSharpMultiLineTypePatternReference( - ref state, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate, - references, - seen, - fileId); - if (!TryConsumeCSharpMultiLineTypePatternHead( - preparedLine, - context, - lineNumber, - resolveContainerForColumn, - ref cursor, - ref state)) - { - state = state with { WaitingForHead = true }; - return; - } - } - - while (TryConsumeCSharpLogicalPatternKeyword( - preparedLine, - SkipWhitespace(preparedLine, state.PendingTypeIndex + state.PendingTypeExpression!.Length), - out cursor)) - { - FlushPendingCSharpMultiLineTypePatternReference( - ref state, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate, - references, - seen, - fileId); - if (!TryConsumeCSharpMultiLineTypePatternHead( - preparedLine, - context, - lineNumber, - resolveContainerForColumn, - ref cursor, - ref state)) - { - state = state with { WaitingForHead = true }; - return; - } - } - } - - private static bool TryConsumeCSharpMultiLineTypePatternHead( - string preparedLine, - string context, - int lineNumber, - Func resolveContainerForColumn, - ref int cursor, - ref CSharpMultiLineTypePatternState state) - { - cursor = SkipWhitespace(preparedLine, cursor); - if (TryConsumeCSharpPatternKeyword(preparedLine, ref cursor, "not")) - cursor = SkipWhitespace(preparedLine, cursor); - - var match = CSharpTypeExpressionAtCursorRegex.Match(preparedLine, cursor); - if (!match.Success) - return false; - - var typeGroup = match.Groups["type"]; - state = new CSharpMultiLineTypePatternState( - WaitingForHead: false, - PendingTypeExpression: typeGroup.Value, - PendingTypeIndex: typeGroup.Index, - PendingTypeLineNumber: lineNumber, - PendingContext: context, - PendingContainer: resolveContainerForColumn(typeGroup.Index)); - cursor = SkipWhitespace(preparedLine, typeGroup.Index + typeGroup.Length); - return true; - } - - internal static void FlushPendingCSharpMultiLineTypePatternReference( - ref CSharpMultiLineTypePatternState state, - IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, - IReadOnlyList csharpUsingAliases, - IReadOnlyList csharpUsingStatics, - Func hasActiveSameFileCSharpTypeCandidate, - List references, - ReferenceDedupeSet seen, - long fileId) - { - if (state.PendingTypeExpression == null || state.PendingContext == null) - { - state = default; - return; - } - - if (!IsCSharpNonTypePatternExpression(state.PendingTypeExpression) - && !IsCSharpConstantPatternMemberHead( - state.PendingTypeExpression, - state.PendingTypeLineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate)) - { - AddTypeExpressionSegments( - references, - seen, - fileId, - state.PendingTypeExpression, - state.PendingTypeIndex, - state.PendingContext, - state.PendingTypeLineNumber, - state.PendingContainer, - "csharp"); - } - - state = default; - } - - private static bool IsStandaloneCSharpMultiLinePatternNegation(string preparedLine) - { - var cursor = SkipWhitespace(preparedLine, 0); - if (!TryConsumeCSharpPatternKeyword(preparedLine, ref cursor, "not")) - return false; - - return SkipWhitespace(preparedLine, cursor) >= preparedLine.Length; - } - - internal static void StartWaitingForCSharpMultiLineTypePatternHead(ref CSharpMultiLineTypePatternState state) - { - state = new CSharpMultiLineTypePatternState( - WaitingForHead: true, - PendingTypeExpression: null, - PendingTypeIndex: 0, - PendingTypeLineNumber: 0, - PendingContext: null, - PendingContainer: null); - } - - private static void EmitCSharpCaseTypePatternReferences( - string preparedLine, - string originalLine, - IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, - IReadOnlyDictionary> csharpQualifiedTypePatternLookup, - IReadOnlyList csharpUsingAliases, - IReadOnlyList csharpUsingStatics, - Func hasActiveSameFileCSharpTypeCandidate, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - ref CSharpMultiLineTypePatternState pendingCSharpMultiLineTypePattern) - { - foreach (Match caseMatch in CSharpCaseLabelRegex.Matches(preparedLine)) - { - int cursor = SkipWhitespace(preparedLine, caseMatch.Index + caseMatch.Length); - bool hadLeadingNot = TryConsumeCSharpPatternKeyword(preparedLine, ref cursor, "not"); - if (hadLeadingNot) - cursor = SkipWhitespace(preparedLine, cursor); - - var typeMatch = CSharpTypeExpressionAtCursorRegex.Match(preparedLine, cursor); - if (!typeMatch.Success) - { - var rawCaseCursor = SkipCSharpTriviaForward(originalLine, caseMatch.Index + caseMatch.Length); - if (TryConsumeLeadingCSharpPatternKeyword(originalLine, ref rawCaseCursor, "not")) - rawCaseCursor = SkipCSharpTriviaForward(originalLine, rawCaseCursor); - - if (HasOnlyTrailingCSharpTrivia(originalLine, rawCaseCursor)) - StartWaitingForCSharpMultiLineTypePatternHead(ref pendingCSharpMultiLineTypePattern); - continue; - } - - var typeGroup = typeMatch.Groups["type"]; - var currentTypeExpression = typeGroup.Value; - var currentTypeIndex = typeGroup.Index; - var currentContinuationIndex = SkipWhitespace(preparedLine, typeGroup.Index + typeGroup.Length); - var sawLogicalKeyword = false; - var waitingForNextHead = false; - - while (TryConsumeCSharpLogicalPatternKeyword(preparedLine, currentContinuationIndex, out var nextHeadCursor)) - { - sawLogicalKeyword = true; - if (!IsCSharpLogicalConstantPatternHead( - preparedLine, - currentTypeExpression, - nextHeadCursor, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpQualifiedTypePatternLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate)) - { - AddTypeExpressionSegments( - references, - seen, - fileId, - currentTypeExpression, - currentTypeIndex, - context, - lineNumber, - resolveContainerForColumn(currentTypeIndex), - "csharp"); - } - - int nextTypeCursor = nextHeadCursor; - if (TryConsumeCSharpPatternKeyword(preparedLine, ref nextTypeCursor, "not")) - nextTypeCursor = SkipWhitespace(preparedLine, nextTypeCursor); - - var nextMatch = CSharpTypeExpressionAtCursorRegex.Match(preparedLine, nextTypeCursor); - if (!nextMatch.Success) - { - var rawNextTypeCursor = SkipCSharpTriviaForward(originalLine, nextHeadCursor); - if (TryConsumeLeadingCSharpPatternKeyword(originalLine, ref rawNextTypeCursor, "not")) - rawNextTypeCursor = SkipCSharpTriviaForward(originalLine, rawNextTypeCursor); - - if (HasOnlyTrailingCSharpTrivia(originalLine, rawNextTypeCursor)) - { - StartWaitingForCSharpMultiLineTypePatternHead(ref pendingCSharpMultiLineTypePattern); - waitingForNextHead = true; - } - break; - } - - var nextTypeGroup = nextMatch.Groups["type"]; - currentTypeExpression = nextTypeGroup.Value; - currentTypeIndex = nextTypeGroup.Index; - currentContinuationIndex = SkipWhitespace(preparedLine, currentTypeIndex + currentTypeExpression.Length); - } - - if (waitingForNextHead) - continue; - - if (sawLogicalKeyword) - { - if (!IsCSharpNonTypePatternExpression(currentTypeExpression) - && !IsCSharpConstantPatternMemberHead( - currentTypeExpression, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate)) - { - AddTypeExpressionSegments( - references, - seen, - fileId, - currentTypeExpression, - currentTypeIndex, - context, - lineNumber, - resolveContainerForColumn(currentTypeIndex), - "csharp"); - } - - continue; - } - - if (!IsCSharpCaseTypePatternContinuation( - preparedLine, - currentTypeExpression, - currentContinuationIndex, - csharpQualifiedConstantPatternMemberLookup, - csharpQualifiedTypePatternLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate, - lineNumber)) - { - continue; - } - - AddTypeExpressionSegments( - references, - seen, - fileId, - currentTypeExpression, - currentTypeIndex, - context, - lineNumber, - resolveContainerForColumn(currentTypeIndex), - "csharp"); - } - } - - private static bool HasOnlyTrailingCSharpTrivia(string text, int cursor) - { - while (cursor < text.Length) - { - if (char.IsWhiteSpace(text[cursor])) - { - cursor++; - continue; - } - - if (cursor + 1 < text.Length - && text[cursor] == '/' - && text[cursor + 1] == '/') - { - return true; - } - - if (cursor + 1 < text.Length - && text[cursor] == '/' - && text[cursor + 1] == '*') - { - var commentEnd = text.IndexOf("*/", cursor + 2, StringComparison.Ordinal); - if (commentEnd < 0) - return true; - - cursor = commentEnd + 2; - continue; - } - - return false; - } - - return true; - } - - private static int SkipCSharpTriviaForward(string text, int cursor) - { - while (cursor < text.Length) - { - if (char.IsWhiteSpace(text[cursor])) - { - cursor++; - continue; - } - - if (cursor + 1 < text.Length - && text[cursor] == '/' - && text[cursor + 1] == '/') - { - return text.Length; - } - - if (cursor + 1 < text.Length - && text[cursor] == '/' - && text[cursor + 1] == '*') - { - var commentEnd = text.IndexOf("*/", cursor + 2, StringComparison.Ordinal); - if (commentEnd < 0) - return text.Length; - - cursor = commentEnd + 2; - continue; - } - - break; - } - - return cursor; - } - - private static bool TryConsumeLeadingCSharpPatternKeyword(string text, ref int cursor, string keyword) - { - if (string.IsNullOrEmpty(keyword)) - return false; - - cursor = SkipCSharpTriviaForward(text, cursor); - if (cursor + keyword.Length > text.Length - || !text.AsSpan(cursor, keyword.Length).Equals(keyword, StringComparison.Ordinal)) - { - return false; - } - - var nextIndex = cursor + keyword.Length; - if (nextIndex < text.Length - && (char.IsLetterOrDigit(text[nextIndex]) || text[nextIndex] == '_')) - { - return false; - } - - cursor = nextIndex; - return true; - } - private sealed class BuiltInReferenceExtractor(string language) : IReferenceExtractor { public string Language { get; } = language; From 3c934eb1061cb99acc43aa780c1dc13f0bc39313 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:14:39 +0900 Subject: [PATCH 040/101] Split reference type analysis phases --- .../ReferenceExtractor.Attributes.cs | 466 +++ .../ReferenceExtractor.ContainerResolution.cs | 575 +++ ...erenceExtractor.DocumentationContainers.cs | 577 +++ .../ReferenceExtractor.GenericInvocations.cs | 563 +++ .../ReferenceExtractor.LinePreparation.cs | 685 +++ .../ReferenceExtractor.PrimaryConstructors.cs | 862 ++++ .../ReferenceExtractor.TypeReferences.cs | 3668 ----------------- 7 files changed, 3728 insertions(+), 3668 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.Attributes.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.ContainerResolution.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.DocumentationContainers.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.GenericInvocations.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.LinePreparation.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.Attributes.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.Attributes.cs new file mode 100644 index 000000000..1e69e3d07 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.Attributes.cs @@ -0,0 +1,466 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static string? TryClassifyMetadataReference( + string language, + string preparedLine, + int nameIndex, + bool insideCSharpAttributeRange) + { + if (language == "csharp") + return insideCSharpAttributeRange ? "attribute" : null; + + if (nameIndex >= 0 + && nameIndex < preparedLine.Length + && preparedLine[nameIndex] == '@' + && AnnotationLanguages.Contains(language)) + { + return "annotation"; + } + + var probe = nameIndex - 1; + while (probe >= 0 && char.IsWhiteSpace(preparedLine[probe])) + probe--; + if (probe < 0) + return null; + + if (AnnotationLanguages.Contains(language)) + return IsAnnotationContext(preparedLine, probe) ? "annotation" : null; + + return null; + } + + /// + /// Build per-line column ranges that identify C# `[...]` attribute sections. Handles + /// declaration-position detection (including parameter attributes preceded by `(` / `,` + /// via forward look-ahead) and multi-line `[\n ... \n]` sections. Each inner list holds + /// ordered `(startColumn, endColumnExclusive)` ranges that are inside an attribute section + /// on that line. Call sites whose name column falls inside one of these ranges are + /// reclassified as `attribute` instead of `call`. + /// C# の `[...]` 属性セクションを行ごとの列範囲で表すテーブルを構築する。 + /// `(` / `,` の直後に置かれるパラメータ属性を forward lookahead で、複数行にわたる + /// `[\n ... \n]` 属性を跨行トラッキングで検出する。各行のリストは属性セクションに含まれる + /// `(開始列, 終端列 (exclusive))` のレンジを保持し、呼び出し名の列がどれかのレンジに含まれる場合に + /// `call` ではなく `attribute` へ再分類する。 + /// + private static (List<(int start, int end)>?[] Ranges, List<(int start, int end)>?[] TopLevelRanges) BuildCSharpAttributeRanges(string[] preparedLines) + { + var perLine = new List<(int start, int end)>?[preparedLines.Length]; + var perLineTopLevel = new List<(int start, int end)>?[preparedLines.Length]; + + // Stack entries capture the opening `[` position, whether that bracket was at + // a C# declaration (attribute) position, and a snapshot of the global paren depth + // at that moment. The snapshot lets us compute an attribute-section-local paren + // depth (`parenDepth - parenDepthAtOpen`), which is what the top-level zone tracking + // uses so that parameter attributes like `void M([Attr] int x)` still have their + // attribute-list top level at section-local depth 0 even though the global depth + // is inside the method's parameter list. + // スタックは `[` の位置、その bracket が属性位置だったか、および開いた瞬間の + // グローバル paren 深さのスナップショットを保持する。スナップショットを使うと + // 属性セクション内ローカルの paren 深さ (`parenDepth - parenDepthAtOpen`) が + // 得られるので、`void M([Attr] int x)` のように外側の method 引数リストの中で + // 開く属性セクションでも、セクション内では top-level (local depth 0) として扱える。 + var bracketStack = new Stack<(int li, int ci, bool isAttr, int parenDepthAtOpen)>(); + char lastMeaningful = '\0'; + int parenDepth = 0; + bool lastClosedBracketWasAttribute = false; + + // Top-level zone tracking: while we are inside an attribute section and the paren + // depth is at the section's open snapshot (section-local depth 0), the current zone + // span is open. When parens open inside the section we close it; when they fully + // close again we reopen. When the attribute section itself closes, we emit the span. + // top-level ゾーン追跡: 属性セクション内かつセクションローカルの paren 深さが 0 の + // あいだだけゾーンを開いておき、セクション内の `(` で閉じ、`)` で再び開く。 + // セクションが閉じる `]` で確定させる。 + int topZoneStartLi = -1; + int topZoneStartCi = 0; + + void EmitTopZone(int endLi, int endCi) + { + if (topZoneStartLi < 0) + return; + for (var l = topZoneStartLi; l <= endLi; l++) + { + int s = (l == topZoneStartLi) ? topZoneStartCi : 0; + int e = (l == endLi) ? endCi : preparedLines[l].Length; + if (e > s) + AddCSharpAttributeRange(perLineTopLevel, l, s, e); + } + topZoneStartLi = -1; + } + + for (var li = 0; li < preparedLines.Length; li++) + { + var line = preparedLines[li]; + for (var ci = 0; ci < line.Length; ci++) + { + var c = line[ci]; + if (c == '/' && ci + 1 < line.Length && line[ci + 1] == '/') + break; + + if (char.IsWhiteSpace(c)) + continue; + + if (c == '(') + { + // If the innermost enclosing bracket is an attribute section and we are + // currently at that section's local top level, close the top-level zone + // just before the `(`. Use the stack top's `parenDepthAtOpen` snapshot so + // parameter attributes inside an outer `(...)` still get their top level + // tracked correctly. + // 直近の `[` が属性セクションで、かつその section-local 深さで top-level のとき、 + // `(` 直前でゾーンを閉じる。外側の `(...)` の中で開く属性セクションにも対応するため、 + // グローバル depth ではなくスタック top の開いたときの snapshot と比較する。 + if (bracketStack.Count > 0) + { + var top = bracketStack.Peek(); + if (top.isAttr && parenDepth == top.parenDepthAtOpen && topZoneStartLi >= 0) + EmitTopZone(li, ci); + } + parenDepth++; + lastMeaningful = c; + continue; + } + if (c == ')') + { + if (parenDepth > 0) + { + parenDepth--; + // If the innermost `[` is an attribute section and we just returned + // to that section's local top level, reopen the top-level zone. + // 直近の `[` が属性セクションで、section-local top-level に戻ってきたら + // top-level ゾーンを再開する。 + if (bracketStack.Count > 0) + { + var top = bracketStack.Peek(); + if (top.isAttr && parenDepth == top.parenDepthAtOpen && topZoneStartLi < 0) + { + topZoneStartLi = li; + topZoneStartCi = ci + 1; + } + } + } + lastMeaningful = c; + continue; + } + + if (c == '[') + { + bool isAttr = EvaluateCSharpAttributePosition( + lastMeaningful, lastClosedBracketWasAttribute, preparedLines, li, ci); + bracketStack.Push((li, ci, isAttr, parenDepth)); + if (isAttr && topZoneStartLi < 0) + { + // Start top-level zone just after the `[` so the `[` itself is not + // inside the zone. Section-local depth is 0 by construction at the + // open bracket. + // `[` 直後から top-level ゾーンを開始する。開いた瞬間は section-local 深さ 0。 + topZoneStartLi = li; + topZoneStartCi = ci + 1; + } + lastMeaningful = c; + continue; + } + + if (c == ']') + { + if (bracketStack.Count > 0) + { + var opened = bracketStack.Pop(); + lastClosedBracketWasAttribute = opened.isAttr; + if (opened.isAttr) + { + // Record the attribute section span for every line it covers so + // cross-line `[\n Foo("x")\n]` also classifies `Foo` as attribute. + // 属性セクションがまたぐ全ての行に対して範囲を記録し、 + // `[\n Foo("x")\n]` のような跨行ケースでも `Foo` が属性として分類されるようにする。 + for (var l = opened.li; l <= li; l++) + { + int s = (l == opened.li) ? opened.ci : 0; + int e = (l == li) ? ci + 1 : preparedLines[l].Length; + AddCSharpAttributeRange(perLine, l, s, e); + } + // Close the top-level zone at the `]`. Section-local depth should + // be 0 here (we are at the closing bracket of this section) — if + // it is not, we drop the open zone because paren balancing was + // malformed. + // `]` で top-level ゾーンを確定する。section-local 深さが 0 のはず。 + // 不整合入力ならゾーンを捨てる。 + if (parenDepth == opened.parenDepthAtOpen) + { + EmitTopZone(li, ci + 1); + } + else + { + topZoneStartLi = -1; + } + } + } + else + { + lastClosedBracketWasAttribute = false; + } + lastMeaningful = c; + continue; + } + + lastMeaningful = c; + } + } + + return (perLine, perLineTopLevel); + } + + private static void AddCSharpAttributeRange( + List<(int start, int end)>?[] rangesByLine, + int lineIndex, + int start, + int end) + { + (rangesByLine[lineIndex] ??= []).Add((start, end)); + } + + /// + /// Decide whether a `[` token sits at a C# attribute position based on the immediately + /// preceding meaningful character. `(` / `,` (parameter attributes) are disambiguated via + /// forward look-ahead because both attributes and C# 12 collection expressions can follow. + /// `[` が C# の属性位置にあるかを、直前の非空白文字から判定する。`(` / `,` の直後は + /// パラメータ属性にも collection expression にもなりうるため、forward lookahead で区別する。 + /// + private static bool EvaluateCSharpAttributePosition( + char lastMeaningful, + bool lastClosedBracketWasAttribute, + string[] preparedLines, + int startLi, + int startCi) + { + // Start of file or after a scope/statement boundary — attribute position. + // ファイル先頭、あるいはスコープ・文境界の直後は属性位置。 + if (lastMeaningful is '\0' or '{' or '}' or ';') + return true; + + // Chained attribute list `[A][B]`: the prior `]` must have closed an attribute section. + // `arr[i][Compute()]` → the prior `]` closed an indexer, so stays `call`. + // 連続した属性リスト `[A][B]` は、直前の `]` が属性セクションを閉じていたときのみ属性扱い。 + // `arr[i][Compute()]` の `]` は indexer を閉じているため `call` のまま。 + if (lastMeaningful == ']') + return lastClosedBracketWasAttribute; + + // Parameter / type-parameter / lambda attribute candidates (`(`, `,`, `<`, `=`): + // `void M([Attr] T x)`, `class C<[Attr] T>`, `var f = [Attr] () => body`, or + // `Consume([Make()])`. Disambiguate by scanning forward to the matching `]` and + // checking whether the next meaningful token begins a declaration (identifier / + // `@` / `(` for tuple types or lambda parameter lists / `[` chained). + // パラメータ / 型パラメータ / ラムダ属性候補 (`(`, `,`, `<`, `=`) は + // `void M([Attr] T x)`・`class C<[Attr] T>`・`var f = [Attr] () => body`・ + // `Consume([Make()])` いずれにもなりうる。対応する `]` まで進んで次トークンが + // 宣言やラムダを開始するか(識別子 / `@` / tuple・ラムダ仮引数の `(` / chained `[`)で区別する。 + if (lastMeaningful is '(' or ',' or '<' or '=') + return IsCSharpAttributeFollowedByDeclaration(preparedLines, startLi, startCi); + + return false; + } + + /// + /// Keywords that indicate the preceding `[...]` is an expression (collection / pattern / + /// switch target) rather than an attribute section when they appear after `]`. + /// `]` の直後に現れると、直前の `[...]` が属性ではなく式(collection / pattern / switch 対象) + /// であることを示す C# のキーワード集合。 + /// + private static readonly HashSet CSharpExpressionContinuationKeywords = new(StringComparer.Ordinal) + { + "is", "as", "switch", "with", "when", + }; + + /// + /// Scan forward from a `[` to its matching `]` (skipping balanced parens) and return true + /// when the next meaningful character begins an identifier-like token. Works across lines so + /// `void M(\n [Attr]\n T x\n)` is recognized as a parameter attribute. + /// `[` から対応する `]` まで進んで、`]` の次の非空白文字が識別子を始める場合に true を返す。 + /// 行を跨ぐ走査にも対応しているため `void M(\n [Attr]\n T x\n)` も属性として認識される。 + /// + private static bool IsCSharpAttributeFollowedByDeclaration(string[] preparedLines, int startLi, int startCi) + { + var bracketDepth = 1; + var parenDepth = 0; + var li = startLi; + var ci = startCi + 1; + while (li < preparedLines.Length) + { + var line = preparedLines[li]; + while (ci < line.Length) + { + var c = line[ci]; + if (c == '/' && ci + 1 < line.Length && line[ci + 1] == '/' && parenDepth == 0) + break; + + if (c == '(') + { + parenDepth++; + ci++; + continue; + } + if (c == ')') + { + if (parenDepth > 0) + parenDepth--; + ci++; + continue; + } + if (parenDepth > 0) + { + ci++; + continue; + } + if (c == '[') + { + bracketDepth++; + ci++; + continue; + } + if (c == ']') + { + bracketDepth--; + if (bracketDepth == 0) + { + ci++; + return NextTokenStartsDeclaration(preparedLines, li, ci); + } + ci++; + continue; + } + ci++; + } + li++; + ci = 0; + } + return false; + } + + /// + /// After the closing `]` of a candidate `[...]`, inspect the next meaningful token to decide + /// whether it begins a declaration. Accepts identifiers (except expression-continuation + /// keywords like `is` / `as` / `switch` / `with` / `when`), leading `@` (verbatim identifier), + /// `(` (tuple-typed parameter), and chained `[` (recurse for `[A][B]`). + /// 閉じ `]` の直後のトークンで宣言が始まるかを判定する。識別子(式継続の `is` / `as` / + /// `switch` / `with` / `when` は除外)、`@`(verbatim 識別子)、`(`(tuple パラメータ型)、 + /// `[`(`[A][B]` の連結)を受け入れる。 + /// + private static bool NextTokenStartsDeclaration(string[] preparedLines, int li, int ci) + { + while (li < preparedLines.Length) + { + var line = preparedLines[li]; + while (ci < line.Length && char.IsWhiteSpace(line[ci])) + ci++; + if (ci < line.Length) + { + var first = line[ci]; + if (first == '@' || first == '(') + return true; + if (first == '[') + return IsCSharpAttributeFollowedByDeclaration(preparedLines, li, ci); + if (!IsIdentifierChar(first)) + return false; + var start = ci; + while (ci < line.Length && IsIdentifierChar(line[ci])) + ci++; + var token = line.Substring(start, ci - start); + return !CSharpExpressionContinuationKeywords.Contains(token); + } + li++; + ci = 0; + } + return false; + } + + private static bool IsInsideCSharpAttributeRange(IReadOnlyList<(int start, int end)> ranges, int index) + { + for (var i = 0; i < ranges.Count; i++) + { + var (start, end) = ranges[i]; + if (index >= start && index < end) + return true; + } + return false; + } + + private static bool IsAnnotationContext(string line, int probe) + { + // `@Annotation(args)` — direct marker. 直接 `@Annotation(args)` の場合。 + if (line[probe] == '@') + return true; + + // `@module.Annotation(args)` — walk past the dotted qualifier chain first so that + // both `@module.Annotation` and `@field:com.example.Annotation` land the probe on + // either `@` or the Kotlin use-site target `:`. + // `@module.Annotation(args)` や `@field:com.example.Annotation(args)` のように修飾子が + // 付く場合も対応するため、先にドット区切り修飾子チェーンを剥がしてから `@` または + // Kotlin の use-site target `:` を判定する。 + while (probe >= 0 && line[probe] == '.') + { + probe--; + while (probe >= 0 && IsIdentifierChar(line[probe])) + probe--; + while (probe >= 0 && char.IsWhiteSpace(line[probe])) + probe--; + } + + if (probe < 0) + return false; + + if (line[probe] == '@') + return true; + + // Kotlin use-site target: `@field:Deprecated("msg")` or + // `@field:com.example.Deprecated("msg")`. After unwinding the dotted qualifier, the + // probe lands on `:`; walk past the target identifier and confirm `@`. + // Kotlin の use-site target `@field:Deprecated("msg")` や + // `@field:com.example.Deprecated("msg")` では、ドット修飾子を剥がしたあと probe が `:` + // に着地するため、target 識別子を読み飛ばして `@` を確認する。 + if (line[probe] == ':') + { + var j = probe - 1; + var idEnd = j; + while (j >= 0 && IsIdentifierChar(line[j])) + j--; + if (j + 1 <= idEnd) + { + var target = line[(j + 1)..(idEnd + 1)]; + if (KotlinAnnotationTargets.Contains(target)) + { + var k = j; + while (k >= 0 && char.IsWhiteSpace(line[k])) + k--; + if (k >= 0 && line[k] == '@') + return true; + } + } + } + + return false; + } + + private static bool UsesHashComments(string lang) => + lang is "python" or "ruby" or "perl" or "php" or "elixir" or "r" or "powershell" + or "shell" or "makefile" or "terraform" or "dockerfile" or "protobuf" + or "nim" or "julia" or "cython"; + + private static bool UsesSlashComments(string lang) => + lang is not "python" and not "ruby" and not "r" and not "haskell" + and not "makefile" and not "terraform" and not "dockerfile" + and not "css" and not "fortran" and not "crystal" and not "tcl" + and not "prolog" and not "ambiguous_pl" and not "nim" and not "matlab" + and not "julia" and not "cython" and not "ada"; + + private static bool UsesDashDashComments(string lang) => + lang is "lua" or "sql" or "haskell" or "ada"; + + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.ContainerResolution.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.ContainerResolution.cs new file mode 100644 index 000000000..d15db5889 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.ContainerResolution.cs @@ -0,0 +1,575 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + internal static void AddTypeReferenceSegment( + List references, + ReferenceDedupeSet seen, + long fileId, + string segment, + int startInLine, + string context, + int lineNumber, + SymbolRecord? container, + string language, + bool isEscapedCSharpIdentifier = false, + IReadOnlySet? ignoredSegments = null, + string referenceKind = "type_reference") + { + if (segment.Length == 0 || IsIgnoredTypeReferenceSegment(language, segment, isEscapedCSharpIdentifier, ignoredSegments)) + return; + + int column = startInLine + 1; // 1-based / 1始まり + var dedupeKey = CreateReferenceDedupeKey(fileId, language, lineNumber, column, referenceKind, segment, container); + if (!seen.Add(dedupeKey)) + return; + + TryAddReference(references, new ReferenceRecord + { + FileId = fileId, + SymbolName = segment, + ReferenceKind = referenceKind, + Line = lineNumber, + Column = column, + Context = context, + ContainerKind = container?.Kind, + ContainerName = container?.Name, + }); + } + + private static SymbolRecord? FindInnermostContainer(IReadOnlyList candidates, int lineNumber) + { + foreach (var candidate in candidates) + { + if (candidate.BodyStartLine!.Value <= lineNumber && candidate.BodyEndLine!.Value >= lineNumber) + return candidate; + } + + return null; + } + + internal sealed class InnermostContainerResolver + { + private readonly IReadOnlyList candidates; + private readonly List<(SymbolRecord Symbol, int SpanLength, int OriginalIndex)>? candidatesByStart; + private SortedSet? activeContainers; + private int nextCandidateIndex; + private int currentLine; + private int? cachedLine; + private SymbolRecord? cachedContainer; + + internal InnermostContainerResolver(IReadOnlyList candidates) + { + this.candidates = candidates; + if (candidates.Count == 0) + return; + + candidatesByStart = new List<(SymbolRecord Symbol, int SpanLength, int OriginalIndex)>(candidates.Count); + for (var index = 0; index < candidates.Count; index++) + { + var symbol = candidates[index]; + candidatesByStart.Add((symbol, GetContainerSpanLength(symbol), index)); + } + + candidatesByStart.Sort(CompareCandidatesByStart); + } + + internal SymbolRecord? Find(int lineNumber) + { + if (cachedLine == lineNumber) + return cachedContainer; + + if (candidatesByStart == null) + return Cache(lineNumber, null); + + if (lineNumber < currentLine) + return Cache(lineNumber, FindInnermostContainer(candidates, lineNumber)); + + AdvanceTo(lineNumber); + return Cache(lineNumber, activeContainers is not { Count: > 0 } ? null : activeContainers.Min.Symbol); + } + + private void AdvanceTo(int lineNumber) + { + if (candidatesByStart == null) + { + currentLine = lineNumber; + return; + } + + while (nextCandidateIndex < candidatesByStart.Count + && candidatesByStart[nextCandidateIndex].Symbol.BodyStartLine!.Value <= lineNumber) + { + var candidate = candidatesByStart[nextCandidateIndex]; + (activeContainers ??= []).Add(new ActiveContainer(candidate.Symbol, candidate.SpanLength, candidate.OriginalIndex)); + nextCandidateIndex++; + } + + activeContainers?.RemoveWhere(active => active.Symbol.BodyEndLine!.Value < lineNumber); + currentLine = lineNumber; + } + + private SymbolRecord? Cache(int lineNumber, SymbolRecord? container) + { + cachedLine = lineNumber; + cachedContainer = container; + return container; + } + + private static int GetContainerSpanLength(SymbolRecord symbol) => + (symbol.BodyEndLine ?? symbol.EndLine) - (symbol.BodyStartLine ?? symbol.StartLine); + + private static int CompareCandidatesByStart( + (SymbolRecord Symbol, int SpanLength, int OriginalIndex) left, + (SymbolRecord Symbol, int SpanLength, int OriginalIndex) right) + { + var compare = left.Symbol.BodyStartLine!.Value.CompareTo(right.Symbol.BodyStartLine!.Value); + if (compare != 0) + return compare; + + compare = left.Symbol.BodyEndLine!.Value.CompareTo(right.Symbol.BodyEndLine!.Value); + if (compare != 0) + return compare; + + compare = left.SpanLength.CompareTo(right.SpanLength); + if (compare != 0) + return compare; + + return left.OriginalIndex.CompareTo(right.OriginalIndex); + } + + private readonly record struct ActiveContainer(SymbolRecord Symbol, int SpanLength, int OriginalIndex) : IComparable + { + public int CompareTo(ActiveContainer other) + { + var spanComparison = SpanLength.CompareTo(other.SpanLength); + if (spanComparison != 0) + return spanComparison; + + return OriginalIndex.CompareTo(other.OriginalIndex); + } + } + } + + private static bool CanAttachCSharpXmlDocCommentToNextDeclaration( + SymbolRecord? innermostContainer, + IReadOnlyList? scopeCandidates, + IReadOnlyList?>? csharpAttrRanges, + string[] preparedLines, + int lineNumber, + SymbolRecord documentedContainer) + { + if (!HasOnlyCSharpWhitespaceOrAttributesBetweenCommentAndDeclaration( + csharpAttrRanges, + preparedLines, + lineNumber, + documentedContainer.StartLine)) + { + return false; + } + + if (innermostContainer != null + && innermostContainer.Kind is not "class" or "struct" or "interface" or "enum" or "namespace") + { + return false; + } + + var enclosingScope = scopeCandidates == null + ? null + : FindInnermostContainer(scopeCandidates, lineNumber); + if (enclosingScope?.BodyStartLine == null) + return true; + + return IsAtCSharpXmlDocAttachmentDepth(enclosingScope, preparedLines, lineNumber); + } + + private static bool HasOnlyCSharpWhitespaceOrAttributesBetweenCommentAndDeclaration( + IReadOnlyList?>? csharpAttrRanges, + string[] preparedLines, + int commentLineNumber, + int declarationLineNumber) + { + var startLineIndex = Math.Max(commentLineNumber, 0); + var endLineIndex = Math.Min(declarationLineNumber - 1, preparedLines.Length); + for (var lineIndex = startLineIndex; lineIndex < endLineIndex; lineIndex++) + { + var line = preparedLines[lineIndex]; + if (string.IsNullOrWhiteSpace(line)) + continue; + + if (IsCSharpAttributeOnlyLine(line, csharpAttrRanges?[lineIndex])) + continue; + + return false; + } + + return true; + } + + private static bool IsCSharpAttributeOnlyLine(string preparedLine, IReadOnlyList<(int start, int end)>? ranges) + { + if (ranges == null || ranges.Count == 0) + return false; + + for (var i = 0; i < preparedLine.Length; i++) + { + if (char.IsWhiteSpace(preparedLine[i])) + continue; + + var covered = false; + foreach (var (start, end) in ranges) + { + if (i >= start && i < end) + { + covered = true; + break; + } + } + + if (!covered) + return false; + } + + return true; + } + + private static bool IsAtCSharpXmlDocAttachmentDepth( + SymbolRecord enclosingScope, + string[] preparedLines, + int lineNumber) + { + var scopeBodyStartIndex = enclosingScope.BodyStartLine!.Value - 1; + var commentLineIndex = lineNumber - 1; + if (scopeBodyStartIndex < 0 + || scopeBodyStartIndex >= preparedLines.Length + || scopeBodyStartIndex >= commentLineIndex) + { + return true; + } + + var sawScopeOpenBrace = false; + var nestedBraceDepth = 0; + var angleDepth = 0; + var parenDepth = 0; + var bracketDepth = 0; + var topLevelExecutableContinuation = false; + var topLevelArrowExpressionContinuation = false; + + for (var i = scopeBodyStartIndex; i < commentLineIndex && i < preparedLines.Length; i++) + { + var line = preparedLines[i]; + for (var j = 0; j < line.Length; j++) + { + var ch = line[j]; + if (!sawScopeOpenBrace) + { + if (ch == '{') + sawScopeOpenBrace = true; + + continue; + } + + if (nestedBraceDepth == 0) + { + if (ch == '<') + { + angleDepth++; + continue; + } + + if (ch == '>' && angleDepth > 0) + { + angleDepth--; + continue; + } + + if (IsCSharpTopLevelArrowToken(line, j)) + { + topLevelExecutableContinuation = true; + topLevelArrowExpressionContinuation = !IsCSharpArrowBlockStart(line, j + 2); + j++; + continue; + } + + if (IsCSharpTopLevelAssignmentOperator(line, j)) + { + topLevelExecutableContinuation = true; + } + } + + if (ch == '{') + { + nestedBraceDepth++; + } + else if (ch == '}') + { + if (nestedBraceDepth == 0) + return false; + + nestedBraceDepth--; + } + else if (ch == '(') + { + parenDepth++; + } + else if (ch == ')' && parenDepth > 0) + { + parenDepth--; + } + else if (ch == '[') + { + bracketDepth++; + } + else if (ch == ']' && bracketDepth > 0) + { + bracketDepth--; + } + else if (nestedBraceDepth == 0 + && ch == ';' + && parenDepth == 0 + && bracketDepth == 0) + { + topLevelExecutableContinuation = false; + topLevelArrowExpressionContinuation = false; + } + } + } + + return !sawScopeOpenBrace + || (nestedBraceDepth == 0 + && angleDepth == 0 + && parenDepth == 0 + && bracketDepth == 0 + && !topLevelExecutableContinuation + && !topLevelArrowExpressionContinuation); + } + + private static (bool[] MultilineStringContent, bool[] BlockComment) BuildCSharpLineStateMasks(string[] lines) + { + var insideStringContent = new bool[lines.Length]; + var insideBlockComment = new bool[lines.Length]; + var inBlockComment = false; + var inVerbatimString = false; + var rawStringDelimiterLength = 0; + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + insideStringContent[i] = inVerbatimString || rawStringDelimiterLength > 0; + insideBlockComment[i] = inBlockComment; + + var index = 0; + while (index < line.Length) + { + if (inBlockComment) + { + var closeIndex = line.IndexOf("*/", index, StringComparison.Ordinal); + if (closeIndex < 0) + break; + + index = closeIndex + 2; + inBlockComment = false; + continue; + } + + if (rawStringDelimiterLength > 0) + { + var closeCandidateIndex = index; + while (closeCandidateIndex < line.Length && char.IsWhiteSpace(line[closeCandidateIndex])) + closeCandidateIndex++; + + var closeLength = CountCharacterRun(line, closeCandidateIndex, '"'); + if (closeLength >= rawStringDelimiterLength + && closeLength > 0) + { + rawStringDelimiterLength = 0; + index = closeCandidateIndex + closeLength; + continue; + } + + break; + } + + if (inVerbatimString) + { + if (line[index] == '"' && index + 1 < line.Length && line[index + 1] == '"') + { + index += 2; + continue; + } + + if (line[index] == '"') + { + index++; + inVerbatimString = false; + continue; + } + + index++; + continue; + } + + if (StartsWithOrdinal(line, index, "//")) + break; + + if (StartsWithOrdinal(line, index, "/*")) + { + inBlockComment = true; + index += 2; + continue; + } + + if (TryStartCSharpRawString(line, index, out var rawOpeningLength, out var rawDelimiterLength)) + { + rawStringDelimiterLength = rawDelimiterLength; + index += rawOpeningLength; + continue; + } + + if (TryStartCSharpVerbatimString(line, index, out var verbatimOpeningLength)) + { + inVerbatimString = true; + index += verbatimOpeningLength; + continue; + } + + if (TryStartCSharpRegularString(line, index, out var regularOpeningLength)) + { + index += regularOpeningLength; + while (index < line.Length) + { + if (line[index] == '\\') + { + index += Math.Min(2, line.Length - index); + continue; + } + + if (line[index] == '"') + { + index++; + break; + } + + index++; + } + + continue; + } + + if (line[index] == '\'') + { + index++; + while (index < line.Length) + { + if (line[index] == '\\') + { + index += Math.Min(2, line.Length - index); + continue; + } + + if (line[index] == '\'') + { + index++; + break; + } + + index++; + } + + continue; + } + + index++; + } + } + + return (insideStringContent, insideBlockComment); + } + + private static bool IsCSharpTopLevelAssignmentOperator(string line, int index) + { + if (index < 0 || index >= line.Length || line[index] != '=') + return false; + + var previous = index > 0 ? line[index - 1] : '\0'; + var next = index + 1 < line.Length ? line[index + 1] : '\0'; + return previous is not ('=' or '!' or '<' or '>') + && next is not ('=' or '>'); + } + + private static bool IsCSharpTopLevelArrowToken(string line, int index) => + index >= 0 + && index + 1 < line.Length + && line[index] == '=' + && line[index + 1] == '>'; + + private static bool IsCSharpArrowBlockStart(string line, int index) + { + while (index < line.Length && char.IsWhiteSpace(line[index])) + index++; + + return index < line.Length && line[index] == '{'; + } + + private static int GetCSharpSameLineDocumentedDeclarationStartColumn( + string originalLine, + int commentEndExclusive, + bool nextDelimitedDocComment) + { + if (nextDelimitedDocComment + || commentEndExclusive < 0 + || commentEndExclusive + 1 >= originalLine.Length + || originalLine[commentEndExclusive] != '*' + || originalLine[commentEndExclusive + 1] != '/') + { + return -1; + } + + var column = commentEndExclusive + 2; + while (column < originalLine.Length && char.IsWhiteSpace(originalLine[column])) + column++; + + return column < originalLine.Length ? column : -1; + } + + private static bool HasOnlyCSharpWhitespaceOrAttributesAfterColumn( + string preparedLine, + IReadOnlyList<(int start, int end)>? ranges, + int startColumn) + { + if (startColumn < 0 || startColumn >= preparedLine.Length) + return true; + + for (var i = startColumn; i < preparedLine.Length; i++) + { + if (char.IsWhiteSpace(preparedLine[i])) + continue; + + if (ranges != null) + { + var covered = false; + foreach (var (start, end) in ranges) + { + if (i >= start && i < end) + { + covered = true; + break; + } + } + + if (covered) + continue; + } + + return false; + } + + return true; + } + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.DocumentationContainers.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.DocumentationContainers.cs new file mode 100644 index 000000000..176a83a41 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.DocumentationContainers.cs @@ -0,0 +1,577 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static bool TryGetJvmDocCommentSpan( + string originalLine, + bool inDelimitedDocComment, + out int commentStart, + out int commentEndExclusive, + out int sameLineDeclarationStartColumn, + out bool nextDelimitedDocComment) + { + commentStart = -1; + commentEndExclusive = -1; + sameLineDeclarationStartColumn = -1; + nextDelimitedDocComment = inDelimitedDocComment; + + var lineStart = 0; + while (lineStart < originalLine.Length && char.IsWhiteSpace(originalLine[lineStart])) + lineStart++; + + if (!inDelimitedDocComment) + { + if (lineStart + 3 > originalLine.Length + || originalLine[lineStart] != '/' + || originalLine[lineStart + 1] != '*' + || originalLine[lineStart + 2] != '*') + { + return false; + } + + commentStart = lineStart + 3; + } + else + { + commentStart = lineStart; + if (commentStart < originalLine.Length && originalLine[commentStart] == '*') + { + if (commentStart + 1 < originalLine.Length && originalLine[commentStart + 1] == '/') + { + commentEndExclusive = commentStart; + nextDelimitedDocComment = false; + sameLineDeclarationStartColumn = GetJvmSameLineDeclarationStartColumn(originalLine, commentStart); + return true; + } + + commentStart++; + if (commentStart < originalLine.Length && originalLine[commentStart] == ' ') + commentStart++; + } + } + + var closeIndex = originalLine.IndexOf("*/", commentStart, StringComparison.Ordinal); + if (closeIndex >= 0) + { + commentEndExclusive = closeIndex; + nextDelimitedDocComment = false; + sameLineDeclarationStartColumn = GetJvmSameLineDeclarationStartColumn(originalLine, closeIndex); + } + else + { + commentEndExclusive = originalLine.Length; + nextDelimitedDocComment = true; + } + + return true; + } + + private static int GetJvmSameLineDeclarationStartColumn(string originalLine, int commentEndExclusive) + { + if (commentEndExclusive + 1 >= originalLine.Length + || originalLine[commentEndExclusive] != '*' + || originalLine[commentEndExclusive + 1] != '/') + { + return -1; + } + + var column = commentEndExclusive + 2; + while (column < originalLine.Length && char.IsWhiteSpace(originalLine[column])) + column++; + + return column < originalLine.Length ? column : -1; + } + + private static SymbolRecord? FindJvmDocumentedContainer( + IReadOnlyList candidates, + IReadOnlyList originalLines, + string structuralLine, + int lineNumber, + int sameLineDeclarationStartColumn) + { + var innermostContainer = FindInnermostContainer(candidates, lineNumber); + if (innermostContainer?.Kind is "function" or "property") + return null; + + var sameLineCandidate = FindSameLineDocumentedContainer( + candidates, + structuralLine, + lineNumber, + sameLineDeclarationStartColumn); + if (sameLineCandidate != null) + return sameLineCandidate; + + SymbolRecord? best = null; + foreach (var candidate in candidates) + { + if (candidate.StartLine <= lineNumber) + continue; + if (!HasOnlyJvmDocTriviaBeforeDeclaration(originalLines, lineNumber, candidate.StartLine)) + continue; + + if (best == null + || candidate.StartLine < best.StartLine + || (candidate.StartLine == best.StartLine + && ((candidate.BodyEndLine ?? candidate.EndLine) - (candidate.BodyStartLine ?? candidate.StartLine)) + < ((best.BodyEndLine ?? best.EndLine) - (best.BodyStartLine ?? best.StartLine)))) + { + best = candidate; + } + } + + return best; + } + + private static bool HasOnlyJvmDocTriviaBeforeDeclaration( + IReadOnlyList originalLines, + int docLineNumber, + int declarationLineNumber) + { + for (var lineIndex = docLineNumber; lineIndex < declarationLineNumber - 1 && lineIndex < originalLines.Count; lineIndex++) + { + var trimmed = originalLines[lineIndex].TrimStart(); + if (trimmed.Length == 0 + || trimmed.StartsWith("/**", StringComparison.Ordinal) + || trimmed.StartsWith("*", StringComparison.Ordinal) + || trimmed.StartsWith("@", StringComparison.Ordinal)) + { + continue; + } + + return false; + } + + return true; + } + + private static SymbolRecord? FindDocumentedContainer( + IReadOnlyList candidates, + string structuralLine, + string preparedLine, + IReadOnlyList<(int start, int end)>? csharpAttrRangesOnLine, + int lineNumber, + int sameLineDeclarationStartColumn) + { + var sameLineCandidate = FindSameLineDocumentedContainer( + candidates, + structuralLine, + lineNumber, + sameLineDeclarationStartColumn); + if (sameLineCandidate != null) + return sameLineCandidate; + if (sameLineDeclarationStartColumn >= 0 + && !HasOnlyCSharpWhitespaceOrAttributesAfterColumn( + preparedLine, + csharpAttrRangesOnLine, + sameLineDeclarationStartColumn)) + { + return null; + } + + SymbolRecord? best = null; + foreach (var candidate in candidates) + { + if (candidate.StartLine <= lineNumber) + continue; + + if (best == null + || candidate.StartLine < best.StartLine + || (candidate.StartLine == best.StartLine + && ((candidate.BodyEndLine ?? candidate.EndLine) - (candidate.BodyStartLine ?? candidate.StartLine)) + < ((best.BodyEndLine ?? best.EndLine) - (best.BodyStartLine ?? best.StartLine)))) + { + best = candidate; + } + } + + return best; + } + + private static SymbolRecord? FindSameLineDocumentedContainer( + IReadOnlyList candidates, + string structuralLine, + int lineNumber, + int sameLineDeclarationStartColumn) + { + if (sameLineDeclarationStartColumn < 0) + return null; + + SymbolRecord? best = null; + var bestStartColumn = int.MaxValue; + var bestSpanLength = int.MaxValue; + var bestKindRank = int.MaxValue; + + foreach (var candidate in candidates) + { + if (candidate.StartLine != lineNumber + || candidate.EndLine != lineNumber + || string.IsNullOrEmpty(candidate.Signature)) + { + continue; + } + + if (!TryGetSameLineSignatureSpan(candidate, structuralLine, out var startColumn, out var endColumn) + || startColumn < sameLineDeclarationStartColumn) + { + continue; + } + + var spanLength = endColumn - startColumn; + var kindRank = GetSameLineContainerKindRank(candidate.Kind); + if (best == null + || startColumn < bestStartColumn + || (startColumn == bestStartColumn && spanLength < bestSpanLength) + || (startColumn == bestStartColumn && spanLength == bestSpanLength && kindRank < bestKindRank)) + { + best = candidate; + bestStartColumn = startColumn; + bestSpanLength = spanLength; + bestKindRank = kindRank; + } + } + + return best; + } + + private static SymbolRecord? FindInnermostSameLineCSharpContainer( + IReadOnlyList candidates, + string structuralLine, + int lineNumber, + int column) + { + SymbolRecord? best = null; + var bestStartColumn = -1; + var bestSpanLength = int.MaxValue; + var bestKindRank = int.MaxValue; + + foreach (var candidate in candidates) + { + if (candidate.BodyStartLine == null + || candidate.BodyEndLine == null + || candidate.BodyStartLine.Value > lineNumber + || candidate.BodyEndLine.Value < lineNumber + || candidate.StartLine != lineNumber + || candidate.EndLine != lineNumber + || string.IsNullOrEmpty(candidate.Signature)) + { + continue; + } + + if (!TryGetSameLineSignatureSpan(candidate, structuralLine, out var startColumn, out var endColumn)) + continue; + + if (column < startColumn || column >= endColumn) + continue; + + if (candidate.Kind == "function" + && (!TryFindCSharpFunctionNameColumn(structuralLine, candidate.Name, out var nameColumn) + || column < nameColumn)) + { + continue; + } + + var spanLength = endColumn - startColumn; + var kindRank = GetSameLineContainerKindRank(candidate.Kind); + if (best == null + || startColumn > bestStartColumn + || (startColumn == bestStartColumn && spanLength < bestSpanLength) + || (startColumn == bestStartColumn && spanLength == bestSpanLength && kindRank < bestKindRank)) + { + best = candidate; + bestStartColumn = startColumn; + bestSpanLength = spanLength; + bestKindRank = kindRank; + } + } + + return best; + } + + private static Dictionary>? BuildCSharpSameLineContainerCandidatesByLine( + string language, + IReadOnlyList candidates) + { + if (language != "csharp") + return null; + + Dictionary>? candidatesByLine = null; + foreach (var candidate in candidates) + { + if (candidate.BodyStartLine == null + || candidate.BodyEndLine == null + || candidate.StartLine != candidate.EndLine + || string.IsNullOrEmpty(candidate.Signature)) + { + continue; + } + + candidatesByLine ??= new Dictionary>(); + if (!candidatesByLine.TryGetValue(candidate.StartLine, out var lineCandidates)) + { + lineCandidates = []; + candidatesByLine.Add(candidate.StartLine, lineCandidates); + } + + lineCandidates.Add(candidate); + } + + return candidatesByLine; + } + + private static SymbolRecord? FindInnermostSameLineCSharpContainer( + IReadOnlyDictionary>? candidatesByLine, + string structuralLine, + int lineNumber, + int column) + => candidatesByLine != null && candidatesByLine.TryGetValue(lineNumber, out var candidates) + ? FindInnermostSameLineCSharpContainer(candidates, structuralLine, lineNumber, column) + : null; + + private static SymbolRecord? FindInnermostCSharpDeclarationRangeContainer( + IReadOnlyList candidates, + string structuralLine, + int lineNumber, + int column) + { + SymbolRecord? best = null; + var bestRange = int.MaxValue; + + foreach (var candidate in candidates) + { + if (candidate.Kind != "function" + || candidate.BodyStartLine == null + || candidate.BodyEndLine == null + || candidate.StartLine > lineNumber + || candidate.BodyStartLine.Value < lineNumber + || candidate.BodyEndLine.Value < lineNumber) + { + continue; + } + + if (candidate.StartLine == lineNumber + && (!TryFindCSharpFunctionNameColumn(structuralLine, candidate.Name, out var nameColumn) + || column < nameColumn)) + { + continue; + } + + var range = candidate.BodyEndLine.Value - candidate.StartLine; + if (best == null || range < bestRange) + { + best = candidate; + bestRange = range; + } + } + + return best; + } + + private static bool TryFindCSharpFunctionNameColumn(string structuralLine, string? name, out int column) + { + column = -1; + if (string.IsNullOrWhiteSpace(structuralLine) || string.IsNullOrWhiteSpace(name)) + return false; + + var searchStart = 0; + while (searchStart < structuralLine.Length) + { + var index = structuralLine.IndexOf(name, searchStart, StringComparison.Ordinal); + if (index < 0) + return false; + + var before = index - 1; + if (before >= 0 && IsTypeExpressionIdentifierPart("csharp", structuralLine[before])) + { + searchStart = index + name.Length; + continue; + } + + var afterName = index + name.Length; + if (afterName < structuralLine.Length && IsTypeExpressionIdentifierPart("csharp", structuralLine[afterName])) + { + searchStart = afterName; + continue; + } + + var after = SkipWhitespace(structuralLine, afterName); + if (after < structuralLine.Length && structuralLine[after] == '<') + { + var genericClose = FindMatchingChar(structuralLine, after, '<', '>'); + if (genericClose > after) + after = SkipWhitespace(structuralLine, genericClose + 1); + } + + if (after < structuralLine.Length && structuralLine[after] == '(') + { + column = index; + return true; + } + + searchStart = afterName; + } + + return false; + } + + private static bool TryGetSameLineSignatureSpan( + SymbolRecord candidate, + string structuralLine, + out int startColumn, + out int endColumn) + { + startColumn = candidate.StartColumn ?? -1; + if (startColumn < 0 || startColumn > structuralLine.Length) + { + startColumn = FindSignatureOccurrenceStartColumn( + structuralLine, + candidate.Signature!, + candidate.SameLineSignatureOccurrenceIndex ?? 0); + if (startColumn < 0) + { + endColumn = -1; + return false; + } + } + + endColumn = Math.Min(structuralLine.Length, startColumn + candidate.Signature!.Length); + return endColumn > startColumn; + } + + private static int FindSignatureOccurrenceStartColumn(string structuralLine, string signature, int occurrenceIndex) + { + if (occurrenceIndex < 0 || string.IsNullOrEmpty(structuralLine) || string.IsNullOrEmpty(signature)) + return -1; + + var currentOccurrence = 0; + var searchStart = 0; + while (searchStart < structuralLine.Length) + { + var matchIndex = structuralLine.IndexOf(signature, searchStart, StringComparison.Ordinal); + if (matchIndex < 0) + return -1; + + if (currentOccurrence == occurrenceIndex) + return matchIndex; + + currentOccurrence++; + searchStart = matchIndex + signature.Length; + } + + return -1; + } + + private static bool TryStartCSharpRawString( + string line, + int startIndex, + out int openingLength, + out int delimiterLength) + { + openingLength = 0; + delimiterLength = 0; + + var quoteIndex = startIndex; + while (quoteIndex < line.Length && line[quoteIndex] == '$') + quoteIndex++; + + delimiterLength = CountCharacterRun(line, quoteIndex, '"'); + if (delimiterLength < 3) + return false; + + openingLength = (quoteIndex - startIndex) + delimiterLength; + return true; + } + + private static bool TryStartCSharpVerbatimString(string line, int startIndex, out int openingLength) + { + openingLength = 0; + if (StartsWithOrdinal(line, startIndex, "$@\"") || StartsWithOrdinal(line, startIndex, "@$\"")) + { + openingLength = 3; + return true; + } + + if (!StartsWithOrdinal(line, startIndex, "@\"")) + return false; + + openingLength = 2; + return true; + } + + private static bool TryStartCSharpRegularString(string line, int startIndex, out int openingLength) + { + openingLength = 0; + if (StartsWithOrdinal(line, startIndex, "$\"")) + { + openingLength = 2; + return true; + } + + if (line[startIndex] != '"') + return false; + + openingLength = 1; + return true; + } + + private static bool StartsWithOrdinal(string line, int startIndex, string value) + { + if (startIndex + value.Length > line.Length) + return false; + + return string.Compare(line, startIndex, value, 0, value.Length, StringComparison.Ordinal) == 0; + } + + private static int CountCharacterRun(string line, int startIndex, char value) + { + var index = startIndex; + while (index < line.Length && line[index] == value) + index++; + + return index - startIndex; + } + + private static int GetSameLineContainerKindRank(string? kind) => kind switch + { + "function" => 0, + "property" => 1, + "class" => 2, + "struct" => 3, + "interface" => 4, + "enum" => 5, + "namespace" => 6, + _ => 7, + }; + + internal static SymbolRecord? FindInnermostClassLike(IReadOnlyList candidates, int lineNumber) + { + foreach (var candidate in candidates) + { + // class/struct/enum are all ctor-owner kinds across supported languages. Java enum bodies + // can declare constructors and chain via `this(...)`; C# enum cannot declare constructors + // at all, so the chain regex will not match inside one even if we pick it up here. + // class/struct/enum はいずれもコンストラクタを持ちうる宿主種別。Java enum は `this(...)` + // 連鎖を書けるため含める。C# enum はコンストラクタ自体を持てないので副作用は出ない。 + if (candidate.Kind != "class" && candidate.Kind != "struct" && candidate.Kind != "enum") + continue; + if (candidate.BodyStartLine!.Value <= lineNumber && candidate.BodyEndLine!.Value >= lineNumber) + return candidate; + } + + return null; + } + + /// + /// Same-line Java ctor span capturing the declarator name plus the 0-based indices of the + /// ctor name, the opening `{` of the body, and the matching `}` on the same line (or -1 + /// when no matching close brace is found). Used to override the container for body-level + /// calls and to suppress the bogus declarator self-call on the ctor name. + /// same-line Java ctor の宣言情報。ctor 名位置・body `{` 位置・body `}` 位置を保持し、 + /// body 内の call に合成 function コンテナを流すのと、宣言子 `CtorName(` が誤って + /// call として記録されるのを抑止するのに使う。 + /// +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.GenericInvocations.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.GenericInvocations.cs new file mode 100644 index 000000000..00387b4c1 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.GenericInvocations.cs @@ -0,0 +1,563 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static bool IsIgnoredCallName(string language, string name) + { + if (LanguageSpecificCallNameKeeps.TryGetValue(language, out var languageSpecificKeepNames) + && languageSpecificKeepNames.Contains(name)) + { + return false; + } + + if (language == "php") + { + if (SharedIgnoredCallNamesCaseInsensitive.Contains(name)) + return true; + } + else if (SharedIgnoredCallNames.Contains(name)) + { + return true; + } + + return LanguageSpecificIgnoredCallNames.TryGetValue(language, out var languageSpecificIgnoredNames) + && languageSpecificIgnoredNames.Contains(name); + } + + private static bool IsConstructorCallName(string language, string preparedLine, int nameIndex) + { + var probe = nameIndex - 1; + while (probe >= 0 && char.IsWhiteSpace(preparedLine[probe])) + probe--; + + if (probe < 0) + return false; + + while (probe >= 0) + { + char? separator = null; + if (probe >= 1 && preparedLine[probe] == ':' && preparedLine[probe - 1] == ':') + { + separator = ':'; + probe -= 2; + } + else if (preparedLine[probe] is '.' or '\\') + { + separator = preparedLine[probe]; + probe--; + } + + if (separator == null) + break; + + if (separator != '\\') + { + while (probe >= 0 && char.IsWhiteSpace(preparedLine[probe])) + probe--; + } + + var segmentEnd = probe; + while (probe >= 0 && IsIdentifierChar(preparedLine[probe])) + probe--; + + var consumedSegment = segmentEnd >= 0 && segmentEnd >= probe + 1; + if (!consumedSegment && separator != '\\') + return false; + } + + while (probe >= 0 && char.IsWhiteSpace(preparedLine[probe])) + probe--; + + if (probe < 0) + return false; + + var tokenEnd = probe; + while (probe >= 0 && IsIdentifierChar(preparedLine[probe])) + probe--; + + var tokenStart = probe + 1; + if (tokenStart > tokenEnd) + return false; + + var token = preparedLine[tokenStart..(tokenEnd + 1)]; + return language == "php" + ? string.Equals(token, "new", StringComparison.OrdinalIgnoreCase) + : string.Equals(token, "new", StringComparison.Ordinal); + } + + private static readonly HashSet KotlinTypeProjectionModifierNames = new(StringComparer.Ordinal) + { + "in", "out", + }; + + private readonly record struct NestedGenericCallCandidate(string Name, int NameIndex); + + private static void EmitGenericInvocationTypeArgumentReferences( + string language, + string preparedLine, + int nameIndex, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (language is not ("csharp" or "java" or "kotlin")) + return; + + if (!TryGetPostNameGenericInvocationTypeArgumentSpan(preparedLine, nameIndex, out var argumentsStart, out var argumentsLength) + && (language != "java" + || !TryGetJavaExplicitGenericInvocationTypeArgumentSpan(preparedLine, nameIndex, out argumentsStart, out argumentsLength))) + { + return; + } + + if (argumentsLength <= 0) + return; + + var ignoredSegments = language == "kotlin" + ? KotlinTypeProjectionModifierNames + : null; + var argumentsExpression = preparedLine.Substring(argumentsStart, argumentsLength); + + AddTypeExpressionSegments( + references, + seen, + fileId, + argumentsExpression, + argumentsStart, + context, + lineNumber, + container, + language, + ignoredSegments); + AddGenericInvocationTypeArgumentSegments( + references, + seen, + fileId, + argumentsExpression, + argumentsStart, + context, + lineNumber, + container, + language, + ignoredSegments); + } + + private static void AddGenericInvocationTypeArgumentSegments( + List references, + ReferenceDedupeSet seen, + long fileId, + string expression, + int expressionStartInLine, + string context, + int lineNumber, + SymbolRecord? container, + string language, + IReadOnlySet? ignoredSegments) + { + if (language != "csharp") + return; + + for (var i = 0; i < expression.Length; i++) + { + if (!IsTypeExpressionIdentifierStart(language, expression[i])) + continue; + + var segmentStart = i; + if (expression[i] == '@') + i++; + while (i < expression.Length && IsTypeExpressionIdentifierPart(language, expression[i])) + i++; + + var segmentLength = i - segmentStart; + var isEscapedCSharpIdentifier = segmentLength > 0 && expression[segmentStart] == '@'; + var segment = isEscapedCSharpIdentifier + ? expression.Substring(segmentStart + 1, segmentLength - 1) + : expression.Substring(segmentStart, segmentLength); + if (i + 1 < expression.Length && expression[i] == ':' && expression[i + 1] == ':') + { + i++; + continue; + } + + AddTypeReferenceSegment( + references, + seen, + fileId, + segment, + expressionStartInLine + segmentStart, + context, + lineNumber, + container, + language, + isEscapedCSharpIdentifier, + ignoredSegments, + "generic_type_argument"); + i--; + } + } + + private static bool TryGetPostNameGenericInvocationTypeArgumentSpan( + string preparedLine, + int nameIndex, + out int argumentsStart, + out int argumentsLength) + { + argumentsStart = -1; + argumentsLength = 0; + + if (nameIndex < 0 || nameIndex >= preparedLine.Length || !IsAtAwareAsciiIdentifierStart(preparedLine, nameIndex)) + return false; + + var scan = ConsumeAtAwareAsciiIdentifier(preparedLine, nameIndex); + if (scan + 1 < preparedLine.Length + && preparedLine[scan] == '?' + && preparedLine[scan + 1] == '.') + { + scan += 2; + } + + if (scan >= preparedLine.Length || preparedLine[scan] != '<') + return false; + + var closeAngle = FindMatchingChar(preparedLine, scan, '<', '>'); + if (closeAngle <= scan) + return false; + + var after = closeAngle + 1; + while (after < preparedLine.Length && char.IsWhiteSpace(preparedLine[after])) + after++; + + if (after >= preparedLine.Length || preparedLine[after] != '(') + return false; + + argumentsStart = scan + 1; + argumentsLength = closeAngle - scan - 1; + return true; + } + + private static bool TryGetJavaExplicitGenericInvocationTypeArgumentSpan( + string preparedLine, + int nameIndex, + out int argumentsStart, + out int argumentsLength) + { + argumentsStart = -1; + argumentsLength = 0; + + var closeAngle = nameIndex - 1; + while (closeAngle >= 0 && char.IsWhiteSpace(preparedLine[closeAngle])) + closeAngle--; + + if (closeAngle < 0 || preparedLine[closeAngle] != '>') + return false; + + var openAngle = FindMatchingOpenChar(preparedLine, closeAngle, '<', '>'); + if (openAngle < 0) + return false; + + var beforeOpen = openAngle - 1; + while (beforeOpen >= 0 && char.IsWhiteSpace(preparedLine[beforeOpen])) + beforeOpen--; + + if (beforeOpen < 0 || preparedLine[beforeOpen] != '.') + return false; + + argumentsStart = openAngle + 1; + argumentsLength = closeAngle - openAngle - 1; + return true; + } + + private static int FindMatchingOpenChar(string text, int closeIndex, char openChar, char closeChar) + { + if (closeIndex < 0 || closeIndex >= text.Length || text[closeIndex] != closeChar) + return -1; + + var depth = 0; + for (var i = closeIndex; i >= 0; i--) + { + if (text[i] == closeChar) + { + depth++; + continue; + } + + if (text[i] != openChar) + continue; + + depth--; + if (depth == 0) + return i; + } + + return -1; + } + + private static IEnumerable EnumerateNestedGenericCallCandidates( + string preparedLine, + HashSet matchedCallIndices) + { + for (var i = 0; i < preparedLine.Length; i++) + { + if (!IsAtAwareAsciiIdentifierStart(preparedLine, i)) + continue; + if (i > 0 && (IsIdentifierChar(preparedLine[i - 1]) || preparedLine[i - 1] == '$' || preparedLine[i - 1] == '@')) + continue; + + var nameStart = i; + i = ConsumeAtAwareAsciiIdentifier(preparedLine, i); + + if (matchedCallIndices.Contains(nameStart)) + { + i--; + continue; + } + + var scan = i; + if (scan + 1 < preparedLine.Length + && preparedLine[scan] == '?' + && preparedLine[scan + 1] == '.') + { + scan += 2; + } + + if (scan >= preparedLine.Length || preparedLine[scan] != '<') + { + i--; + continue; + } + + if (!TrySkipBalancedGenericArgs(preparedLine, ref scan, out var sawNestedGeneric) || !sawNestedGeneric) + { + i--; + continue; + } + + while (scan < preparedLine.Length && char.IsWhiteSpace(preparedLine[scan])) + scan++; + + if (scan < preparedLine.Length && preparedLine[scan] == '(') + yield return new NestedGenericCallCandidate(preparedLine[nameStart..i], nameStart); + + i--; + } + } + + private static IEnumerable EnumerateNestedGenericInitializerCandidates( + string preparedLine, + HashSet matchedInitializerIndices, + bool requireOpeningBrace) + { + for (var i = 0; i < preparedLine.Length; i++) + { + if (!IsStandaloneNewKeyword(preparedLine, i)) + continue; + + var scan = i + 3; + if (!TryReadQualifiedTypeName(preparedLine, ref scan, out var name, out var nameIndex)) + { + i += 2; + continue; + } + + if (matchedInitializerIndices.Contains(nameIndex)) + { + i = scan - 1; + continue; + } + + if (!TrySkipBalancedGenericArgs(preparedLine, ref scan, out var sawNestedGeneric) || !sawNestedGeneric) + { + i = scan - 1; + continue; + } + + if (!TrySkipArraySuffixes(preparedLine, ref scan)) + { + i = scan - 1; + continue; + } + + while (scan < preparedLine.Length && char.IsWhiteSpace(preparedLine[scan])) + scan++; + + if (requireOpeningBrace) + { + if (scan < preparedLine.Length && preparedLine[scan] == '{') + yield return new NestedGenericCallCandidate(name, nameIndex); + } + else if (scan == preparedLine.Length) + { + yield return new NestedGenericCallCandidate(name, nameIndex); + } + + i = scan - 1; + } + } + + private static bool TryReadQualifiedTypeName( + string preparedLine, + ref int scan, + out string name, + out int nameIndex) + { + name = string.Empty; + nameIndex = -1; + + while (true) + { + while (scan < preparedLine.Length && char.IsWhiteSpace(preparedLine[scan])) + scan++; + + if (scan >= preparedLine.Length || !IsAtAwareAsciiIdentifierStart(preparedLine, scan)) + return false; + + var segmentStart = scan; + scan = ConsumeAtAwareAsciiIdentifier(preparedLine, scan); + + name = preparedLine[segmentStart..scan]; + nameIndex = segmentStart; + + var separatorScan = scan; + while (separatorScan < preparedLine.Length && char.IsWhiteSpace(preparedLine[separatorScan])) + separatorScan++; + + if (separatorScan + 1 < preparedLine.Length + && preparedLine[separatorScan] == ':' + && preparedLine[separatorScan + 1] == ':') + { + scan = separatorScan + 2; + continue; + } + + if (separatorScan < preparedLine.Length && preparedLine[separatorScan] == '.') + { + scan = separatorScan + 1; + continue; + } + + scan = separatorScan; + return true; + } + } + + private static bool TrySkipArraySuffixes(string preparedLine, ref int scan) + { + while (true) + { + while (scan < preparedLine.Length && char.IsWhiteSpace(preparedLine[scan])) + scan++; + + if (scan >= preparedLine.Length || preparedLine[scan] != '[') + return true; + + scan++; + while (scan < preparedLine.Length && preparedLine[scan] != ']') + scan++; + + if (scan >= preparedLine.Length || preparedLine[scan] != ']') + return false; + + scan++; + } + } + + private static bool ShouldSkipInitializerName(string language, string name) => + (language == "csharp" && CSharpBuiltInTypeNames.Contains(name)) + || (language == "java" && JavaPrimitiveTypeNames.Contains(name)) + || IsIgnoredCallName(language, name); + + private static bool IsStandaloneNewKeyword(string preparedLine, int index) + { + if (index < 0 || index + 3 > preparedLine.Length) + return false; + if (preparedLine[index] != 'n' + || preparedLine[index + 1] != 'e' + || preparedLine[index + 2] != 'w') + { + return false; + } + + if (index > 0 && IsIdentifierChar(preparedLine[index - 1])) + return false; + + return index + 3 >= preparedLine.Length || !IsIdentifierChar(preparedLine[index + 3]); + } + + private static bool TrySkipBalancedGenericArgs(string preparedLine, ref int scan, out bool sawNestedGeneric) + { + sawNestedGeneric = false; + if (scan >= preparedLine.Length || preparedLine[scan] != '<') + return false; + + var depth = 0; + while (scan < preparedLine.Length) + { + var ch = preparedLine[scan++]; + if (ch == '<') + { + depth++; + if (depth > 1) + sawNestedGeneric = true; + } + else if (ch == '>') + { + depth--; + if (depth == 0) + return true; + if (depth < 0) + return false; + } + } + + return false; + } + + private static bool IsAsciiIdentifierStartChar(char ch) => + ch == '_' || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); + + private static bool IsAtAwareAsciiIdentifierStart(string text, int index) + { + if (index < 0 || index >= text.Length) + return false; + + if (text[index] == '@') + return index + 1 < text.Length && IsAsciiIdentifierStartChar(text[index + 1]); + + return IsAsciiIdentifierStartChar(text[index]); + } + + private static int ConsumeAtAwareAsciiIdentifier(string text, int startIndex) + { + var index = startIndex; + if (index < text.Length && text[index] == '@') + index++; + + if (index >= text.Length || !IsAsciiIdentifierStartChar(text[index])) + return startIndex; + + index++; + while (index < text.Length && IsIdentifierChar(text[index])) + index++; + + return index; + } + + private static bool IsIdentifierChar(char ch) => + char.IsLetterOrDigit(ch) || ch == '_'; + + /// + /// Classify a call-looking identifier as an attribute/annotation when it appears inside + /// a C# `[...]` attribute list or is preceded by a Java-family `@` marker. Returns null + /// for ordinary method calls so the caller emits the default `call` reference kind. + /// 呼び出しに見える識別子を、C# の `[...]` 属性リスト内や Java 系 `@` 付き注釈に該当する + /// 場合に専用の reference kind へ分類する。通常の呼び出しは null を返して既定の `call` を維持する。 + /// +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.LinePreparation.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.LinePreparation.cs new file mode 100644 index 000000000..c01125fb3 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.LinePreparation.cs @@ -0,0 +1,685 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private readonly record struct ReferenceLinePrepareOptions( + bool UseCSharpTriggerFastPath, + bool MaskRustLifetimes, + bool MaskStringLiterals, + bool PreserveStringLiteralWidth, + bool MaskNimRawStrings, + bool IncludeBacktickStringDelimiter, + bool PreserveStringLiteralLength, + bool PreservePostfixSingleQuotes, + bool UseMatlabStringRules, + bool ScientificStringUsesBackslashEscapes, + bool UsesHashComments, + bool UsesRHashComments, + bool UsesSlashComments, + bool UsesDashDashComments, + bool UsesPercentComments, + bool UsesFortranBangComments, + bool UsesPascalBlockComments, + bool UsesVisualBasicComments); + + private static ReferenceLinePrepareOptions CreateReferenceLinePrepareOptions(string lang) + => new( + UseCSharpTriggerFastPath: lang == "csharp", + MaskRustLifetimes: lang == "rust", + MaskStringLiterals: lang != "cobol", + PreserveStringLiteralWidth: lang is "crystal" or "groovy" or "prolog" or "ambiguous_pl", + MaskNimRawStrings: lang == "nim", + IncludeBacktickStringDelimiter: lang is not ("kotlin" or "r"), + PreserveStringLiteralLength: ScientificNativeReferenceExtractor.Supports(lang), + PreservePostfixSingleQuotes: lang is "ada" or "julia" or "matlab", + UseMatlabStringRules: lang == "matlab", + ScientificStringUsesBackslashEscapes: lang is "cython" or "d" or "julia" or "nim" or "objc", + UsesHashComments: UsesHashComments(lang), + UsesRHashComments: lang == "r", + UsesSlashComments: UsesSlashComments(lang), + UsesDashDashComments: UsesDashDashComments(lang), + UsesPercentComments: lang == "matlab", + UsesFortranBangComments: lang == "fortran", + UsesPascalBlockComments: lang == "pascal", + UsesVisualBasicComments: lang == "vb"); + + private static string PrepareLine(string lang, string line) + => PrepareLine(line, CreateReferenceLinePrepareOptions(lang)); + + private static string PrepareLine(string line, ReferenceLinePrepareOptions options) + { + if (line.Length == 0) + return line; + + if (options.UseCSharpTriggerFastPath && line.IndexOfAny(CSharpReferenceLinePreparationTriggerChars) < 0) + return line; + + var result = line; + if (options.MaskRustLifetimes) + result = MaskRustLifetimeTokens(result); + if (options.MaskNimRawStrings) + result = ScientificNativeCommentMasker.MaskNimRawStringLiterals(result); + if (options.MaskStringLiterals && MayContainStringLiteralDelimiter(result, options.IncludeBacktickStringDelimiter)) + { + if (options.PreserveStringLiteralLength) + { + result = ScientificNativeCommentMasker.MaskLineStringLiteralsPreservingPostfixSingleQuotes( + result, + options.UseMatlabStringRules, + options.ScientificStringUsesBackslashEscapes, + options.PreservePostfixSingleQuotes); + } + else + { + var stringLiteralRegex = !options.IncludeBacktickStringDelimiter + ? NonBacktickStringLiteralRegex + : StringLiteralRegex; + result = options.PreserveStringLiteralWidth + ? stringLiteralRegex.Replace(result, static match => new string(' ', match.Length)) + : stringLiteralRegex.Replace(result, "\"\""); + } + } + if (result.Contains("/*", StringComparison.Ordinal)) + result = InlineBlockCommentRegex.Replace(result, " "); + + if (options.UsesHashComments) + { + var hashIndex = options.UsesRHashComments + ? FindRHashCommentStart(result) + : result.IndexOf('#'); + if (hashIndex >= 0) + result = result[..hashIndex]; + } + + if (options.UsesSlashComments) + { + var slashIndex = result.IndexOf("//", StringComparison.Ordinal); + if (slashIndex >= 0) + result = result[..slashIndex]; + } + + // Lua, SQL, Haskell use -- for line comments / Lua、SQL、Haskell は -- を行コメントに使う + if (options.UsesDashDashComments) + { + var dashCommentIndex = result.IndexOf("--", StringComparison.Ordinal); + if (dashCommentIndex >= 0) + result = result[..dashCommentIndex]; + } + + if (options.UsesPercentComments) + { + // Outside strings, MATLAB treats `...` and the rest of the physical line as a + // continuation comment. MATLAB では文字列外の `...` 以降は継続コメントになる。 + var continuationIndex = result.IndexOf("...", StringComparison.Ordinal); + if (continuationIndex >= 0) + result = result[..continuationIndex]; + + var percentCommentIndex = result.IndexOf('%'); + if (percentCommentIndex >= 0) + result = result[..percentCommentIndex]; + } + + if (options.UsesFortranBangComments) + { + var bangCommentIndex = result.IndexOf('!'); + if (bangCommentIndex >= 0) + result = result[..bangCommentIndex]; + } + + if (options.UsesPascalBlockComments) + { + result = PascalBraceCommentRegex.Replace(result, " "); + result = PascalParenStarCommentRegex.Replace(result, " "); + } + + // VB.NET uses Rem and ' for line comments / VB.NET は Rem と ' を行コメントに使う + if (options.UsesVisualBasicComments) + { + var remCommentMatch = VisualBasicRemCommentRegex.Match(result); + if (remCommentMatch.Success) + result = result[..remCommentMatch.Index]; + + var vbCommentIndex = result.IndexOf('\''); + if (vbCommentIndex >= 0) + result = result[..vbCommentIndex]; + } + + return result; + } + + private static bool MayContainStringLiteralDelimiter(string line, bool includeBacktick) + => includeBacktick + ? line.AsSpan().IndexOfAny('"', '\'', '`') >= 0 + : line.AsSpan().IndexOfAny('"', '\'') >= 0; + + private static int FindRHashCommentStart(string line) + { + var inBacktickIdentifier = false; + for (var i = 0; i < line.Length; i++) + { + var ch = line[i]; + if (inBacktickIdentifier && ch == '\\' && i + 1 < line.Length) + { + i++; + continue; + } + + if (ch == '`') + { + inBacktickIdentifier = !inBacktickIdentifier; + continue; + } + + if (ch == '#' && !inBacktickIdentifier) + return i; + } + + return -1; + } + + private static string MaskRustLifetimeTokens(string line) + { + var quoteIndex = line.IndexOf('\''); + if (quoteIndex < 0) + return line; + + char[]? chars = null; + for (var index = quoteIndex; index + 1 < line.Length; index++) + { + if (line[index] != '\'') + continue; + + var next = line[index + 1]; + if (next != '_' && !char.IsLetter(next)) + continue; + + var end = index + 2; + while (end < line.Length && IsJavaIdentifierPart(line[end])) + end++; + + if (end == index + 2 && end < line.Length && line[end] == '\'') + continue; + + chars ??= line.ToCharArray(); + for (var maskIndex = index; maskIndex < end; maskIndex++) + chars[maskIndex] = ' '; + + index = end - 1; + } + + return chars is null ? line : new string(chars); + } + + private static string[] MaskPascalBlockCommentLines(IReadOnlyList lines) + { + if (lines is string[] lineArray && !MayContainPascalBlockComment(lines)) + return lineArray; + + var result = new string[lines.Count]; + var inBraceComment = false; + var inParenStarComment = false; + + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + var line = lines[lineIndex]; + char[]? chars = null; + + void MaskAt(int index) => + (chars ??= line.ToCharArray())[index] = ' '; + + var cursor = 0; + + while (cursor < line.Length) + { + if (inBraceComment) + { + var closes = line[cursor] == '}'; + MaskAt(cursor++); + if (closes) + inBraceComment = false; + continue; + } + + if (inParenStarComment) + { + if (line[cursor] == '*' && cursor + 1 < line.Length && line[cursor + 1] == ')') + { + MaskAt(cursor++); + MaskAt(cursor++); + inParenStarComment = false; + continue; + } + + MaskAt(cursor++); + continue; + } + + if (line[cursor] == '\'') + { + cursor++; + while (cursor < line.Length) + { + if (line[cursor] == '\'') + { + cursor++; + if (cursor < line.Length && line[cursor] == '\'') + { + cursor++; + continue; + } + break; + } + + cursor++; + } + continue; + } + + if (line[cursor] == '{') + { + MaskAt(cursor++); + inBraceComment = true; + continue; + } + + if (line[cursor] == '(' && cursor + 1 < line.Length && line[cursor + 1] == '*') + { + MaskAt(cursor++); + MaskAt(cursor++); + inParenStarComment = true; + continue; + } + + cursor++; + } + + result[lineIndex] = chars is null ? line : new string(chars); + } + + return result; + } + + private static bool MayContainPascalBlockComment(IReadOnlyList lines) + { + foreach (var line in lines) + { + if (line.Contains('{') || line.Contains("(*", StringComparison.Ordinal)) + return true; + } + + return false; + } + + private static bool UsesCStyleBlockComments(string language) => + language is "c" + or "cpp" + or "cuda" + or "glsl" + or "hlsl" + or "metal" + or "wgsl" + or "go" + or "objc" + or "dart"; + + private static string[] MaskCStyleBlockCommentLines(string language, IReadOnlyList lines) + { + if (lines is string[] lineArray && !MayContainCStyleMaskingTrigger(language, lines)) + return lineArray; + + var result = new string[lines.Count]; + var blockCommentDepth = 0; + var inGoRawString = false; + char dartTripleQuote = '\0'; + string? cppRawStringTerminator = null; + + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + var line = lines[lineIndex]; + char[]? chars = null; + + void MaskAt(int index) => + (chars ??= line.ToCharArray())[index] = ' '; + + void MaskRange(int start, int endExclusive) + { + var masked = chars ??= line.ToCharArray(); + for (var index = start; index < endExclusive; index++) + masked[index] = ' '; + } + + var cursor = 0; + while (cursor < line.Length) + { + if (blockCommentDepth > 0) + { + MaskAt(cursor); + if (language == "wgsl" + && line[cursor] == '/' + && cursor + 1 < line.Length + && line[cursor + 1] == '*') + { + MaskAt(cursor + 1); + blockCommentDepth++; + cursor += 2; + continue; + } + + if (line[cursor] == '*' && cursor + 1 < line.Length && line[cursor + 1] == '/') + { + MaskAt(cursor + 1); + blockCommentDepth--; + cursor += 2; + continue; + } + + cursor++; + continue; + } + + if (inGoRawString) + { + MaskAt(cursor); + if (line[cursor] == '`') + inGoRawString = false; + cursor++; + continue; + } + + if (dartTripleQuote != '\0') + { + if (IsTripleQuoteAt(line, cursor, dartTripleQuote)) + { + MaskRange(cursor, cursor + 3); + dartTripleQuote = '\0'; + cursor += 3; + continue; + } + + MaskAt(cursor); + cursor++; + continue; + } + + if (cppRawStringTerminator != null) + { + var closeIndex = line.IndexOf(cppRawStringTerminator, cursor, StringComparison.Ordinal); + if (closeIndex < 0) + { + MaskRange(cursor, line.Length); + break; + } + + MaskRange(cursor, closeIndex + cppRawStringTerminator.Length); + cursor = closeIndex + cppRawStringTerminator.Length; + cppRawStringTerminator = null; + continue; + } + + if (line[cursor] == '/' && cursor + 1 < line.Length && line[cursor + 1] == '/') + break; + + if (language == "go" && line[cursor] == '`') + { + MaskAt(cursor); + inGoRawString = true; + cursor++; + continue; + } + + if (language == "dart" && TryGetDartTripleStringStart(line, cursor, out var dartQuote, out var dartOpeningLength)) + { + var closeIndex = IndexOfTripleQuote(line, cursor + dartOpeningLength, dartQuote); + if (closeIndex < 0) + { + MaskRange(cursor, line.Length); + dartTripleQuote = dartQuote; + break; + } + + MaskRange(cursor, closeIndex + 3); + cursor = closeIndex + 3; + continue; + } + + if (language == "cpp" && TryGetCppRawStringTerminator(line, cursor, out var rawTerminator, out var rawOpeningLength)) + { + var closeIndex = line.IndexOf(rawTerminator, cursor + rawOpeningLength, StringComparison.Ordinal); + if (closeIndex < 0) + { + MaskRange(cursor, line.Length); + cppRawStringTerminator = rawTerminator; + break; + } + + MaskRange(cursor, closeIndex + rawTerminator.Length); + cursor = closeIndex + rawTerminator.Length; + continue; + } + + if (line[cursor] is '"' or '\'' or '`') + { + cursor = SkipCStyleQuotedLiteral(line, cursor) + 1; + continue; + } + + if (line[cursor] == '/' && cursor + 1 < line.Length && line[cursor + 1] == '*') + { + MaskAt(cursor); + cursor++; + MaskAt(cursor); + blockCommentDepth = 1; + cursor++; + continue; + } + + cursor++; + } + + result[lineIndex] = chars is null ? line : new string(chars); + } + + return result; + } + + private static bool MayContainCStyleMaskingTrigger(string language, IReadOnlyList lines) + { + foreach (var line in lines) + { + if (line.Contains('/')) + return true; + if (language == "go" && line.Contains('`')) + return true; + if (language == "dart" && + (line.Contains("\"\"\"", StringComparison.Ordinal) || + line.Contains("'''", StringComparison.Ordinal))) + { + return true; + } + + if (language == "cpp" && line.Contains("R\"", StringComparison.Ordinal)) + return true; + } + + return false; + } + + private static bool TryGetDartTripleStringStart(string line, int start, out char quote, out int openingLength) + { + quote = '\0'; + openingLength = 0; + var quoteIndex = start; + + if (line[start] is 'r' or 'R') + { + if (start > 0 && IsIdentifierChar(line[start - 1])) + return false; + quoteIndex = start + 1; + } + + if (quoteIndex + 2 >= line.Length) + return false; + + quote = line[quoteIndex]; + if (quote is not ('"' or '\'') || !IsTripleQuoteAt(line, quoteIndex, quote)) + return false; + + openingLength = quoteIndex - start + 3; + return true; + } + + private static bool IsTripleQuoteAt(string line, int start, char quote) => + start + 2 < line.Length + && line[start] == quote + && line[start + 1] == quote + && line[start + 2] == quote; + + private static int IndexOfTripleQuote(string line, int start, char quote) + { + for (var i = start; i + 2 < line.Length; i++) + { + if (IsTripleQuoteAt(line, i, quote)) + return i; + } + + return -1; + } + + private static bool TryGetCppRawStringTerminator(string line, int start, out string terminator, out int openingLength) + { + terminator = string.Empty; + openingLength = 0; + if (line[start] != 'R' || start + 2 >= line.Length || line[start + 1] != '"') + return false; + + var delimiterStart = start + 2; + var parenIndex = line.IndexOf('(', delimiterStart); + if (parenIndex < 0) + return false; + + for (var i = delimiterStart; i < parenIndex; i++) + { + if (char.IsWhiteSpace(line[i]) || line[i] is '(' or ')' or '\\') + return false; + } + + terminator = ")" + line[delimiterStart..parenIndex] + "\""; + openingLength = parenIndex - start + 1; + return true; + } + + private static string[] MaskHaskellBlockCommentLines(IReadOnlyList lines) + { + if (lines is string[] lineArray && !MayContainHaskellBlockComment(lines)) + return lineArray; + + var result = new string[lines.Count]; + var blockDepth = 0; + + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + var line = lines[lineIndex]; + char[]? chars = null; + + void MaskAt(int index) => + (chars ??= line.ToCharArray())[index] = ' '; + + var cursor = 0; + + while (cursor < line.Length) + { + if (blockDepth > 0) + { + if (line[cursor] == '{' && cursor + 1 < line.Length && line[cursor + 1] == '-') + { + MaskAt(cursor); + MaskAt(cursor + 1); + blockDepth++; + cursor += 2; + continue; + } + + if (line[cursor] == '-' && cursor + 1 < line.Length && line[cursor + 1] == '}') + { + MaskAt(cursor); + MaskAt(cursor + 1); + blockDepth--; + cursor += 2; + continue; + } + + MaskAt(cursor++); + continue; + } + + if (line[cursor] == '"') + { + cursor = SkipCStyleQuotedLiteral(line, cursor) + 1; + continue; + } + + if (line[cursor] == '-' && cursor + 1 < line.Length && line[cursor + 1] == '-') + break; + + if (line[cursor] == '{' && cursor + 1 < line.Length && line[cursor + 1] == '-') + { + MaskAt(cursor); + MaskAt(cursor + 1); + blockDepth = 1; + cursor += 2; + continue; + } + + cursor++; + } + + result[lineIndex] = chars is null ? line : new string(chars); + } + + return result; + } + + private static bool MayContainHaskellBlockComment(IReadOnlyList lines) + { + foreach (var line in lines) + { + if (line.Contains("{-", StringComparison.Ordinal)) + return true; + } + + return false; + } + + private static int SkipCStyleQuotedLiteral(string line, int start) + { + var quote = line[start]; + var cursor = start + 1; + while (cursor < line.Length) + { + if (quote != '`' && line[cursor] == '\\' && cursor + 1 < line.Length) + { + cursor += 2; + continue; + } + + if (line[cursor] == quote) + return cursor; + cursor++; + } + + return line.Length; + } + + private static readonly Regex VisualBasicRemCommentRegex = new( + @"(?:^|:)\s*Rem\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex PascalBraceCommentRegex = new(@"\{[^}\r\n]*\}", RegexOptions.Compiled); + private static readonly Regex PascalParenStarCommentRegex = new(@"\(\*.*?\*\)", RegexOptions.Compiled); + + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs new file mode 100644 index 000000000..aab7f5118 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.PrimaryConstructors.cs @@ -0,0 +1,862 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + internal readonly record struct JavaSameLineCtorSpan( + string Name, + int NameIndex, + int OpenBraceIndex, + int CloseBraceIndex); + + /// + /// Depth-aware scanner for `@Annot ... > Ctor(...) { ... }` + /// style declarations. Returns the constructor name when the line opens a ctor body, or + /// null otherwise. Handles qualified annotations (`@demo.Ann`), annotation argument lists + /// with nested parens, and nested generic bounds that a flat regex cannot balance. + /// 修飾付きアノテーション・引数付きアノテーション・入れ子の generic 境界を含む + /// same-line ctor 宣言を depth-aware にスキャンして ctor 名を返すヘルパー。 + /// + internal static string? TryExtractJavaCtorNameFromLine(string line) + => JavaReferenceExtractor.TryExtractCtorNameFromLine(line); + + /// + /// Same as but also returns the ctor name + /// index, body-open `{` index, and the matching body-close `}` index on the same line. + /// `TryExtractJavaCtorNameFromLine` と同じスキャナだが、ctor 名位置・`{` 位置・対応する + /// `}` 位置もまとめて返すバリアント。 + /// + internal static JavaSameLineCtorSpan? TryExtractJavaSameLineCtorSpan(string line) + => JavaReferenceExtractor.TryExtractSameLineCtorSpan(line); + + private static void AddChainReference( + List references, + ReferenceDedupeSet seen, + long fileId, + string name, + int column, + string referenceKind, + string context, + int lineNumber, + SymbolRecord? container) + { + var dedupeKey = CreateReferenceDedupeKey(fileId, null, lineNumber, column, referenceKind, name, container); + if (!seen.Add(dedupeKey)) + return; + + TryAddReference(references, new ReferenceRecord + { + FileId = fileId, + SymbolName = name, + ReferenceKind = referenceKind, + Line = lineNumber, + Column = column, + Context = context, + ContainerKind = container?.Kind, + ContainerName = container?.Name, + }); + } + + private static void EmitMethodGroupReferences( + string language, + string preparedLine, + HashSet? callableDefinitionNames, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (callableDefinitionNames == null || callableDefinitionNames.Count == 0) + return; + + foreach (Match match in MethodGroupReferenceRegex.Matches(preparedLine)) + { + var contextTargetGroup = match.Groups["contextTarget"]; + if (contextTargetGroup.Success && MethodGroupContextTargetIgnoreNames.Contains(contextTargetGroup.Value)) + continue; + if (!contextTargetGroup.Success) + { + var prefix = preparedLine.AsSpan(0, match.Groups["name"].Index).TrimEnd(); + if (prefix.EndsWith("+=", StringComparison.Ordinal) || prefix.EndsWith("-=", StringComparison.Ordinal)) + continue; + } + + var nameGroup = match.Groups["name"]; + var rawName = nameGroup.Value; + var name = language == "csharp" ? NormalizeCSharpIdentifier(rawName) : rawName; + if (!callableDefinitionNames.Contains(name)) + continue; + + var container = resolveContainerForColumn(nameGroup.Index); + AddChainReference(references, seen, fileId, name, nameGroup.Index, "call", context, lineNumber, container); + } + } + + /// + /// Build a list of line ranges paired with synthetic function-kind containers for C# primary + /// constructor declarations that carry a base primary-constructor call. This covers records + /// (`record Child(int x) : Parent(x)`), C# 12 classes (`class Child(int x) : Parent(x)`) and + /// structs (`struct Child(int x) : Parent(x)`), including the multi-line form where + /// `: Parent(x)` sits on a continuation line. SymbolExtractor does not synthesize a separate + /// ctor symbol for the implicit primary constructor, so the `Parent(x)` reference would + /// otherwise land on `container = null` (when the declaration line has no body range) or on + /// the declaring type itself. The synthetic container covers the header range only; methods + /// inside a braced body still resolve to their real containers via FindInnermostContainer, + /// and within the end line the override is limited to columns before the terminator so body + /// calls sharing the same line (e.g. `record Child(int V) : Parent(V) { ... Add(V, 1); }`) + /// are not pulled onto the synthetic ctor. + /// C# の primary constructor 宣言に対して合成 function コンテナの (start, end, endColumn, container) + /// リストを作る。record だけでなく C# 12 の class / struct primary constructor も対象にし、 + /// 宣言ヘッダーの範囲(end line は終端 `;` / `{` のカラムまで)だけ合成 ctor に差し替えることで、 + /// 同一行 braced body の呼び出しや後続メソッドは本来の container に残る。 + /// + private static List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container)> BuildCSharpPrimaryCtorContainers( + string language, + IReadOnlyList symbols, + string[] structuralLines) + { + if (language != "csharp") + return []; + + var ranges = new List<(int, int, int, int, SymbolRecord)>(4); + foreach (var symbol in symbols) + { + // SymbolExtractor stores C# records as Kind=class and C# 12 structs as Kind=struct. + // Interfaces / enums / delegates cannot have primary constructors in C# so skip them. + // C# record は Kind=class、C# 12 struct は Kind=struct として登録されるため両方対象。 + if (symbol.Kind != "class" && symbol.Kind != "struct") + continue; + var signature = symbol.Signature; + if (string.IsNullOrWhiteSpace(signature)) + continue; + + // SymbolRecord.Signature only captures the first declaration line, so the first-line + // regex filter misses split-line primary-ctor forms such as + // `public record Child\n(\n int Value\n)\n : Parent(Value);`. Walk the + // structural-masked lines from StartLine until we hit `;` / `{` and run the + // primary-ctor detection on the joined header text instead. + // 宣言の signature は 1 行目だけしか持たないので、`record` / `class` / `struct` と + // `(` を別行に分ける書式では先頭行 regex の前段フィルタが空振りする。ここでは + // structuralLines から `;` / `{` までヘッダーを連結し、連結後のテキストで判定する。 + var (headerEndLine, headerEndColumn, headerText) = CollectCSharpRecordHeader(structuralLines, symbol.StartLine); + if (!IsCSharpPrimaryCtorHeader(headerText)) + continue; + if (!HasCSharpBasePrimaryCtorCall(headerText)) + continue; + + // Restrict the synthetic container to the actual declaration span, starting at the + // `class` / `struct` / `record` keyword column on the start line. Without this + // same-line tokens BEFORE the keyword (e.g. attribute arguments in + // `[Attr(Helper.Get())] public class Child(int x) : Parent(x) {}`) would get + // attributed to the synthetic ctor and pollute callers / impact with phantom + // `Child` callers for `Attr` and `Helper.Get`. + // 合成 ctor コンテナを本物の宣言範囲に限定する。`class` / `struct` / `record` + // キーワード位置より前(同一行の属性呼び出しなど)は本来の container に残す。 + var startColumn = FindCSharpPrimaryCtorKeywordColumn(structuralLines, symbol.StartLine); + + var synthetic = new SymbolRecord + { + FileId = symbol.FileId, + Kind = "function", + Name = symbol.Name, + Line = symbol.Line, + StartLine = symbol.StartLine, + EndLine = headerEndLine, + BodyStartLine = symbol.StartLine, + BodyEndLine = headerEndLine, + Signature = signature, + ContainerKind = symbol.ContainerKind, + ContainerName = symbol.ContainerName, + ContainerQualifiedName = symbol.ContainerQualifiedName, + FamilyKey = symbol.FamilyKey, + Visibility = symbol.Visibility, + }; + + ranges.Add((symbol.StartLine, startColumn, headerEndLine, headerEndColumn, synthetic)); + } + + return ranges; + } + + private static int FindCSharpPrimaryCtorKeywordColumn(string[] structuralLines, int startLine) + { + var idx = Math.Max(0, startLine - 1); + if (idx >= structuralLines.Length) + return 0; + var line = structuralLines[idx]; + foreach (var keyword in CSharpPrimaryCtorKeywords) + { + int pos = 0; + while (pos < line.Length) + { + var found = line.IndexOf(keyword, pos, StringComparison.Ordinal); + if (found < 0) break; + var before = found == 0 ? ' ' : line[found - 1]; + var afterIdx = found + keyword.Length; + var after = afterIdx < line.Length ? line[afterIdx] : ' '; + if (!IsCSharpIdentifierPart(before) && !IsCSharpIdentifierPart(after)) + return found; + pos = found + 1; + } + } + return 0; + } + + private static readonly string[] CSharpPrimaryCtorKeywords = { "record", "class", "struct" }; + + private static bool IsCSharpIdentifierPart(char c) => char.IsLetterOrDigit(c) || c == '_'; + + /// + /// Walk structural-masked lines starting at the 1-based and collect + /// the declaration header up to (but not including) the first `;` or `{` that sits outside a + /// string or comment. Returns the 1-based line number where the terminator was found (or the + /// final line index when none was found) and the joined header text for further parsing. + /// Reused for record primary-ctor container synthesis and multi-line `: base(...)` resolution. + /// structuralLines を使って、class / struct / record 宣言ヘッダーを最初の `;` / `{` まで連結する。 + /// record primary-ctor のコンテナ合成と、複数行 `: base(...)` 解決の両方で使う。 + /// + internal static (int EndLine, int EndColumn, string Text) CollectCSharpRecordHeader(string[] structuralLines, int startLine) + { + var startIdx = Math.Max(0, startLine - 1); + if (structuralLines.Length == 0) + return (startLine, int.MaxValue, string.Empty); + + // Depth-aware termination so that `{` / `;` inside annotation arg lists (e.g. the `{` in + // `@Ann({A.class, B.class})`) or attribute-argument brackets does not cut the header off + // before the real base-list terminator, which would silently drop the base type. + // We intentionally do NOT track `<` / `>` as generic depth here: comparison operators + // inside annotation / attribute expressions (e.g. `[Attr(Flag = 1 < 2)]` or + // `@Ann(flag = 1 < 2)`) are raised as `<` without a matching `>`, so angle-depth tracking + // would leave the counter pinned above zero and silently drop the real top-level `{` / `;` + // terminator, letting the synthetic primary-ctor container or the Java base-type parse + // swallow everything up to EOF. `{` / `;` cannot legally appear inside a top-level + // `<...>` generic arg list in either C# or Java, so paren/bracket masking is sufficient. + // EndColumn tracks the column index of the top-level terminator on the end line, or + // int.MaxValue when no terminator was found (end-of-file), so call-site-scoped container + // overrides can restrict themselves to the header portion of the end line. + // アノテーション引数の `{` などを本当のヘッダ終端と誤認しないよう、`()` / `[]` の深さを追いながら + // 最初の top-level `;` / `{` でのみ終了する。`<` / `>` は annotation / attribute 式内の比較演算子で + // 非対称に現れうるため generic 深度として扱わない。 + // EndColumn は end line 上の終端 `;` / `{` の位置を返す(終端が無ければ int.MaxValue)。 + var sb = new System.Text.StringBuilder(); + int parenDepth = 0; + int bracketDepth = 0; + // Comment / string awareness so unbalanced `(` / `[` / `{` / `;` inside a line + // comment, block comment, or string literal never advances the depth counters, + // fires the terminator, or leaks into the returned header text. For Java `extends` + // headers the structuralLines array is an unmasked clone (StructuralLineMasker is a + // no-op for Java), so this is what keeps `class Leaf extends Root /* ( stray [ */ {` + // from pinning parenDepth / bracketDepth at 1 and skipping the real `{` terminator, + // and it also prevents ParseJavaBaseType from seeing the comment body when it parses + // the header text downstream. + // コメント・文字列内の不均衡な `(` / `[` / `{` / `;` を terminator 判定・連結テキスト双方から除外する。 + bool inBlockComment = false; + bool inString = false; + for (int i = startIdx; i < structuralLines.Length; i++) + { + var line = structuralLines[i]; + char[]? masked = null; + var terminatorIdx = -1; + void MaskChar(int index) + { + masked ??= line.ToCharArray(); + masked[index] = ' '; + } + + void MaskRange(int start, int endExclusive) + { + masked ??= line.ToCharArray(); + for (int k = start; k < endExclusive; k++) + masked[k] = ' '; + } + + for (int j = 0; j < line.Length; j++) + { + var c = line[j]; + + if (inBlockComment) + { + MaskChar(j); + if (c == '*' && j + 1 < line.Length && line[j + 1] == '/') + { + inBlockComment = false; + MaskChar(j + 1); + j++; + } + continue; + } + + if (inString) + { + MaskChar(j); + if (c == '\\' && j + 1 < line.Length) + { + MaskChar(j + 1); + j++; + continue; + } + if (c == '"') + inString = false; + continue; + } + + if (c == '/' && j + 1 < line.Length) + { + if (line[j + 1] == '/') + { + MaskRange(j, line.Length); + break; + } + if (line[j + 1] == '*') + { + inBlockComment = true; + MaskChar(j); + MaskChar(j + 1); + j++; + continue; + } + } + + if (c == '"') + { + inString = true; + MaskChar(j); + continue; + } + + if (c == '\'') + { + // Rust / OCaml lifetime annotation vs. char literal: only skip when a + // closing `'` exists within ~12 chars on this line. + // Rust の lifetime と char literal を短距離の閉じ `'` の有無で見分ける。 + var closeIdx = -1; + var limit = Math.Min(line.Length, j + 12); + for (int k = j + 1; k < limit; k++) + { + if (line[k] == '\\' && k + 1 < line.Length) + { + k++; + continue; + } + if (line[k] == '\'') + { + closeIdx = k; + break; + } + } + if (closeIdx > 0) + { + MaskRange(j, closeIdx + 1); + j = closeIdx; + } + continue; + } + + if (c == '(') parenDepth++; + else if (c == ')') { if (parenDepth > 0) parenDepth--; } + else if (c == '[') bracketDepth++; + else if (c == ']') { if (bracketDepth > 0) bracketDepth--; } + else if ((c == ';' || c == '{') && parenDepth == 0 && bracketDepth == 0) + { + terminatorIdx = j; + break; + } + } + + if (terminatorIdx >= 0) + { + if (masked == null) + sb.Append(line, 0, terminatorIdx); + else + sb.Append(masked, 0, terminatorIdx); + return (i + 1, terminatorIdx, sb.ToString()); + } + + if (masked == null) + sb.Append(line); + else + sb.Append(masked); + sb.Append('\n'); + } + + return (structuralLines.Length, int.MaxValue, sb.ToString()); + } + + /// + /// Returns true when the C# type header text carries a base-list entry that looks like a + /// primary-constructor call (contains `(`). Accepts multi-line header text already joined by + /// . + /// C# 型ヘッダー(複数行連結後でも可)の base-list 先頭エントリが `(` を含むかを判定する。 + /// + /// + /// Return true when a joined C# type-declaration header (possibly spanning multiple lines, + /// including line-broken primary-ctor parens) looks like a primary-constructor declaration. + /// Accepts `record Child(...)`, `record class Child(...)`, `record struct Child(...)`, + /// C# 12 `class Child(...)`, `struct Child(...)`, generic arity such as `class Child(...)`, + /// and the split-line form where `record Child\n(\n ... )` places the `(` on a continuation line. + /// 連結済みの C# 宣言ヘッダーが primary-ctor 宣言かを判定する。`record` だけでなく C# 12 の + /// `class` / `struct` primary constructor も対象にし、`(` が別行に分かれる書式にも対応する。 + /// + private static bool IsCSharpPrimaryCtorHeader(string headerText) + { + if (string.IsNullOrWhiteSpace(headerText)) + return false; + return CSharpPrimaryCtorHeaderRegex.IsMatch(headerText); + } + + private static bool HasCSharpBasePrimaryCtorCall(string headerText) + { + var text = headerText.TrimEnd(); + if (text.EndsWith(";", StringComparison.Ordinal)) + { + var end = text.Length - 1; + while (end > 0 && char.IsWhiteSpace(text[end - 1])) + end--; + text = text.Substring(0, end); + } + if (text.EndsWith("{", StringComparison.Ordinal)) + { + var end = text.Length - 1; + while (end > 0 && char.IsWhiteSpace(text[end - 1])) + end--; + text = text.Substring(0, end); + } + + var colonIndex = FindSignatureColonIndex(text); + if (colonIndex < 0) + return false; + + var baseList = text.Substring(colonIndex + 1); + var whereMatch = CSharpWhereClauseRegex.Match(baseList); + if (whereMatch.Success) + baseList = baseList.Substring(0, whereMatch.Index); + + var firstEntryText = TakeFirstBaseEntry(baseList); + var firstEntryStart = 0; + while (firstEntryStart < firstEntryText.Length && char.IsWhiteSpace(firstEntryText[firstEntryStart])) + firstEntryStart++; + + var firstEntryEnd = firstEntryText.Length; + while (firstEntryEnd > firstEntryStart && char.IsWhiteSpace(firstEntryText[firstEntryEnd - 1])) + firstEntryEnd--; + + var firstEntry = firstEntryText.Substring(firstEntryStart, firstEntryEnd - firstEntryStart); + // Only count a `(` that sits at generic / bracket depth 0 — a primary-ctor base call + // always puts its argument list directly after the bare type name, whereas generic args + // and array ranks can legally contain `(` (tuple syntax `<(int, int)>`, function types + // `>`, or attribute arg brackets). A naive `.Contains('(')` would treat + // those as primary-ctor calls and synthesize a phantom record ctor container. + // 先頭エントリのうち generic/bracket 深度 0 の `(` だけを primary-ctor 呼び出し扱いにする。 + // `IBox<(int, int)>` のような tuple を含む interface 実装を連鎖呼び出しと誤認させない。 + int angleDepth = 0; + int squareDepth = 0; + for (int i = 0; i < firstEntry.Length; i++) + { + var c = firstEntry[i]; + switch (c) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) angleDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) squareDepth--; + break; + case '(': + if (angleDepth == 0 && squareDepth == 0) + return true; + break; + } + } + return false; + } + + /// + /// Parse the first base-class token from a C# class/struct/record signature such as + /// `class B : A, IFoo`, `record C(int x) : A(x)`, or `class B : A where T : new()`. + /// Returns null when no base list is present or when the signature is empty. + /// C# の class/struct/record シグネチャから最初の基底クラストークンを取り出す。 + /// + internal static string? ParseCSharpBaseType(string? signature) + { + if (string.IsNullOrWhiteSpace(signature)) + return null; + + var text = signature.TrimEnd(); + if (text.EndsWith("{", StringComparison.Ordinal)) + { + var end = text.Length - 1; + while (end > 0 && char.IsWhiteSpace(text[end - 1])) + end--; + text = text.Substring(0, end); + } + + var colonIndex = FindSignatureColonIndex(text); + if (colonIndex < 0) + return null; + + var baseList = text.Substring(colonIndex + 1); + var whereMatch = CSharpWhereClauseRegex.Match(baseList); + if (whereMatch.Success) + baseList = baseList.Substring(0, whereMatch.Index); + + var firstEntryText = TakeFirstBaseEntry(baseList); + var firstEntryStart = 0; + while (firstEntryStart < firstEntryText.Length && char.IsWhiteSpace(firstEntryText[firstEntryStart])) + firstEntryStart++; + + var firstEntryEnd = firstEntryText.Length; + while (firstEntryEnd > firstEntryStart && char.IsWhiteSpace(firstEntryText[firstEntryEnd - 1])) + firstEntryEnd--; + + var firstEntry = firstEntryText.Substring(firstEntryStart, firstEntryEnd - firstEntryStart); + return ExtractBareTypeName(firstEntry); + } + + /// + /// Parse the first extends-clause type from a Java class/interface/record signature. + /// 例: `class B extends A implements IFoo` → `A`、 + /// `class Leaf extends Outer.Base {` → `Base`。 + /// + internal static string? ParseJavaBaseType(string? signature) + { + if (string.IsNullOrWhiteSpace(signature)) + return null; + + // Locate `extends` at angle/paren depth 0 so bounded type parameters like + // `class Leaf extends Root {` do not resolve to the + // parameter bound (`Number`) instead of the real base (`Root`). + // 境界付き型パラメータ(`class Leaf extends Root {`)で + // 型パラメータ境界の `extends` を先に拾わないよう、angle / paren 深度 0 の + // `extends` のみを検出する。 + int start = FindTopLevelExtendsEnd(signature!); + if (start < 0) + return null; + + int i = start; + int angleDepth = 0; + int parenDepth = 0; + while (i < signature.Length) + { + char c = signature[i]; + if (c == '<') + { + angleDepth++; + } + else if (c == '>') + { + if (angleDepth > 0) angleDepth--; + } + else if (c == '(') + { + // Track `(...)` depth so that commas inside annotation arguments such as + // `@Ann(a = 1, b = 2) Root` or `@Ann({A.class, B.class}) Root` are not mistaken + // for top-level base-list separators. Without this the scanner breaks at the + // inner `,`, feeds a truncated segment to the annotation stripper, and the + // super(...) edge gets misattributed or dropped entirely. + // annotation 引数内のカンマ(`@Ann(a = 1, b = 2) Root` や + // `@Ann({A.class, B.class}) Root`)が base-list 区切りと誤認されないよう `(...)` の + // 深さも追跡する。これをやらないと内側の `,` で走査が切れ、annotation stripper に + // 壊れたセグメントが渡って super(...) の連鎖エッジが落ちる。 + parenDepth++; + } + else if (c == ')') + { + if (parenDepth > 0) parenDepth--; + } + else if (angleDepth == 0 && parenDepth == 0) + { + if (c == '{' || c == ',' || c == ';') + break; + // Stop at a word-boundary `implements` or `permits` (Java 17+ sealed types). + // 単語境界の `implements` / `permits` (Java 17+ sealed 型) で停止する。 + if (IsJavaBaseListTerminatorKeyword(signature, i, start, "implements") || + IsJavaBaseListTerminatorKeyword(signature, i, start, "permits")) + { + break; + } + } + i++; + } + + var segment = signature.Substring(start, i - start).Trim(); + if (segment.Length == 0) + return null; + + // Strip Java type-use annotations (JLS 9.7.4): `@Ann`, `@pkg.Ann`, `@Ann(value=1)` can + // appear before the type itself (`extends @Ann Root`) or between nested-type segments + // (`Outer.@Ann Base`). Without this pass the base resolver returns a phantom + // type name like `@Ann Root` that misattributes references / callers / impact. + // Java の type-use annotation (JLS 9.7.4) を剥がす。`extends @Ann Root` や + // `Outer.@Ann Base` のような形で基底型の直前やセグメント間に現れるため、 + // 先に除去しないと `@Ann Root` のような幽霊シンボルへ参照が張られてしまう。 + segment = StripJavaTypeAnnotations(segment); + return segment.Length == 0 ? null : ExtractBareTypeName(segment); + } + + /// + /// Return the index past the first `extends` keyword that appears at angle/paren depth 0, + /// or -1 when no such occurrence exists. Matches the semantics of the old `\bextends\s+` + /// regex entrypoint but skips `extends` inside `<...>` (bounded type parameters) and + /// `(...)` (annotation argument lists). + /// + private static int FindTopLevelExtendsEnd(string signature) + { + int angleDepth = 0; + int parenDepth = 0; + for (int i = 0; i < signature.Length; i++) + { + char c = signature[i]; + if (c == '<') + { + angleDepth++; + } + else if (c == '>') + { + if (angleDepth > 0) angleDepth--; + } + else if (c == '(') + { + parenDepth++; + } + else if (c == ')') + { + if (parenDepth > 0) parenDepth--; + } + else if (angleDepth == 0 && parenDepth == 0 && IsExtendsKeywordAt(signature, i)) + { + int end = i + 7; // "extends".Length + while (end < signature.Length && char.IsWhiteSpace(signature[end])) + end++; + return end; + } + } + return -1; + } + + private static bool IsExtendsKeywordAt(string signature, int i) + { + const string Keyword = "extends"; + if (i + Keyword.Length > signature.Length) + return false; + if (i > 0 && IsJavaIdentifierPart(signature[i - 1])) + return false; + if (string.CompareOrdinal(signature, i, Keyword, 0, Keyword.Length) != 0) + return false; + int after = i + Keyword.Length; + // `\bextends\s+` equivalence: must be followed by whitespace so that names like + // `extendsFoo` or identifiers containing `extends` do not match. + // `\bextends\s+` 相当: `extendsFoo` のような識別子や合成語を誤認しないよう、 + // 直後に空白が続くものだけを `extends` キーワードとして扱う。 + if (after >= signature.Length) + return false; + return char.IsWhiteSpace(signature[after]); + } + + private static string StripJavaTypeAnnotations(string text) + { + if (text.IndexOf('@') < 0) + return text; + + var sb = new System.Text.StringBuilder(text.Length); + int i = 0; + while (i < text.Length) + { + char c = text[i]; + if (c == '@') + { + // Skip `@` + qualified identifier (`@pkg.Ann`) + optional balanced `(...)`. + i++; + while (i < text.Length && (IsJavaIdentifierPart(text[i]) || text[i] == '.')) + i++; + if (i < text.Length && text[i] == '(') + { + int parenDepth = 1; + i++; + while (i < text.Length && parenDepth > 0) + { + var ch = text[i]; + // Skip string / char literals so `@Ann(text=")")` does not close early. + // 文字列・文字リテラル内の `)` で早期終了しないようスキップする。 + if (ch == '"' || ch == '\'') + { + var quote = ch; + i++; + while (i < text.Length) + { + var lc = text[i]; + if (lc == '\\' && i + 1 < text.Length) { i += 2; continue; } + if (lc == quote) { i++; break; } + i++; + } + continue; + } + if (ch == '(') parenDepth++; + else if (ch == ')') parenDepth--; + i++; + } + } + // Drop a single trailing whitespace run so `@Ann Root` collapses to `Root`. + while (i < text.Length && char.IsWhiteSpace(text[i])) + i++; + continue; + } + sb.Append(c); + i++; + } + + return sb.ToString(); + } + + internal static bool IsJavaIdentifierPart(char c) => + char.IsLetterOrDigit(c) || c == '_' || c == '$'; + + private static bool IsJavaBaseListTerminatorKeyword(string signature, int i, int start, string keyword) => + IsJavaBaseListTerminatorKeyword(signature.AsSpan(), i, start, keyword); + + private static bool IsJavaBaseListTerminatorKeyword(ReadOnlySpan signature, int i, int start, string keyword) + { + var keywordSpan = keyword.AsSpan(); + if (i + keywordSpan.Length > signature.Length) + return false; + if (i != start && IsJavaIdentifierPart(signature[i - 1])) + return false; + if (!signature.Slice(i, keywordSpan.Length).SequenceEqual(keywordSpan)) + return false; + if (i + keywordSpan.Length < signature.Length && IsJavaIdentifierPart(signature[i + keywordSpan.Length])) + return false; + return true; + } + + private static int FindSignatureColonIndex(string text) + { + var depth = 0; + for (int i = 0; i < text.Length; i++) + { + var c = text[i]; + switch (c) + { + case '<': + case '(': + case '[': + depth++; + break; + case '>': + case ')': + case ']': + if (depth > 0) depth--; + break; + case ':': + if (depth == 0) + { + // Skip `::` alias qualifier (`global::System.Exception`). + // `::` エイリアス修飾子(`global::System.Exception`)はスキップ。 + if (i + 1 < text.Length && text[i + 1] == ':') + { + i++; + continue; + } + return i; + } + break; + } + } + + return -1; + } + + private static string TakeFirstBaseEntry(string baseList) + { + var depth = 0; + for (int i = 0; i < baseList.Length; i++) + { + var c = baseList[i]; + switch (c) + { + case '<': + case '(': + case '[': + depth++; + break; + case '>': + case ')': + case ']': + if (depth > 0) depth--; + break; + case ',': + if (depth == 0) + return baseList.Substring(0, i); + break; + } + } + + return baseList; + } + + private static string? ExtractBareTypeName(string entry) + { + var trimmed = entry.Trim(); + if (trimmed.Length == 0) + return null; + + // Split on `.` / `::` at generic depth 0, then return the last segment with generic + // args stripped. Naive "first `<`, then last `.`" slicing loses nested types such as + // `Outer.Base`, `Outer.Base`, or `global::Ns.Outer.Inner`. + // 最初の `<` で切ってから末尾 `.` を探す素朴な方法では `Outer.Base` のような + // ネスト型を取り違えるため、generic 深度 0 の `.` / `::` でセグメント分割して末尾だけ返す。 + int lastSegmentStart = 0; + int angleDepth = 0; + int endIndex = trimmed.Length; + for (int i = 0; i < trimmed.Length; i++) + { + var c = trimmed[i]; + if (c == '<') + { + angleDepth++; + } + else if (c == '>') + { + if (angleDepth > 0) angleDepth--; + } + else if (angleDepth == 0) + { + if (c == '(') + { + // Strip record primary-ctor args at top level: `A(...)` → `A`. + // record のプライマリコンストラクタ引数を剥がす。 + endIndex = i; + break; + } + if (c == '.') + { + lastSegmentStart = i + 1; + } + else if (c == ':' && i + 1 < trimmed.Length && trimmed[i + 1] == ':') + { + lastSegmentStart = i + 2; + i++; + } + } + } + + var segment = trimmed.Substring(lastSegmentStart, endIndex - lastSegmentStart).Trim(); + var ltIndex = segment.IndexOf('<'); + if (ltIndex >= 0) + segment = segment.Substring(0, ltIndex); + + segment = segment.Trim(); + return segment.Length > 0 ? segment : null; + } + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs index cde2c7a8d..e719838e1 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs @@ -725,3672 +725,4 @@ private static int IndexOfCSharpWord(string text, string word, int startIndex) return -1; } - internal static void AddTypeReferenceSegment( - List references, - ReferenceDedupeSet seen, - long fileId, - string segment, - int startInLine, - string context, - int lineNumber, - SymbolRecord? container, - string language, - bool isEscapedCSharpIdentifier = false, - IReadOnlySet? ignoredSegments = null, - string referenceKind = "type_reference") - { - if (segment.Length == 0 || IsIgnoredTypeReferenceSegment(language, segment, isEscapedCSharpIdentifier, ignoredSegments)) - return; - - int column = startInLine + 1; // 1-based / 1始まり - var dedupeKey = CreateReferenceDedupeKey(fileId, language, lineNumber, column, referenceKind, segment, container); - if (!seen.Add(dedupeKey)) - return; - - TryAddReference(references, new ReferenceRecord - { - FileId = fileId, - SymbolName = segment, - ReferenceKind = referenceKind, - Line = lineNumber, - Column = column, - Context = context, - ContainerKind = container?.Kind, - ContainerName = container?.Name, - }); - } - - private static SymbolRecord? FindInnermostContainer(IReadOnlyList candidates, int lineNumber) - { - foreach (var candidate in candidates) - { - if (candidate.BodyStartLine!.Value <= lineNumber && candidate.BodyEndLine!.Value >= lineNumber) - return candidate; - } - - return null; - } - - internal sealed class InnermostContainerResolver - { - private readonly IReadOnlyList candidates; - private readonly List<(SymbolRecord Symbol, int SpanLength, int OriginalIndex)>? candidatesByStart; - private SortedSet? activeContainers; - private int nextCandidateIndex; - private int currentLine; - private int? cachedLine; - private SymbolRecord? cachedContainer; - - internal InnermostContainerResolver(IReadOnlyList candidates) - { - this.candidates = candidates; - if (candidates.Count == 0) - return; - - candidatesByStart = new List<(SymbolRecord Symbol, int SpanLength, int OriginalIndex)>(candidates.Count); - for (var index = 0; index < candidates.Count; index++) - { - var symbol = candidates[index]; - candidatesByStart.Add((symbol, GetContainerSpanLength(symbol), index)); - } - - candidatesByStart.Sort(CompareCandidatesByStart); - } - - internal SymbolRecord? Find(int lineNumber) - { - if (cachedLine == lineNumber) - return cachedContainer; - - if (candidatesByStart == null) - return Cache(lineNumber, null); - - if (lineNumber < currentLine) - return Cache(lineNumber, FindInnermostContainer(candidates, lineNumber)); - - AdvanceTo(lineNumber); - return Cache(lineNumber, activeContainers is not { Count: > 0 } ? null : activeContainers.Min.Symbol); - } - - private void AdvanceTo(int lineNumber) - { - if (candidatesByStart == null) - { - currentLine = lineNumber; - return; - } - - while (nextCandidateIndex < candidatesByStart.Count - && candidatesByStart[nextCandidateIndex].Symbol.BodyStartLine!.Value <= lineNumber) - { - var candidate = candidatesByStart[nextCandidateIndex]; - (activeContainers ??= []).Add(new ActiveContainer(candidate.Symbol, candidate.SpanLength, candidate.OriginalIndex)); - nextCandidateIndex++; - } - - activeContainers?.RemoveWhere(active => active.Symbol.BodyEndLine!.Value < lineNumber); - currentLine = lineNumber; - } - - private SymbolRecord? Cache(int lineNumber, SymbolRecord? container) - { - cachedLine = lineNumber; - cachedContainer = container; - return container; - } - - private static int GetContainerSpanLength(SymbolRecord symbol) => - (symbol.BodyEndLine ?? symbol.EndLine) - (symbol.BodyStartLine ?? symbol.StartLine); - - private static int CompareCandidatesByStart( - (SymbolRecord Symbol, int SpanLength, int OriginalIndex) left, - (SymbolRecord Symbol, int SpanLength, int OriginalIndex) right) - { - var compare = left.Symbol.BodyStartLine!.Value.CompareTo(right.Symbol.BodyStartLine!.Value); - if (compare != 0) - return compare; - - compare = left.Symbol.BodyEndLine!.Value.CompareTo(right.Symbol.BodyEndLine!.Value); - if (compare != 0) - return compare; - - compare = left.SpanLength.CompareTo(right.SpanLength); - if (compare != 0) - return compare; - - return left.OriginalIndex.CompareTo(right.OriginalIndex); - } - - private readonly record struct ActiveContainer(SymbolRecord Symbol, int SpanLength, int OriginalIndex) : IComparable - { - public int CompareTo(ActiveContainer other) - { - var spanComparison = SpanLength.CompareTo(other.SpanLength); - if (spanComparison != 0) - return spanComparison; - - return OriginalIndex.CompareTo(other.OriginalIndex); - } - } - } - - private static bool CanAttachCSharpXmlDocCommentToNextDeclaration( - SymbolRecord? innermostContainer, - IReadOnlyList? scopeCandidates, - IReadOnlyList?>? csharpAttrRanges, - string[] preparedLines, - int lineNumber, - SymbolRecord documentedContainer) - { - if (!HasOnlyCSharpWhitespaceOrAttributesBetweenCommentAndDeclaration( - csharpAttrRanges, - preparedLines, - lineNumber, - documentedContainer.StartLine)) - { - return false; - } - - if (innermostContainer != null - && innermostContainer.Kind is not "class" or "struct" or "interface" or "enum" or "namespace") - { - return false; - } - - var enclosingScope = scopeCandidates == null - ? null - : FindInnermostContainer(scopeCandidates, lineNumber); - if (enclosingScope?.BodyStartLine == null) - return true; - - return IsAtCSharpXmlDocAttachmentDepth(enclosingScope, preparedLines, lineNumber); - } - - private static bool HasOnlyCSharpWhitespaceOrAttributesBetweenCommentAndDeclaration( - IReadOnlyList?>? csharpAttrRanges, - string[] preparedLines, - int commentLineNumber, - int declarationLineNumber) - { - var startLineIndex = Math.Max(commentLineNumber, 0); - var endLineIndex = Math.Min(declarationLineNumber - 1, preparedLines.Length); - for (var lineIndex = startLineIndex; lineIndex < endLineIndex; lineIndex++) - { - var line = preparedLines[lineIndex]; - if (string.IsNullOrWhiteSpace(line)) - continue; - - if (IsCSharpAttributeOnlyLine(line, csharpAttrRanges?[lineIndex])) - continue; - - return false; - } - - return true; - } - - private static bool IsCSharpAttributeOnlyLine(string preparedLine, IReadOnlyList<(int start, int end)>? ranges) - { - if (ranges == null || ranges.Count == 0) - return false; - - for (var i = 0; i < preparedLine.Length; i++) - { - if (char.IsWhiteSpace(preparedLine[i])) - continue; - - var covered = false; - foreach (var (start, end) in ranges) - { - if (i >= start && i < end) - { - covered = true; - break; - } - } - - if (!covered) - return false; - } - - return true; - } - - private static bool IsAtCSharpXmlDocAttachmentDepth( - SymbolRecord enclosingScope, - string[] preparedLines, - int lineNumber) - { - var scopeBodyStartIndex = enclosingScope.BodyStartLine!.Value - 1; - var commentLineIndex = lineNumber - 1; - if (scopeBodyStartIndex < 0 - || scopeBodyStartIndex >= preparedLines.Length - || scopeBodyStartIndex >= commentLineIndex) - { - return true; - } - - var sawScopeOpenBrace = false; - var nestedBraceDepth = 0; - var angleDepth = 0; - var parenDepth = 0; - var bracketDepth = 0; - var topLevelExecutableContinuation = false; - var topLevelArrowExpressionContinuation = false; - - for (var i = scopeBodyStartIndex; i < commentLineIndex && i < preparedLines.Length; i++) - { - var line = preparedLines[i]; - for (var j = 0; j < line.Length; j++) - { - var ch = line[j]; - if (!sawScopeOpenBrace) - { - if (ch == '{') - sawScopeOpenBrace = true; - - continue; - } - - if (nestedBraceDepth == 0) - { - if (ch == '<') - { - angleDepth++; - continue; - } - - if (ch == '>' && angleDepth > 0) - { - angleDepth--; - continue; - } - - if (IsCSharpTopLevelArrowToken(line, j)) - { - topLevelExecutableContinuation = true; - topLevelArrowExpressionContinuation = !IsCSharpArrowBlockStart(line, j + 2); - j++; - continue; - } - - if (IsCSharpTopLevelAssignmentOperator(line, j)) - { - topLevelExecutableContinuation = true; - } - } - - if (ch == '{') - { - nestedBraceDepth++; - } - else if (ch == '}') - { - if (nestedBraceDepth == 0) - return false; - - nestedBraceDepth--; - } - else if (ch == '(') - { - parenDepth++; - } - else if (ch == ')' && parenDepth > 0) - { - parenDepth--; - } - else if (ch == '[') - { - bracketDepth++; - } - else if (ch == ']' && bracketDepth > 0) - { - bracketDepth--; - } - else if (nestedBraceDepth == 0 - && ch == ';' - && parenDepth == 0 - && bracketDepth == 0) - { - topLevelExecutableContinuation = false; - topLevelArrowExpressionContinuation = false; - } - } - } - - return !sawScopeOpenBrace - || (nestedBraceDepth == 0 - && angleDepth == 0 - && parenDepth == 0 - && bracketDepth == 0 - && !topLevelExecutableContinuation - && !topLevelArrowExpressionContinuation); - } - - private static (bool[] MultilineStringContent, bool[] BlockComment) BuildCSharpLineStateMasks(string[] lines) - { - var insideStringContent = new bool[lines.Length]; - var insideBlockComment = new bool[lines.Length]; - var inBlockComment = false; - var inVerbatimString = false; - var rawStringDelimiterLength = 0; - - for (var i = 0; i < lines.Length; i++) - { - var line = lines[i]; - insideStringContent[i] = inVerbatimString || rawStringDelimiterLength > 0; - insideBlockComment[i] = inBlockComment; - - var index = 0; - while (index < line.Length) - { - if (inBlockComment) - { - var closeIndex = line.IndexOf("*/", index, StringComparison.Ordinal); - if (closeIndex < 0) - break; - - index = closeIndex + 2; - inBlockComment = false; - continue; - } - - if (rawStringDelimiterLength > 0) - { - var closeCandidateIndex = index; - while (closeCandidateIndex < line.Length && char.IsWhiteSpace(line[closeCandidateIndex])) - closeCandidateIndex++; - - var closeLength = CountCharacterRun(line, closeCandidateIndex, '"'); - if (closeLength >= rawStringDelimiterLength - && closeLength > 0) - { - rawStringDelimiterLength = 0; - index = closeCandidateIndex + closeLength; - continue; - } - - break; - } - - if (inVerbatimString) - { - if (line[index] == '"' && index + 1 < line.Length && line[index + 1] == '"') - { - index += 2; - continue; - } - - if (line[index] == '"') - { - index++; - inVerbatimString = false; - continue; - } - - index++; - continue; - } - - if (StartsWithOrdinal(line, index, "//")) - break; - - if (StartsWithOrdinal(line, index, "/*")) - { - inBlockComment = true; - index += 2; - continue; - } - - if (TryStartCSharpRawString(line, index, out var rawOpeningLength, out var rawDelimiterLength)) - { - rawStringDelimiterLength = rawDelimiterLength; - index += rawOpeningLength; - continue; - } - - if (TryStartCSharpVerbatimString(line, index, out var verbatimOpeningLength)) - { - inVerbatimString = true; - index += verbatimOpeningLength; - continue; - } - - if (TryStartCSharpRegularString(line, index, out var regularOpeningLength)) - { - index += regularOpeningLength; - while (index < line.Length) - { - if (line[index] == '\\') - { - index += Math.Min(2, line.Length - index); - continue; - } - - if (line[index] == '"') - { - index++; - break; - } - - index++; - } - - continue; - } - - if (line[index] == '\'') - { - index++; - while (index < line.Length) - { - if (line[index] == '\\') - { - index += Math.Min(2, line.Length - index); - continue; - } - - if (line[index] == '\'') - { - index++; - break; - } - - index++; - } - - continue; - } - - index++; - } - } - - return (insideStringContent, insideBlockComment); - } - - private static bool IsCSharpTopLevelAssignmentOperator(string line, int index) - { - if (index < 0 || index >= line.Length || line[index] != '=') - return false; - - var previous = index > 0 ? line[index - 1] : '\0'; - var next = index + 1 < line.Length ? line[index + 1] : '\0'; - return previous is not ('=' or '!' or '<' or '>') - && next is not ('=' or '>'); - } - - private static bool IsCSharpTopLevelArrowToken(string line, int index) => - index >= 0 - && index + 1 < line.Length - && line[index] == '=' - && line[index + 1] == '>'; - - private static bool IsCSharpArrowBlockStart(string line, int index) - { - while (index < line.Length && char.IsWhiteSpace(line[index])) - index++; - - return index < line.Length && line[index] == '{'; - } - - private static int GetCSharpSameLineDocumentedDeclarationStartColumn( - string originalLine, - int commentEndExclusive, - bool nextDelimitedDocComment) - { - if (nextDelimitedDocComment - || commentEndExclusive < 0 - || commentEndExclusive + 1 >= originalLine.Length - || originalLine[commentEndExclusive] != '*' - || originalLine[commentEndExclusive + 1] != '/') - { - return -1; - } - - var column = commentEndExclusive + 2; - while (column < originalLine.Length && char.IsWhiteSpace(originalLine[column])) - column++; - - return column < originalLine.Length ? column : -1; - } - - private static bool HasOnlyCSharpWhitespaceOrAttributesAfterColumn( - string preparedLine, - IReadOnlyList<(int start, int end)>? ranges, - int startColumn) - { - if (startColumn < 0 || startColumn >= preparedLine.Length) - return true; - - for (var i = startColumn; i < preparedLine.Length; i++) - { - if (char.IsWhiteSpace(preparedLine[i])) - continue; - - if (ranges != null) - { - var covered = false; - foreach (var (start, end) in ranges) - { - if (i >= start && i < end) - { - covered = true; - break; - } - } - - if (covered) - continue; - } - - return false; - } - - return true; - } - - private static bool TryGetJvmDocCommentSpan( - string originalLine, - bool inDelimitedDocComment, - out int commentStart, - out int commentEndExclusive, - out int sameLineDeclarationStartColumn, - out bool nextDelimitedDocComment) - { - commentStart = -1; - commentEndExclusive = -1; - sameLineDeclarationStartColumn = -1; - nextDelimitedDocComment = inDelimitedDocComment; - - var lineStart = 0; - while (lineStart < originalLine.Length && char.IsWhiteSpace(originalLine[lineStart])) - lineStart++; - - if (!inDelimitedDocComment) - { - if (lineStart + 3 > originalLine.Length - || originalLine[lineStart] != '/' - || originalLine[lineStart + 1] != '*' - || originalLine[lineStart + 2] != '*') - { - return false; - } - - commentStart = lineStart + 3; - } - else - { - commentStart = lineStart; - if (commentStart < originalLine.Length && originalLine[commentStart] == '*') - { - if (commentStart + 1 < originalLine.Length && originalLine[commentStart + 1] == '/') - { - commentEndExclusive = commentStart; - nextDelimitedDocComment = false; - sameLineDeclarationStartColumn = GetJvmSameLineDeclarationStartColumn(originalLine, commentStart); - return true; - } - - commentStart++; - if (commentStart < originalLine.Length && originalLine[commentStart] == ' ') - commentStart++; - } - } - - var closeIndex = originalLine.IndexOf("*/", commentStart, StringComparison.Ordinal); - if (closeIndex >= 0) - { - commentEndExclusive = closeIndex; - nextDelimitedDocComment = false; - sameLineDeclarationStartColumn = GetJvmSameLineDeclarationStartColumn(originalLine, closeIndex); - } - else - { - commentEndExclusive = originalLine.Length; - nextDelimitedDocComment = true; - } - - return true; - } - - private static int GetJvmSameLineDeclarationStartColumn(string originalLine, int commentEndExclusive) - { - if (commentEndExclusive + 1 >= originalLine.Length - || originalLine[commentEndExclusive] != '*' - || originalLine[commentEndExclusive + 1] != '/') - { - return -1; - } - - var column = commentEndExclusive + 2; - while (column < originalLine.Length && char.IsWhiteSpace(originalLine[column])) - column++; - - return column < originalLine.Length ? column : -1; - } - - private static SymbolRecord? FindJvmDocumentedContainer( - IReadOnlyList candidates, - IReadOnlyList originalLines, - string structuralLine, - int lineNumber, - int sameLineDeclarationStartColumn) - { - var innermostContainer = FindInnermostContainer(candidates, lineNumber); - if (innermostContainer?.Kind is "function" or "property") - return null; - - var sameLineCandidate = FindSameLineDocumentedContainer( - candidates, - structuralLine, - lineNumber, - sameLineDeclarationStartColumn); - if (sameLineCandidate != null) - return sameLineCandidate; - - SymbolRecord? best = null; - foreach (var candidate in candidates) - { - if (candidate.StartLine <= lineNumber) - continue; - if (!HasOnlyJvmDocTriviaBeforeDeclaration(originalLines, lineNumber, candidate.StartLine)) - continue; - - if (best == null - || candidate.StartLine < best.StartLine - || (candidate.StartLine == best.StartLine - && ((candidate.BodyEndLine ?? candidate.EndLine) - (candidate.BodyStartLine ?? candidate.StartLine)) - < ((best.BodyEndLine ?? best.EndLine) - (best.BodyStartLine ?? best.StartLine)))) - { - best = candidate; - } - } - - return best; - } - - private static bool HasOnlyJvmDocTriviaBeforeDeclaration( - IReadOnlyList originalLines, - int docLineNumber, - int declarationLineNumber) - { - for (var lineIndex = docLineNumber; lineIndex < declarationLineNumber - 1 && lineIndex < originalLines.Count; lineIndex++) - { - var trimmed = originalLines[lineIndex].TrimStart(); - if (trimmed.Length == 0 - || trimmed.StartsWith("/**", StringComparison.Ordinal) - || trimmed.StartsWith("*", StringComparison.Ordinal) - || trimmed.StartsWith("@", StringComparison.Ordinal)) - { - continue; - } - - return false; - } - - return true; - } - - private static SymbolRecord? FindDocumentedContainer( - IReadOnlyList candidates, - string structuralLine, - string preparedLine, - IReadOnlyList<(int start, int end)>? csharpAttrRangesOnLine, - int lineNumber, - int sameLineDeclarationStartColumn) - { - var sameLineCandidate = FindSameLineDocumentedContainer( - candidates, - structuralLine, - lineNumber, - sameLineDeclarationStartColumn); - if (sameLineCandidate != null) - return sameLineCandidate; - if (sameLineDeclarationStartColumn >= 0 - && !HasOnlyCSharpWhitespaceOrAttributesAfterColumn( - preparedLine, - csharpAttrRangesOnLine, - sameLineDeclarationStartColumn)) - { - return null; - } - - SymbolRecord? best = null; - foreach (var candidate in candidates) - { - if (candidate.StartLine <= lineNumber) - continue; - - if (best == null - || candidate.StartLine < best.StartLine - || (candidate.StartLine == best.StartLine - && ((candidate.BodyEndLine ?? candidate.EndLine) - (candidate.BodyStartLine ?? candidate.StartLine)) - < ((best.BodyEndLine ?? best.EndLine) - (best.BodyStartLine ?? best.StartLine)))) - { - best = candidate; - } - } - - return best; - } - - private static SymbolRecord? FindSameLineDocumentedContainer( - IReadOnlyList candidates, - string structuralLine, - int lineNumber, - int sameLineDeclarationStartColumn) - { - if (sameLineDeclarationStartColumn < 0) - return null; - - SymbolRecord? best = null; - var bestStartColumn = int.MaxValue; - var bestSpanLength = int.MaxValue; - var bestKindRank = int.MaxValue; - - foreach (var candidate in candidates) - { - if (candidate.StartLine != lineNumber - || candidate.EndLine != lineNumber - || string.IsNullOrEmpty(candidate.Signature)) - { - continue; - } - - if (!TryGetSameLineSignatureSpan(candidate, structuralLine, out var startColumn, out var endColumn) - || startColumn < sameLineDeclarationStartColumn) - { - continue; - } - - var spanLength = endColumn - startColumn; - var kindRank = GetSameLineContainerKindRank(candidate.Kind); - if (best == null - || startColumn < bestStartColumn - || (startColumn == bestStartColumn && spanLength < bestSpanLength) - || (startColumn == bestStartColumn && spanLength == bestSpanLength && kindRank < bestKindRank)) - { - best = candidate; - bestStartColumn = startColumn; - bestSpanLength = spanLength; - bestKindRank = kindRank; - } - } - - return best; - } - - private static SymbolRecord? FindInnermostSameLineCSharpContainer( - IReadOnlyList candidates, - string structuralLine, - int lineNumber, - int column) - { - SymbolRecord? best = null; - var bestStartColumn = -1; - var bestSpanLength = int.MaxValue; - var bestKindRank = int.MaxValue; - - foreach (var candidate in candidates) - { - if (candidate.BodyStartLine == null - || candidate.BodyEndLine == null - || candidate.BodyStartLine.Value > lineNumber - || candidate.BodyEndLine.Value < lineNumber - || candidate.StartLine != lineNumber - || candidate.EndLine != lineNumber - || string.IsNullOrEmpty(candidate.Signature)) - { - continue; - } - - if (!TryGetSameLineSignatureSpan(candidate, structuralLine, out var startColumn, out var endColumn)) - continue; - - if (column < startColumn || column >= endColumn) - continue; - - if (candidate.Kind == "function" - && (!TryFindCSharpFunctionNameColumn(structuralLine, candidate.Name, out var nameColumn) - || column < nameColumn)) - { - continue; - } - - var spanLength = endColumn - startColumn; - var kindRank = GetSameLineContainerKindRank(candidate.Kind); - if (best == null - || startColumn > bestStartColumn - || (startColumn == bestStartColumn && spanLength < bestSpanLength) - || (startColumn == bestStartColumn && spanLength == bestSpanLength && kindRank < bestKindRank)) - { - best = candidate; - bestStartColumn = startColumn; - bestSpanLength = spanLength; - bestKindRank = kindRank; - } - } - - return best; - } - - private static Dictionary>? BuildCSharpSameLineContainerCandidatesByLine( - string language, - IReadOnlyList candidates) - { - if (language != "csharp") - return null; - - Dictionary>? candidatesByLine = null; - foreach (var candidate in candidates) - { - if (candidate.BodyStartLine == null - || candidate.BodyEndLine == null - || candidate.StartLine != candidate.EndLine - || string.IsNullOrEmpty(candidate.Signature)) - { - continue; - } - - candidatesByLine ??= new Dictionary>(); - if (!candidatesByLine.TryGetValue(candidate.StartLine, out var lineCandidates)) - { - lineCandidates = []; - candidatesByLine.Add(candidate.StartLine, lineCandidates); - } - - lineCandidates.Add(candidate); - } - - return candidatesByLine; - } - - private static SymbolRecord? FindInnermostSameLineCSharpContainer( - IReadOnlyDictionary>? candidatesByLine, - string structuralLine, - int lineNumber, - int column) - => candidatesByLine != null && candidatesByLine.TryGetValue(lineNumber, out var candidates) - ? FindInnermostSameLineCSharpContainer(candidates, structuralLine, lineNumber, column) - : null; - - private static SymbolRecord? FindInnermostCSharpDeclarationRangeContainer( - IReadOnlyList candidates, - string structuralLine, - int lineNumber, - int column) - { - SymbolRecord? best = null; - var bestRange = int.MaxValue; - - foreach (var candidate in candidates) - { - if (candidate.Kind != "function" - || candidate.BodyStartLine == null - || candidate.BodyEndLine == null - || candidate.StartLine > lineNumber - || candidate.BodyStartLine.Value < lineNumber - || candidate.BodyEndLine.Value < lineNumber) - { - continue; - } - - if (candidate.StartLine == lineNumber - && (!TryFindCSharpFunctionNameColumn(structuralLine, candidate.Name, out var nameColumn) - || column < nameColumn)) - { - continue; - } - - var range = candidate.BodyEndLine.Value - candidate.StartLine; - if (best == null || range < bestRange) - { - best = candidate; - bestRange = range; - } - } - - return best; - } - - private static bool TryFindCSharpFunctionNameColumn(string structuralLine, string? name, out int column) - { - column = -1; - if (string.IsNullOrWhiteSpace(structuralLine) || string.IsNullOrWhiteSpace(name)) - return false; - - var searchStart = 0; - while (searchStart < structuralLine.Length) - { - var index = structuralLine.IndexOf(name, searchStart, StringComparison.Ordinal); - if (index < 0) - return false; - - var before = index - 1; - if (before >= 0 && IsTypeExpressionIdentifierPart("csharp", structuralLine[before])) - { - searchStart = index + name.Length; - continue; - } - - var afterName = index + name.Length; - if (afterName < structuralLine.Length && IsTypeExpressionIdentifierPart("csharp", structuralLine[afterName])) - { - searchStart = afterName; - continue; - } - - var after = SkipWhitespace(structuralLine, afterName); - if (after < structuralLine.Length && structuralLine[after] == '<') - { - var genericClose = FindMatchingChar(structuralLine, after, '<', '>'); - if (genericClose > after) - after = SkipWhitespace(structuralLine, genericClose + 1); - } - - if (after < structuralLine.Length && structuralLine[after] == '(') - { - column = index; - return true; - } - - searchStart = afterName; - } - - return false; - } - - private static bool TryGetSameLineSignatureSpan( - SymbolRecord candidate, - string structuralLine, - out int startColumn, - out int endColumn) - { - startColumn = candidate.StartColumn ?? -1; - if (startColumn < 0 || startColumn > structuralLine.Length) - { - startColumn = FindSignatureOccurrenceStartColumn( - structuralLine, - candidate.Signature!, - candidate.SameLineSignatureOccurrenceIndex ?? 0); - if (startColumn < 0) - { - endColumn = -1; - return false; - } - } - - endColumn = Math.Min(structuralLine.Length, startColumn + candidate.Signature!.Length); - return endColumn > startColumn; - } - - private static int FindSignatureOccurrenceStartColumn(string structuralLine, string signature, int occurrenceIndex) - { - if (occurrenceIndex < 0 || string.IsNullOrEmpty(structuralLine) || string.IsNullOrEmpty(signature)) - return -1; - - var currentOccurrence = 0; - var searchStart = 0; - while (searchStart < structuralLine.Length) - { - var matchIndex = structuralLine.IndexOf(signature, searchStart, StringComparison.Ordinal); - if (matchIndex < 0) - return -1; - - if (currentOccurrence == occurrenceIndex) - return matchIndex; - - currentOccurrence++; - searchStart = matchIndex + signature.Length; - } - - return -1; - } - - private static bool TryStartCSharpRawString( - string line, - int startIndex, - out int openingLength, - out int delimiterLength) - { - openingLength = 0; - delimiterLength = 0; - - var quoteIndex = startIndex; - while (quoteIndex < line.Length && line[quoteIndex] == '$') - quoteIndex++; - - delimiterLength = CountCharacterRun(line, quoteIndex, '"'); - if (delimiterLength < 3) - return false; - - openingLength = (quoteIndex - startIndex) + delimiterLength; - return true; - } - - private static bool TryStartCSharpVerbatimString(string line, int startIndex, out int openingLength) - { - openingLength = 0; - if (StartsWithOrdinal(line, startIndex, "$@\"") || StartsWithOrdinal(line, startIndex, "@$\"")) - { - openingLength = 3; - return true; - } - - if (!StartsWithOrdinal(line, startIndex, "@\"")) - return false; - - openingLength = 2; - return true; - } - - private static bool TryStartCSharpRegularString(string line, int startIndex, out int openingLength) - { - openingLength = 0; - if (StartsWithOrdinal(line, startIndex, "$\"")) - { - openingLength = 2; - return true; - } - - if (line[startIndex] != '"') - return false; - - openingLength = 1; - return true; - } - - private static bool StartsWithOrdinal(string line, int startIndex, string value) - { - if (startIndex + value.Length > line.Length) - return false; - - return string.Compare(line, startIndex, value, 0, value.Length, StringComparison.Ordinal) == 0; - } - - private static int CountCharacterRun(string line, int startIndex, char value) - { - var index = startIndex; - while (index < line.Length && line[index] == value) - index++; - - return index - startIndex; - } - - private static int GetSameLineContainerKindRank(string? kind) => kind switch - { - "function" => 0, - "property" => 1, - "class" => 2, - "struct" => 3, - "interface" => 4, - "enum" => 5, - "namespace" => 6, - _ => 7, - }; - - internal static SymbolRecord? FindInnermostClassLike(IReadOnlyList candidates, int lineNumber) - { - foreach (var candidate in candidates) - { - // class/struct/enum are all ctor-owner kinds across supported languages. Java enum bodies - // can declare constructors and chain via `this(...)`; C# enum cannot declare constructors - // at all, so the chain regex will not match inside one even if we pick it up here. - // class/struct/enum はいずれもコンストラクタを持ちうる宿主種別。Java enum は `this(...)` - // 連鎖を書けるため含める。C# enum はコンストラクタ自体を持てないので副作用は出ない。 - if (candidate.Kind != "class" && candidate.Kind != "struct" && candidate.Kind != "enum") - continue; - if (candidate.BodyStartLine!.Value <= lineNumber && candidate.BodyEndLine!.Value >= lineNumber) - return candidate; - } - - return null; - } - - /// - /// Same-line Java ctor span capturing the declarator name plus the 0-based indices of the - /// ctor name, the opening `{` of the body, and the matching `}` on the same line (or -1 - /// when no matching close brace is found). Used to override the container for body-level - /// calls and to suppress the bogus declarator self-call on the ctor name. - /// same-line Java ctor の宣言情報。ctor 名位置・body `{` 位置・body `}` 位置を保持し、 - /// body 内の call に合成 function コンテナを流すのと、宣言子 `CtorName(` が誤って - /// call として記録されるのを抑止するのに使う。 - /// - internal readonly record struct JavaSameLineCtorSpan( - string Name, - int NameIndex, - int OpenBraceIndex, - int CloseBraceIndex); - - /// - /// Depth-aware scanner for `@Annot ... > Ctor(...) { ... }` - /// style declarations. Returns the constructor name when the line opens a ctor body, or - /// null otherwise. Handles qualified annotations (`@demo.Ann`), annotation argument lists - /// with nested parens, and nested generic bounds that a flat regex cannot balance. - /// 修飾付きアノテーション・引数付きアノテーション・入れ子の generic 境界を含む - /// same-line ctor 宣言を depth-aware にスキャンして ctor 名を返すヘルパー。 - /// - internal static string? TryExtractJavaCtorNameFromLine(string line) - => JavaReferenceExtractor.TryExtractCtorNameFromLine(line); - - /// - /// Same as but also returns the ctor name - /// index, body-open `{` index, and the matching body-close `}` index on the same line. - /// `TryExtractJavaCtorNameFromLine` と同じスキャナだが、ctor 名位置・`{` 位置・対応する - /// `}` 位置もまとめて返すバリアント。 - /// - internal static JavaSameLineCtorSpan? TryExtractJavaSameLineCtorSpan(string line) - => JavaReferenceExtractor.TryExtractSameLineCtorSpan(line); - - private static void AddChainReference( - List references, - ReferenceDedupeSet seen, - long fileId, - string name, - int column, - string referenceKind, - string context, - int lineNumber, - SymbolRecord? container) - { - var dedupeKey = CreateReferenceDedupeKey(fileId, null, lineNumber, column, referenceKind, name, container); - if (!seen.Add(dedupeKey)) - return; - - TryAddReference(references, new ReferenceRecord - { - FileId = fileId, - SymbolName = name, - ReferenceKind = referenceKind, - Line = lineNumber, - Column = column, - Context = context, - ContainerKind = container?.Kind, - ContainerName = container?.Name, - }); - } - - private static void EmitMethodGroupReferences( - string language, - string preparedLine, - HashSet? callableDefinitionNames, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (callableDefinitionNames == null || callableDefinitionNames.Count == 0) - return; - - foreach (Match match in MethodGroupReferenceRegex.Matches(preparedLine)) - { - var contextTargetGroup = match.Groups["contextTarget"]; - if (contextTargetGroup.Success && MethodGroupContextTargetIgnoreNames.Contains(contextTargetGroup.Value)) - continue; - if (!contextTargetGroup.Success) - { - var prefix = preparedLine.AsSpan(0, match.Groups["name"].Index).TrimEnd(); - if (prefix.EndsWith("+=", StringComparison.Ordinal) || prefix.EndsWith("-=", StringComparison.Ordinal)) - continue; - } - - var nameGroup = match.Groups["name"]; - var rawName = nameGroup.Value; - var name = language == "csharp" ? NormalizeCSharpIdentifier(rawName) : rawName; - if (!callableDefinitionNames.Contains(name)) - continue; - - var container = resolveContainerForColumn(nameGroup.Index); - AddChainReference(references, seen, fileId, name, nameGroup.Index, "call", context, lineNumber, container); - } - } - - /// - /// Build a list of line ranges paired with synthetic function-kind containers for C# primary - /// constructor declarations that carry a base primary-constructor call. This covers records - /// (`record Child(int x) : Parent(x)`), C# 12 classes (`class Child(int x) : Parent(x)`) and - /// structs (`struct Child(int x) : Parent(x)`), including the multi-line form where - /// `: Parent(x)` sits on a continuation line. SymbolExtractor does not synthesize a separate - /// ctor symbol for the implicit primary constructor, so the `Parent(x)` reference would - /// otherwise land on `container = null` (when the declaration line has no body range) or on - /// the declaring type itself. The synthetic container covers the header range only; methods - /// inside a braced body still resolve to their real containers via FindInnermostContainer, - /// and within the end line the override is limited to columns before the terminator so body - /// calls sharing the same line (e.g. `record Child(int V) : Parent(V) { ... Add(V, 1); }`) - /// are not pulled onto the synthetic ctor. - /// C# の primary constructor 宣言に対して合成 function コンテナの (start, end, endColumn, container) - /// リストを作る。record だけでなく C# 12 の class / struct primary constructor も対象にし、 - /// 宣言ヘッダーの範囲(end line は終端 `;` / `{` のカラムまで)だけ合成 ctor に差し替えることで、 - /// 同一行 braced body の呼び出しや後続メソッドは本来の container に残る。 - /// - private static List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container)> BuildCSharpPrimaryCtorContainers( - string language, - IReadOnlyList symbols, - string[] structuralLines) - { - if (language != "csharp") - return []; - - var ranges = new List<(int, int, int, int, SymbolRecord)>(4); - foreach (var symbol in symbols) - { - // SymbolExtractor stores C# records as Kind=class and C# 12 structs as Kind=struct. - // Interfaces / enums / delegates cannot have primary constructors in C# so skip them. - // C# record は Kind=class、C# 12 struct は Kind=struct として登録されるため両方対象。 - if (symbol.Kind != "class" && symbol.Kind != "struct") - continue; - var signature = symbol.Signature; - if (string.IsNullOrWhiteSpace(signature)) - continue; - - // SymbolRecord.Signature only captures the first declaration line, so the first-line - // regex filter misses split-line primary-ctor forms such as - // `public record Child\n(\n int Value\n)\n : Parent(Value);`. Walk the - // structural-masked lines from StartLine until we hit `;` / `{` and run the - // primary-ctor detection on the joined header text instead. - // 宣言の signature は 1 行目だけしか持たないので、`record` / `class` / `struct` と - // `(` を別行に分ける書式では先頭行 regex の前段フィルタが空振りする。ここでは - // structuralLines から `;` / `{` までヘッダーを連結し、連結後のテキストで判定する。 - var (headerEndLine, headerEndColumn, headerText) = CollectCSharpRecordHeader(structuralLines, symbol.StartLine); - if (!IsCSharpPrimaryCtorHeader(headerText)) - continue; - if (!HasCSharpBasePrimaryCtorCall(headerText)) - continue; - - // Restrict the synthetic container to the actual declaration span, starting at the - // `class` / `struct` / `record` keyword column on the start line. Without this - // same-line tokens BEFORE the keyword (e.g. attribute arguments in - // `[Attr(Helper.Get())] public class Child(int x) : Parent(x) {}`) would get - // attributed to the synthetic ctor and pollute callers / impact with phantom - // `Child` callers for `Attr` and `Helper.Get`. - // 合成 ctor コンテナを本物の宣言範囲に限定する。`class` / `struct` / `record` - // キーワード位置より前(同一行の属性呼び出しなど)は本来の container に残す。 - var startColumn = FindCSharpPrimaryCtorKeywordColumn(structuralLines, symbol.StartLine); - - var synthetic = new SymbolRecord - { - FileId = symbol.FileId, - Kind = "function", - Name = symbol.Name, - Line = symbol.Line, - StartLine = symbol.StartLine, - EndLine = headerEndLine, - BodyStartLine = symbol.StartLine, - BodyEndLine = headerEndLine, - Signature = signature, - ContainerKind = symbol.ContainerKind, - ContainerName = symbol.ContainerName, - ContainerQualifiedName = symbol.ContainerQualifiedName, - FamilyKey = symbol.FamilyKey, - Visibility = symbol.Visibility, - }; - - ranges.Add((symbol.StartLine, startColumn, headerEndLine, headerEndColumn, synthetic)); - } - - return ranges; - } - - private static int FindCSharpPrimaryCtorKeywordColumn(string[] structuralLines, int startLine) - { - var idx = Math.Max(0, startLine - 1); - if (idx >= structuralLines.Length) - return 0; - var line = structuralLines[idx]; - foreach (var keyword in CSharpPrimaryCtorKeywords) - { - int pos = 0; - while (pos < line.Length) - { - var found = line.IndexOf(keyword, pos, StringComparison.Ordinal); - if (found < 0) break; - var before = found == 0 ? ' ' : line[found - 1]; - var afterIdx = found + keyword.Length; - var after = afterIdx < line.Length ? line[afterIdx] : ' '; - if (!IsCSharpIdentifierPart(before) && !IsCSharpIdentifierPart(after)) - return found; - pos = found + 1; - } - } - return 0; - } - - private static readonly string[] CSharpPrimaryCtorKeywords = { "record", "class", "struct" }; - - private static bool IsCSharpIdentifierPart(char c) => char.IsLetterOrDigit(c) || c == '_'; - - /// - /// Walk structural-masked lines starting at the 1-based and collect - /// the declaration header up to (but not including) the first `;` or `{` that sits outside a - /// string or comment. Returns the 1-based line number where the terminator was found (or the - /// final line index when none was found) and the joined header text for further parsing. - /// Reused for record primary-ctor container synthesis and multi-line `: base(...)` resolution. - /// structuralLines を使って、class / struct / record 宣言ヘッダーを最初の `;` / `{` まで連結する。 - /// record primary-ctor のコンテナ合成と、複数行 `: base(...)` 解決の両方で使う。 - /// - internal static (int EndLine, int EndColumn, string Text) CollectCSharpRecordHeader(string[] structuralLines, int startLine) - { - var startIdx = Math.Max(0, startLine - 1); - if (structuralLines.Length == 0) - return (startLine, int.MaxValue, string.Empty); - - // Depth-aware termination so that `{` / `;` inside annotation arg lists (e.g. the `{` in - // `@Ann({A.class, B.class})`) or attribute-argument brackets does not cut the header off - // before the real base-list terminator, which would silently drop the base type. - // We intentionally do NOT track `<` / `>` as generic depth here: comparison operators - // inside annotation / attribute expressions (e.g. `[Attr(Flag = 1 < 2)]` or - // `@Ann(flag = 1 < 2)`) are raised as `<` without a matching `>`, so angle-depth tracking - // would leave the counter pinned above zero and silently drop the real top-level `{` / `;` - // terminator, letting the synthetic primary-ctor container or the Java base-type parse - // swallow everything up to EOF. `{` / `;` cannot legally appear inside a top-level - // `<...>` generic arg list in either C# or Java, so paren/bracket masking is sufficient. - // EndColumn tracks the column index of the top-level terminator on the end line, or - // int.MaxValue when no terminator was found (end-of-file), so call-site-scoped container - // overrides can restrict themselves to the header portion of the end line. - // アノテーション引数の `{` などを本当のヘッダ終端と誤認しないよう、`()` / `[]` の深さを追いながら - // 最初の top-level `;` / `{` でのみ終了する。`<` / `>` は annotation / attribute 式内の比較演算子で - // 非対称に現れうるため generic 深度として扱わない。 - // EndColumn は end line 上の終端 `;` / `{` の位置を返す(終端が無ければ int.MaxValue)。 - var sb = new System.Text.StringBuilder(); - int parenDepth = 0; - int bracketDepth = 0; - // Comment / string awareness so unbalanced `(` / `[` / `{` / `;` inside a line - // comment, block comment, or string literal never advances the depth counters, - // fires the terminator, or leaks into the returned header text. For Java `extends` - // headers the structuralLines array is an unmasked clone (StructuralLineMasker is a - // no-op for Java), so this is what keeps `class Leaf extends Root /* ( stray [ */ {` - // from pinning parenDepth / bracketDepth at 1 and skipping the real `{` terminator, - // and it also prevents ParseJavaBaseType from seeing the comment body when it parses - // the header text downstream. - // コメント・文字列内の不均衡な `(` / `[` / `{` / `;` を terminator 判定・連結テキスト双方から除外する。 - bool inBlockComment = false; - bool inString = false; - for (int i = startIdx; i < structuralLines.Length; i++) - { - var line = structuralLines[i]; - char[]? masked = null; - var terminatorIdx = -1; - void MaskChar(int index) - { - masked ??= line.ToCharArray(); - masked[index] = ' '; - } - - void MaskRange(int start, int endExclusive) - { - masked ??= line.ToCharArray(); - for (int k = start; k < endExclusive; k++) - masked[k] = ' '; - } - - for (int j = 0; j < line.Length; j++) - { - var c = line[j]; - - if (inBlockComment) - { - MaskChar(j); - if (c == '*' && j + 1 < line.Length && line[j + 1] == '/') - { - inBlockComment = false; - MaskChar(j + 1); - j++; - } - continue; - } - - if (inString) - { - MaskChar(j); - if (c == '\\' && j + 1 < line.Length) - { - MaskChar(j + 1); - j++; - continue; - } - if (c == '"') - inString = false; - continue; - } - - if (c == '/' && j + 1 < line.Length) - { - if (line[j + 1] == '/') - { - MaskRange(j, line.Length); - break; - } - if (line[j + 1] == '*') - { - inBlockComment = true; - MaskChar(j); - MaskChar(j + 1); - j++; - continue; - } - } - - if (c == '"') - { - inString = true; - MaskChar(j); - continue; - } - - if (c == '\'') - { - // Rust / OCaml lifetime annotation vs. char literal: only skip when a - // closing `'` exists within ~12 chars on this line. - // Rust の lifetime と char literal を短距離の閉じ `'` の有無で見分ける。 - var closeIdx = -1; - var limit = Math.Min(line.Length, j + 12); - for (int k = j + 1; k < limit; k++) - { - if (line[k] == '\\' && k + 1 < line.Length) - { - k++; - continue; - } - if (line[k] == '\'') - { - closeIdx = k; - break; - } - } - if (closeIdx > 0) - { - MaskRange(j, closeIdx + 1); - j = closeIdx; - } - continue; - } - - if (c == '(') parenDepth++; - else if (c == ')') { if (parenDepth > 0) parenDepth--; } - else if (c == '[') bracketDepth++; - else if (c == ']') { if (bracketDepth > 0) bracketDepth--; } - else if ((c == ';' || c == '{') && parenDepth == 0 && bracketDepth == 0) - { - terminatorIdx = j; - break; - } - } - - if (terminatorIdx >= 0) - { - if (masked == null) - sb.Append(line, 0, terminatorIdx); - else - sb.Append(masked, 0, terminatorIdx); - return (i + 1, terminatorIdx, sb.ToString()); - } - - if (masked == null) - sb.Append(line); - else - sb.Append(masked); - sb.Append('\n'); - } - - return (structuralLines.Length, int.MaxValue, sb.ToString()); - } - - /// - /// Returns true when the C# type header text carries a base-list entry that looks like a - /// primary-constructor call (contains `(`). Accepts multi-line header text already joined by - /// . - /// C# 型ヘッダー(複数行連結後でも可)の base-list 先頭エントリが `(` を含むかを判定する。 - /// - /// - /// Return true when a joined C# type-declaration header (possibly spanning multiple lines, - /// including line-broken primary-ctor parens) looks like a primary-constructor declaration. - /// Accepts `record Child(...)`, `record class Child(...)`, `record struct Child(...)`, - /// C# 12 `class Child(...)`, `struct Child(...)`, generic arity such as `class Child(...)`, - /// and the split-line form where `record Child\n(\n ... )` places the `(` on a continuation line. - /// 連結済みの C# 宣言ヘッダーが primary-ctor 宣言かを判定する。`record` だけでなく C# 12 の - /// `class` / `struct` primary constructor も対象にし、`(` が別行に分かれる書式にも対応する。 - /// - private static bool IsCSharpPrimaryCtorHeader(string headerText) - { - if (string.IsNullOrWhiteSpace(headerText)) - return false; - return CSharpPrimaryCtorHeaderRegex.IsMatch(headerText); - } - - private static bool HasCSharpBasePrimaryCtorCall(string headerText) - { - var text = headerText.TrimEnd(); - if (text.EndsWith(";", StringComparison.Ordinal)) - { - var end = text.Length - 1; - while (end > 0 && char.IsWhiteSpace(text[end - 1])) - end--; - text = text.Substring(0, end); - } - if (text.EndsWith("{", StringComparison.Ordinal)) - { - var end = text.Length - 1; - while (end > 0 && char.IsWhiteSpace(text[end - 1])) - end--; - text = text.Substring(0, end); - } - - var colonIndex = FindSignatureColonIndex(text); - if (colonIndex < 0) - return false; - - var baseList = text.Substring(colonIndex + 1); - var whereMatch = CSharpWhereClauseRegex.Match(baseList); - if (whereMatch.Success) - baseList = baseList.Substring(0, whereMatch.Index); - - var firstEntryText = TakeFirstBaseEntry(baseList); - var firstEntryStart = 0; - while (firstEntryStart < firstEntryText.Length && char.IsWhiteSpace(firstEntryText[firstEntryStart])) - firstEntryStart++; - - var firstEntryEnd = firstEntryText.Length; - while (firstEntryEnd > firstEntryStart && char.IsWhiteSpace(firstEntryText[firstEntryEnd - 1])) - firstEntryEnd--; - - var firstEntry = firstEntryText.Substring(firstEntryStart, firstEntryEnd - firstEntryStart); - // Only count a `(` that sits at generic / bracket depth 0 — a primary-ctor base call - // always puts its argument list directly after the bare type name, whereas generic args - // and array ranks can legally contain `(` (tuple syntax `<(int, int)>`, function types - // `>`, or attribute arg brackets). A naive `.Contains('(')` would treat - // those as primary-ctor calls and synthesize a phantom record ctor container. - // 先頭エントリのうち generic/bracket 深度 0 の `(` だけを primary-ctor 呼び出し扱いにする。 - // `IBox<(int, int)>` のような tuple を含む interface 実装を連鎖呼び出しと誤認させない。 - int angleDepth = 0; - int squareDepth = 0; - for (int i = 0; i < firstEntry.Length; i++) - { - var c = firstEntry[i]; - switch (c) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) angleDepth--; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) squareDepth--; - break; - case '(': - if (angleDepth == 0 && squareDepth == 0) - return true; - break; - } - } - return false; - } - - /// - /// Parse the first base-class token from a C# class/struct/record signature such as - /// `class B : A, IFoo`, `record C(int x) : A(x)`, or `class B : A where T : new()`. - /// Returns null when no base list is present or when the signature is empty. - /// C# の class/struct/record シグネチャから最初の基底クラストークンを取り出す。 - /// - internal static string? ParseCSharpBaseType(string? signature) - { - if (string.IsNullOrWhiteSpace(signature)) - return null; - - var text = signature.TrimEnd(); - if (text.EndsWith("{", StringComparison.Ordinal)) - { - var end = text.Length - 1; - while (end > 0 && char.IsWhiteSpace(text[end - 1])) - end--; - text = text.Substring(0, end); - } - - var colonIndex = FindSignatureColonIndex(text); - if (colonIndex < 0) - return null; - - var baseList = text.Substring(colonIndex + 1); - var whereMatch = CSharpWhereClauseRegex.Match(baseList); - if (whereMatch.Success) - baseList = baseList.Substring(0, whereMatch.Index); - - var firstEntryText = TakeFirstBaseEntry(baseList); - var firstEntryStart = 0; - while (firstEntryStart < firstEntryText.Length && char.IsWhiteSpace(firstEntryText[firstEntryStart])) - firstEntryStart++; - - var firstEntryEnd = firstEntryText.Length; - while (firstEntryEnd > firstEntryStart && char.IsWhiteSpace(firstEntryText[firstEntryEnd - 1])) - firstEntryEnd--; - - var firstEntry = firstEntryText.Substring(firstEntryStart, firstEntryEnd - firstEntryStart); - return ExtractBareTypeName(firstEntry); - } - - /// - /// Parse the first extends-clause type from a Java class/interface/record signature. - /// 例: `class B extends A implements IFoo` → `A`、 - /// `class Leaf extends Outer.Base {` → `Base`。 - /// - internal static string? ParseJavaBaseType(string? signature) - { - if (string.IsNullOrWhiteSpace(signature)) - return null; - - // Locate `extends` at angle/paren depth 0 so bounded type parameters like - // `class Leaf extends Root {` do not resolve to the - // parameter bound (`Number`) instead of the real base (`Root`). - // 境界付き型パラメータ(`class Leaf extends Root {`)で - // 型パラメータ境界の `extends` を先に拾わないよう、angle / paren 深度 0 の - // `extends` のみを検出する。 - int start = FindTopLevelExtendsEnd(signature!); - if (start < 0) - return null; - - int i = start; - int angleDepth = 0; - int parenDepth = 0; - while (i < signature.Length) - { - char c = signature[i]; - if (c == '<') - { - angleDepth++; - } - else if (c == '>') - { - if (angleDepth > 0) angleDepth--; - } - else if (c == '(') - { - // Track `(...)` depth so that commas inside annotation arguments such as - // `@Ann(a = 1, b = 2) Root` or `@Ann({A.class, B.class}) Root` are not mistaken - // for top-level base-list separators. Without this the scanner breaks at the - // inner `,`, feeds a truncated segment to the annotation stripper, and the - // super(...) edge gets misattributed or dropped entirely. - // annotation 引数内のカンマ(`@Ann(a = 1, b = 2) Root` や - // `@Ann({A.class, B.class}) Root`)が base-list 区切りと誤認されないよう `(...)` の - // 深さも追跡する。これをやらないと内側の `,` で走査が切れ、annotation stripper に - // 壊れたセグメントが渡って super(...) の連鎖エッジが落ちる。 - parenDepth++; - } - else if (c == ')') - { - if (parenDepth > 0) parenDepth--; - } - else if (angleDepth == 0 && parenDepth == 0) - { - if (c == '{' || c == ',' || c == ';') - break; - // Stop at a word-boundary `implements` or `permits` (Java 17+ sealed types). - // 単語境界の `implements` / `permits` (Java 17+ sealed 型) で停止する。 - if (IsJavaBaseListTerminatorKeyword(signature, i, start, "implements") || - IsJavaBaseListTerminatorKeyword(signature, i, start, "permits")) - { - break; - } - } - i++; - } - - var segment = signature.Substring(start, i - start).Trim(); - if (segment.Length == 0) - return null; - - // Strip Java type-use annotations (JLS 9.7.4): `@Ann`, `@pkg.Ann`, `@Ann(value=1)` can - // appear before the type itself (`extends @Ann Root`) or between nested-type segments - // (`Outer.@Ann Base`). Without this pass the base resolver returns a phantom - // type name like `@Ann Root` that misattributes references / callers / impact. - // Java の type-use annotation (JLS 9.7.4) を剥がす。`extends @Ann Root` や - // `Outer.@Ann Base` のような形で基底型の直前やセグメント間に現れるため、 - // 先に除去しないと `@Ann Root` のような幽霊シンボルへ参照が張られてしまう。 - segment = StripJavaTypeAnnotations(segment); - return segment.Length == 0 ? null : ExtractBareTypeName(segment); - } - - /// - /// Return the index past the first `extends` keyword that appears at angle/paren depth 0, - /// or -1 when no such occurrence exists. Matches the semantics of the old `\bextends\s+` - /// regex entrypoint but skips `extends` inside `<...>` (bounded type parameters) and - /// `(...)` (annotation argument lists). - /// - private static int FindTopLevelExtendsEnd(string signature) - { - int angleDepth = 0; - int parenDepth = 0; - for (int i = 0; i < signature.Length; i++) - { - char c = signature[i]; - if (c == '<') - { - angleDepth++; - } - else if (c == '>') - { - if (angleDepth > 0) angleDepth--; - } - else if (c == '(') - { - parenDepth++; - } - else if (c == ')') - { - if (parenDepth > 0) parenDepth--; - } - else if (angleDepth == 0 && parenDepth == 0 && IsExtendsKeywordAt(signature, i)) - { - int end = i + 7; // "extends".Length - while (end < signature.Length && char.IsWhiteSpace(signature[end])) - end++; - return end; - } - } - return -1; - } - - private static bool IsExtendsKeywordAt(string signature, int i) - { - const string Keyword = "extends"; - if (i + Keyword.Length > signature.Length) - return false; - if (i > 0 && IsJavaIdentifierPart(signature[i - 1])) - return false; - if (string.CompareOrdinal(signature, i, Keyword, 0, Keyword.Length) != 0) - return false; - int after = i + Keyword.Length; - // `\bextends\s+` equivalence: must be followed by whitespace so that names like - // `extendsFoo` or identifiers containing `extends` do not match. - // `\bextends\s+` 相当: `extendsFoo` のような識別子や合成語を誤認しないよう、 - // 直後に空白が続くものだけを `extends` キーワードとして扱う。 - if (after >= signature.Length) - return false; - return char.IsWhiteSpace(signature[after]); - } - - private static string StripJavaTypeAnnotations(string text) - { - if (text.IndexOf('@') < 0) - return text; - - var sb = new System.Text.StringBuilder(text.Length); - int i = 0; - while (i < text.Length) - { - char c = text[i]; - if (c == '@') - { - // Skip `@` + qualified identifier (`@pkg.Ann`) + optional balanced `(...)`. - i++; - while (i < text.Length && (IsJavaIdentifierPart(text[i]) || text[i] == '.')) - i++; - if (i < text.Length && text[i] == '(') - { - int parenDepth = 1; - i++; - while (i < text.Length && parenDepth > 0) - { - var ch = text[i]; - // Skip string / char literals so `@Ann(text=")")` does not close early. - // 文字列・文字リテラル内の `)` で早期終了しないようスキップする。 - if (ch == '"' || ch == '\'') - { - var quote = ch; - i++; - while (i < text.Length) - { - var lc = text[i]; - if (lc == '\\' && i + 1 < text.Length) { i += 2; continue; } - if (lc == quote) { i++; break; } - i++; - } - continue; - } - if (ch == '(') parenDepth++; - else if (ch == ')') parenDepth--; - i++; - } - } - // Drop a single trailing whitespace run so `@Ann Root` collapses to `Root`. - while (i < text.Length && char.IsWhiteSpace(text[i])) - i++; - continue; - } - sb.Append(c); - i++; - } - - return sb.ToString(); - } - - internal static bool IsJavaIdentifierPart(char c) => - char.IsLetterOrDigit(c) || c == '_' || c == '$'; - - private static bool IsJavaBaseListTerminatorKeyword(string signature, int i, int start, string keyword) => - IsJavaBaseListTerminatorKeyword(signature.AsSpan(), i, start, keyword); - - private static bool IsJavaBaseListTerminatorKeyword(ReadOnlySpan signature, int i, int start, string keyword) - { - var keywordSpan = keyword.AsSpan(); - if (i + keywordSpan.Length > signature.Length) - return false; - if (i != start && IsJavaIdentifierPart(signature[i - 1])) - return false; - if (!signature.Slice(i, keywordSpan.Length).SequenceEqual(keywordSpan)) - return false; - if (i + keywordSpan.Length < signature.Length && IsJavaIdentifierPart(signature[i + keywordSpan.Length])) - return false; - return true; - } - - private static int FindSignatureColonIndex(string text) - { - var depth = 0; - for (int i = 0; i < text.Length; i++) - { - var c = text[i]; - switch (c) - { - case '<': - case '(': - case '[': - depth++; - break; - case '>': - case ')': - case ']': - if (depth > 0) depth--; - break; - case ':': - if (depth == 0) - { - // Skip `::` alias qualifier (`global::System.Exception`). - // `::` エイリアス修飾子(`global::System.Exception`)はスキップ。 - if (i + 1 < text.Length && text[i + 1] == ':') - { - i++; - continue; - } - return i; - } - break; - } - } - - return -1; - } - - private static string TakeFirstBaseEntry(string baseList) - { - var depth = 0; - for (int i = 0; i < baseList.Length; i++) - { - var c = baseList[i]; - switch (c) - { - case '<': - case '(': - case '[': - depth++; - break; - case '>': - case ')': - case ']': - if (depth > 0) depth--; - break; - case ',': - if (depth == 0) - return baseList.Substring(0, i); - break; - } - } - - return baseList; - } - - private static string? ExtractBareTypeName(string entry) - { - var trimmed = entry.Trim(); - if (trimmed.Length == 0) - return null; - - // Split on `.` / `::` at generic depth 0, then return the last segment with generic - // args stripped. Naive "first `<`, then last `.`" slicing loses nested types such as - // `Outer.Base`, `Outer.Base`, or `global::Ns.Outer.Inner`. - // 最初の `<` で切ってから末尾 `.` を探す素朴な方法では `Outer.Base` のような - // ネスト型を取り違えるため、generic 深度 0 の `.` / `::` でセグメント分割して末尾だけ返す。 - int lastSegmentStart = 0; - int angleDepth = 0; - int endIndex = trimmed.Length; - for (int i = 0; i < trimmed.Length; i++) - { - var c = trimmed[i]; - if (c == '<') - { - angleDepth++; - } - else if (c == '>') - { - if (angleDepth > 0) angleDepth--; - } - else if (angleDepth == 0) - { - if (c == '(') - { - // Strip record primary-ctor args at top level: `A(...)` → `A`. - // record のプライマリコンストラクタ引数を剥がす。 - endIndex = i; - break; - } - if (c == '.') - { - lastSegmentStart = i + 1; - } - else if (c == ':' && i + 1 < trimmed.Length && trimmed[i + 1] == ':') - { - lastSegmentStart = i + 2; - i++; - } - } - } - - var segment = trimmed.Substring(lastSegmentStart, endIndex - lastSegmentStart).Trim(); - var ltIndex = segment.IndexOf('<'); - if (ltIndex >= 0) - segment = segment.Substring(0, ltIndex); - - segment = segment.Trim(); - return segment.Length > 0 ? segment : null; - } - - private readonly record struct ReferenceLinePrepareOptions( - bool UseCSharpTriggerFastPath, - bool MaskRustLifetimes, - bool MaskStringLiterals, - bool PreserveStringLiteralWidth, - bool MaskNimRawStrings, - bool IncludeBacktickStringDelimiter, - bool PreserveStringLiteralLength, - bool PreservePostfixSingleQuotes, - bool UseMatlabStringRules, - bool ScientificStringUsesBackslashEscapes, - bool UsesHashComments, - bool UsesRHashComments, - bool UsesSlashComments, - bool UsesDashDashComments, - bool UsesPercentComments, - bool UsesFortranBangComments, - bool UsesPascalBlockComments, - bool UsesVisualBasicComments); - - private static ReferenceLinePrepareOptions CreateReferenceLinePrepareOptions(string lang) - => new( - UseCSharpTriggerFastPath: lang == "csharp", - MaskRustLifetimes: lang == "rust", - MaskStringLiterals: lang != "cobol", - PreserveStringLiteralWidth: lang is "crystal" or "groovy" or "prolog" or "ambiguous_pl", - MaskNimRawStrings: lang == "nim", - IncludeBacktickStringDelimiter: lang is not ("kotlin" or "r"), - PreserveStringLiteralLength: ScientificNativeReferenceExtractor.Supports(lang), - PreservePostfixSingleQuotes: lang is "ada" or "julia" or "matlab", - UseMatlabStringRules: lang == "matlab", - ScientificStringUsesBackslashEscapes: lang is "cython" or "d" or "julia" or "nim" or "objc", - UsesHashComments: UsesHashComments(lang), - UsesRHashComments: lang == "r", - UsesSlashComments: UsesSlashComments(lang), - UsesDashDashComments: UsesDashDashComments(lang), - UsesPercentComments: lang == "matlab", - UsesFortranBangComments: lang == "fortran", - UsesPascalBlockComments: lang == "pascal", - UsesVisualBasicComments: lang == "vb"); - - private static string PrepareLine(string lang, string line) - => PrepareLine(line, CreateReferenceLinePrepareOptions(lang)); - - private static string PrepareLine(string line, ReferenceLinePrepareOptions options) - { - if (line.Length == 0) - return line; - - if (options.UseCSharpTriggerFastPath && line.IndexOfAny(CSharpReferenceLinePreparationTriggerChars) < 0) - return line; - - var result = line; - if (options.MaskRustLifetimes) - result = MaskRustLifetimeTokens(result); - if (options.MaskNimRawStrings) - result = ScientificNativeCommentMasker.MaskNimRawStringLiterals(result); - if (options.MaskStringLiterals && MayContainStringLiteralDelimiter(result, options.IncludeBacktickStringDelimiter)) - { - if (options.PreserveStringLiteralLength) - { - result = ScientificNativeCommentMasker.MaskLineStringLiteralsPreservingPostfixSingleQuotes( - result, - options.UseMatlabStringRules, - options.ScientificStringUsesBackslashEscapes, - options.PreservePostfixSingleQuotes); - } - else - { - var stringLiteralRegex = !options.IncludeBacktickStringDelimiter - ? NonBacktickStringLiteralRegex - : StringLiteralRegex; - result = options.PreserveStringLiteralWidth - ? stringLiteralRegex.Replace(result, static match => new string(' ', match.Length)) - : stringLiteralRegex.Replace(result, "\"\""); - } - } - if (result.Contains("/*", StringComparison.Ordinal)) - result = InlineBlockCommentRegex.Replace(result, " "); - - if (options.UsesHashComments) - { - var hashIndex = options.UsesRHashComments - ? FindRHashCommentStart(result) - : result.IndexOf('#'); - if (hashIndex >= 0) - result = result[..hashIndex]; - } - - if (options.UsesSlashComments) - { - var slashIndex = result.IndexOf("//", StringComparison.Ordinal); - if (slashIndex >= 0) - result = result[..slashIndex]; - } - - // Lua, SQL, Haskell use -- for line comments / Lua、SQL、Haskell は -- を行コメントに使う - if (options.UsesDashDashComments) - { - var dashCommentIndex = result.IndexOf("--", StringComparison.Ordinal); - if (dashCommentIndex >= 0) - result = result[..dashCommentIndex]; - } - - if (options.UsesPercentComments) - { - // Outside strings, MATLAB treats `...` and the rest of the physical line as a - // continuation comment. MATLAB では文字列外の `...` 以降は継続コメントになる。 - var continuationIndex = result.IndexOf("...", StringComparison.Ordinal); - if (continuationIndex >= 0) - result = result[..continuationIndex]; - - var percentCommentIndex = result.IndexOf('%'); - if (percentCommentIndex >= 0) - result = result[..percentCommentIndex]; - } - - if (options.UsesFortranBangComments) - { - var bangCommentIndex = result.IndexOf('!'); - if (bangCommentIndex >= 0) - result = result[..bangCommentIndex]; - } - - if (options.UsesPascalBlockComments) - { - result = PascalBraceCommentRegex.Replace(result, " "); - result = PascalParenStarCommentRegex.Replace(result, " "); - } - - // VB.NET uses Rem and ' for line comments / VB.NET は Rem と ' を行コメントに使う - if (options.UsesVisualBasicComments) - { - var remCommentMatch = VisualBasicRemCommentRegex.Match(result); - if (remCommentMatch.Success) - result = result[..remCommentMatch.Index]; - - var vbCommentIndex = result.IndexOf('\''); - if (vbCommentIndex >= 0) - result = result[..vbCommentIndex]; - } - - return result; - } - - private static bool MayContainStringLiteralDelimiter(string line, bool includeBacktick) - => includeBacktick - ? line.AsSpan().IndexOfAny('"', '\'', '`') >= 0 - : line.AsSpan().IndexOfAny('"', '\'') >= 0; - - private static int FindRHashCommentStart(string line) - { - var inBacktickIdentifier = false; - for (var i = 0; i < line.Length; i++) - { - var ch = line[i]; - if (inBacktickIdentifier && ch == '\\' && i + 1 < line.Length) - { - i++; - continue; - } - - if (ch == '`') - { - inBacktickIdentifier = !inBacktickIdentifier; - continue; - } - - if (ch == '#' && !inBacktickIdentifier) - return i; - } - - return -1; - } - - private static string MaskRustLifetimeTokens(string line) - { - var quoteIndex = line.IndexOf('\''); - if (quoteIndex < 0) - return line; - - char[]? chars = null; - for (var index = quoteIndex; index + 1 < line.Length; index++) - { - if (line[index] != '\'') - continue; - - var next = line[index + 1]; - if (next != '_' && !char.IsLetter(next)) - continue; - - var end = index + 2; - while (end < line.Length && IsJavaIdentifierPart(line[end])) - end++; - - if (end == index + 2 && end < line.Length && line[end] == '\'') - continue; - - chars ??= line.ToCharArray(); - for (var maskIndex = index; maskIndex < end; maskIndex++) - chars[maskIndex] = ' '; - - index = end - 1; - } - - return chars is null ? line : new string(chars); - } - - private static string[] MaskPascalBlockCommentLines(IReadOnlyList lines) - { - if (lines is string[] lineArray && !MayContainPascalBlockComment(lines)) - return lineArray; - - var result = new string[lines.Count]; - var inBraceComment = false; - var inParenStarComment = false; - - for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) - { - var line = lines[lineIndex]; - char[]? chars = null; - - void MaskAt(int index) => - (chars ??= line.ToCharArray())[index] = ' '; - - var cursor = 0; - - while (cursor < line.Length) - { - if (inBraceComment) - { - var closes = line[cursor] == '}'; - MaskAt(cursor++); - if (closes) - inBraceComment = false; - continue; - } - - if (inParenStarComment) - { - if (line[cursor] == '*' && cursor + 1 < line.Length && line[cursor + 1] == ')') - { - MaskAt(cursor++); - MaskAt(cursor++); - inParenStarComment = false; - continue; - } - - MaskAt(cursor++); - continue; - } - - if (line[cursor] == '\'') - { - cursor++; - while (cursor < line.Length) - { - if (line[cursor] == '\'') - { - cursor++; - if (cursor < line.Length && line[cursor] == '\'') - { - cursor++; - continue; - } - break; - } - - cursor++; - } - continue; - } - - if (line[cursor] == '{') - { - MaskAt(cursor++); - inBraceComment = true; - continue; - } - - if (line[cursor] == '(' && cursor + 1 < line.Length && line[cursor + 1] == '*') - { - MaskAt(cursor++); - MaskAt(cursor++); - inParenStarComment = true; - continue; - } - - cursor++; - } - - result[lineIndex] = chars is null ? line : new string(chars); - } - - return result; - } - - private static bool MayContainPascalBlockComment(IReadOnlyList lines) - { - foreach (var line in lines) - { - if (line.Contains('{') || line.Contains("(*", StringComparison.Ordinal)) - return true; - } - - return false; - } - - private static bool UsesCStyleBlockComments(string language) => - language is "c" - or "cpp" - or "cuda" - or "glsl" - or "hlsl" - or "metal" - or "wgsl" - or "go" - or "objc" - or "dart"; - - private static string[] MaskCStyleBlockCommentLines(string language, IReadOnlyList lines) - { - if (lines is string[] lineArray && !MayContainCStyleMaskingTrigger(language, lines)) - return lineArray; - - var result = new string[lines.Count]; - var blockCommentDepth = 0; - var inGoRawString = false; - char dartTripleQuote = '\0'; - string? cppRawStringTerminator = null; - - for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) - { - var line = lines[lineIndex]; - char[]? chars = null; - - void MaskAt(int index) => - (chars ??= line.ToCharArray())[index] = ' '; - - void MaskRange(int start, int endExclusive) - { - var masked = chars ??= line.ToCharArray(); - for (var index = start; index < endExclusive; index++) - masked[index] = ' '; - } - - var cursor = 0; - while (cursor < line.Length) - { - if (blockCommentDepth > 0) - { - MaskAt(cursor); - if (language == "wgsl" - && line[cursor] == '/' - && cursor + 1 < line.Length - && line[cursor + 1] == '*') - { - MaskAt(cursor + 1); - blockCommentDepth++; - cursor += 2; - continue; - } - - if (line[cursor] == '*' && cursor + 1 < line.Length && line[cursor + 1] == '/') - { - MaskAt(cursor + 1); - blockCommentDepth--; - cursor += 2; - continue; - } - - cursor++; - continue; - } - - if (inGoRawString) - { - MaskAt(cursor); - if (line[cursor] == '`') - inGoRawString = false; - cursor++; - continue; - } - - if (dartTripleQuote != '\0') - { - if (IsTripleQuoteAt(line, cursor, dartTripleQuote)) - { - MaskRange(cursor, cursor + 3); - dartTripleQuote = '\0'; - cursor += 3; - continue; - } - - MaskAt(cursor); - cursor++; - continue; - } - - if (cppRawStringTerminator != null) - { - var closeIndex = line.IndexOf(cppRawStringTerminator, cursor, StringComparison.Ordinal); - if (closeIndex < 0) - { - MaskRange(cursor, line.Length); - break; - } - - MaskRange(cursor, closeIndex + cppRawStringTerminator.Length); - cursor = closeIndex + cppRawStringTerminator.Length; - cppRawStringTerminator = null; - continue; - } - - if (line[cursor] == '/' && cursor + 1 < line.Length && line[cursor + 1] == '/') - break; - - if (language == "go" && line[cursor] == '`') - { - MaskAt(cursor); - inGoRawString = true; - cursor++; - continue; - } - - if (language == "dart" && TryGetDartTripleStringStart(line, cursor, out var dartQuote, out var dartOpeningLength)) - { - var closeIndex = IndexOfTripleQuote(line, cursor + dartOpeningLength, dartQuote); - if (closeIndex < 0) - { - MaskRange(cursor, line.Length); - dartTripleQuote = dartQuote; - break; - } - - MaskRange(cursor, closeIndex + 3); - cursor = closeIndex + 3; - continue; - } - - if (language == "cpp" && TryGetCppRawStringTerminator(line, cursor, out var rawTerminator, out var rawOpeningLength)) - { - var closeIndex = line.IndexOf(rawTerminator, cursor + rawOpeningLength, StringComparison.Ordinal); - if (closeIndex < 0) - { - MaskRange(cursor, line.Length); - cppRawStringTerminator = rawTerminator; - break; - } - - MaskRange(cursor, closeIndex + rawTerminator.Length); - cursor = closeIndex + rawTerminator.Length; - continue; - } - - if (line[cursor] is '"' or '\'' or '`') - { - cursor = SkipCStyleQuotedLiteral(line, cursor) + 1; - continue; - } - - if (line[cursor] == '/' && cursor + 1 < line.Length && line[cursor + 1] == '*') - { - MaskAt(cursor); - cursor++; - MaskAt(cursor); - blockCommentDepth = 1; - cursor++; - continue; - } - - cursor++; - } - - result[lineIndex] = chars is null ? line : new string(chars); - } - - return result; - } - - private static bool MayContainCStyleMaskingTrigger(string language, IReadOnlyList lines) - { - foreach (var line in lines) - { - if (line.Contains('/')) - return true; - if (language == "go" && line.Contains('`')) - return true; - if (language == "dart" && - (line.Contains("\"\"\"", StringComparison.Ordinal) || - line.Contains("'''", StringComparison.Ordinal))) - { - return true; - } - - if (language == "cpp" && line.Contains("R\"", StringComparison.Ordinal)) - return true; - } - - return false; - } - - private static bool TryGetDartTripleStringStart(string line, int start, out char quote, out int openingLength) - { - quote = '\0'; - openingLength = 0; - var quoteIndex = start; - - if (line[start] is 'r' or 'R') - { - if (start > 0 && IsIdentifierChar(line[start - 1])) - return false; - quoteIndex = start + 1; - } - - if (quoteIndex + 2 >= line.Length) - return false; - - quote = line[quoteIndex]; - if (quote is not ('"' or '\'') || !IsTripleQuoteAt(line, quoteIndex, quote)) - return false; - - openingLength = quoteIndex - start + 3; - return true; - } - - private static bool IsTripleQuoteAt(string line, int start, char quote) => - start + 2 < line.Length - && line[start] == quote - && line[start + 1] == quote - && line[start + 2] == quote; - - private static int IndexOfTripleQuote(string line, int start, char quote) - { - for (var i = start; i + 2 < line.Length; i++) - { - if (IsTripleQuoteAt(line, i, quote)) - return i; - } - - return -1; - } - - private static bool TryGetCppRawStringTerminator(string line, int start, out string terminator, out int openingLength) - { - terminator = string.Empty; - openingLength = 0; - if (line[start] != 'R' || start + 2 >= line.Length || line[start + 1] != '"') - return false; - - var delimiterStart = start + 2; - var parenIndex = line.IndexOf('(', delimiterStart); - if (parenIndex < 0) - return false; - - for (var i = delimiterStart; i < parenIndex; i++) - { - if (char.IsWhiteSpace(line[i]) || line[i] is '(' or ')' or '\\') - return false; - } - - terminator = ")" + line[delimiterStart..parenIndex] + "\""; - openingLength = parenIndex - start + 1; - return true; - } - - private static string[] MaskHaskellBlockCommentLines(IReadOnlyList lines) - { - if (lines is string[] lineArray && !MayContainHaskellBlockComment(lines)) - return lineArray; - - var result = new string[lines.Count]; - var blockDepth = 0; - - for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) - { - var line = lines[lineIndex]; - char[]? chars = null; - - void MaskAt(int index) => - (chars ??= line.ToCharArray())[index] = ' '; - - var cursor = 0; - - while (cursor < line.Length) - { - if (blockDepth > 0) - { - if (line[cursor] == '{' && cursor + 1 < line.Length && line[cursor + 1] == '-') - { - MaskAt(cursor); - MaskAt(cursor + 1); - blockDepth++; - cursor += 2; - continue; - } - - if (line[cursor] == '-' && cursor + 1 < line.Length && line[cursor + 1] == '}') - { - MaskAt(cursor); - MaskAt(cursor + 1); - blockDepth--; - cursor += 2; - continue; - } - - MaskAt(cursor++); - continue; - } - - if (line[cursor] == '"') - { - cursor = SkipCStyleQuotedLiteral(line, cursor) + 1; - continue; - } - - if (line[cursor] == '-' && cursor + 1 < line.Length && line[cursor + 1] == '-') - break; - - if (line[cursor] == '{' && cursor + 1 < line.Length && line[cursor + 1] == '-') - { - MaskAt(cursor); - MaskAt(cursor + 1); - blockDepth = 1; - cursor += 2; - continue; - } - - cursor++; - } - - result[lineIndex] = chars is null ? line : new string(chars); - } - - return result; - } - - private static bool MayContainHaskellBlockComment(IReadOnlyList lines) - { - foreach (var line in lines) - { - if (line.Contains("{-", StringComparison.Ordinal)) - return true; - } - - return false; - } - - private static int SkipCStyleQuotedLiteral(string line, int start) - { - var quote = line[start]; - var cursor = start + 1; - while (cursor < line.Length) - { - if (quote != '`' && line[cursor] == '\\' && cursor + 1 < line.Length) - { - cursor += 2; - continue; - } - - if (line[cursor] == quote) - return cursor; - cursor++; - } - - return line.Length; - } - - private static readonly Regex VisualBasicRemCommentRegex = new( - @"(?:^|:)\s*Rem\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex PascalBraceCommentRegex = new(@"\{[^}\r\n]*\}", RegexOptions.Compiled); - private static readonly Regex PascalParenStarCommentRegex = new(@"\(\*.*?\*\)", RegexOptions.Compiled); - - - private static bool IsIgnoredCallName(string language, string name) - { - if (LanguageSpecificCallNameKeeps.TryGetValue(language, out var languageSpecificKeepNames) - && languageSpecificKeepNames.Contains(name)) - { - return false; - } - - if (language == "php") - { - if (SharedIgnoredCallNamesCaseInsensitive.Contains(name)) - return true; - } - else if (SharedIgnoredCallNames.Contains(name)) - { - return true; - } - - return LanguageSpecificIgnoredCallNames.TryGetValue(language, out var languageSpecificIgnoredNames) - && languageSpecificIgnoredNames.Contains(name); - } - - private static bool IsConstructorCallName(string language, string preparedLine, int nameIndex) - { - var probe = nameIndex - 1; - while (probe >= 0 && char.IsWhiteSpace(preparedLine[probe])) - probe--; - - if (probe < 0) - return false; - - while (probe >= 0) - { - char? separator = null; - if (probe >= 1 && preparedLine[probe] == ':' && preparedLine[probe - 1] == ':') - { - separator = ':'; - probe -= 2; - } - else if (preparedLine[probe] is '.' or '\\') - { - separator = preparedLine[probe]; - probe--; - } - - if (separator == null) - break; - - if (separator != '\\') - { - while (probe >= 0 && char.IsWhiteSpace(preparedLine[probe])) - probe--; - } - - var segmentEnd = probe; - while (probe >= 0 && IsIdentifierChar(preparedLine[probe])) - probe--; - - var consumedSegment = segmentEnd >= 0 && segmentEnd >= probe + 1; - if (!consumedSegment && separator != '\\') - return false; - } - - while (probe >= 0 && char.IsWhiteSpace(preparedLine[probe])) - probe--; - - if (probe < 0) - return false; - - var tokenEnd = probe; - while (probe >= 0 && IsIdentifierChar(preparedLine[probe])) - probe--; - - var tokenStart = probe + 1; - if (tokenStart > tokenEnd) - return false; - - var token = preparedLine[tokenStart..(tokenEnd + 1)]; - return language == "php" - ? string.Equals(token, "new", StringComparison.OrdinalIgnoreCase) - : string.Equals(token, "new", StringComparison.Ordinal); - } - - private static readonly HashSet KotlinTypeProjectionModifierNames = new(StringComparer.Ordinal) - { - "in", "out", - }; - - private readonly record struct NestedGenericCallCandidate(string Name, int NameIndex); - - private static void EmitGenericInvocationTypeArgumentReferences( - string language, - string preparedLine, - int nameIndex, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (language is not ("csharp" or "java" or "kotlin")) - return; - - if (!TryGetPostNameGenericInvocationTypeArgumentSpan(preparedLine, nameIndex, out var argumentsStart, out var argumentsLength) - && (language != "java" - || !TryGetJavaExplicitGenericInvocationTypeArgumentSpan(preparedLine, nameIndex, out argumentsStart, out argumentsLength))) - { - return; - } - - if (argumentsLength <= 0) - return; - - var ignoredSegments = language == "kotlin" - ? KotlinTypeProjectionModifierNames - : null; - var argumentsExpression = preparedLine.Substring(argumentsStart, argumentsLength); - - AddTypeExpressionSegments( - references, - seen, - fileId, - argumentsExpression, - argumentsStart, - context, - lineNumber, - container, - language, - ignoredSegments); - AddGenericInvocationTypeArgumentSegments( - references, - seen, - fileId, - argumentsExpression, - argumentsStart, - context, - lineNumber, - container, - language, - ignoredSegments); - } - - private static void AddGenericInvocationTypeArgumentSegments( - List references, - ReferenceDedupeSet seen, - long fileId, - string expression, - int expressionStartInLine, - string context, - int lineNumber, - SymbolRecord? container, - string language, - IReadOnlySet? ignoredSegments) - { - if (language != "csharp") - return; - - for (var i = 0; i < expression.Length; i++) - { - if (!IsTypeExpressionIdentifierStart(language, expression[i])) - continue; - - var segmentStart = i; - if (expression[i] == '@') - i++; - while (i < expression.Length && IsTypeExpressionIdentifierPart(language, expression[i])) - i++; - - var segmentLength = i - segmentStart; - var isEscapedCSharpIdentifier = segmentLength > 0 && expression[segmentStart] == '@'; - var segment = isEscapedCSharpIdentifier - ? expression.Substring(segmentStart + 1, segmentLength - 1) - : expression.Substring(segmentStart, segmentLength); - if (i + 1 < expression.Length && expression[i] == ':' && expression[i + 1] == ':') - { - i++; - continue; - } - - AddTypeReferenceSegment( - references, - seen, - fileId, - segment, - expressionStartInLine + segmentStart, - context, - lineNumber, - container, - language, - isEscapedCSharpIdentifier, - ignoredSegments, - "generic_type_argument"); - i--; - } - } - - private static bool TryGetPostNameGenericInvocationTypeArgumentSpan( - string preparedLine, - int nameIndex, - out int argumentsStart, - out int argumentsLength) - { - argumentsStart = -1; - argumentsLength = 0; - - if (nameIndex < 0 || nameIndex >= preparedLine.Length || !IsAtAwareAsciiIdentifierStart(preparedLine, nameIndex)) - return false; - - var scan = ConsumeAtAwareAsciiIdentifier(preparedLine, nameIndex); - if (scan + 1 < preparedLine.Length - && preparedLine[scan] == '?' - && preparedLine[scan + 1] == '.') - { - scan += 2; - } - - if (scan >= preparedLine.Length || preparedLine[scan] != '<') - return false; - - var closeAngle = FindMatchingChar(preparedLine, scan, '<', '>'); - if (closeAngle <= scan) - return false; - - var after = closeAngle + 1; - while (after < preparedLine.Length && char.IsWhiteSpace(preparedLine[after])) - after++; - - if (after >= preparedLine.Length || preparedLine[after] != '(') - return false; - - argumentsStart = scan + 1; - argumentsLength = closeAngle - scan - 1; - return true; - } - - private static bool TryGetJavaExplicitGenericInvocationTypeArgumentSpan( - string preparedLine, - int nameIndex, - out int argumentsStart, - out int argumentsLength) - { - argumentsStart = -1; - argumentsLength = 0; - - var closeAngle = nameIndex - 1; - while (closeAngle >= 0 && char.IsWhiteSpace(preparedLine[closeAngle])) - closeAngle--; - - if (closeAngle < 0 || preparedLine[closeAngle] != '>') - return false; - - var openAngle = FindMatchingOpenChar(preparedLine, closeAngle, '<', '>'); - if (openAngle < 0) - return false; - - var beforeOpen = openAngle - 1; - while (beforeOpen >= 0 && char.IsWhiteSpace(preparedLine[beforeOpen])) - beforeOpen--; - - if (beforeOpen < 0 || preparedLine[beforeOpen] != '.') - return false; - - argumentsStart = openAngle + 1; - argumentsLength = closeAngle - openAngle - 1; - return true; - } - - private static int FindMatchingOpenChar(string text, int closeIndex, char openChar, char closeChar) - { - if (closeIndex < 0 || closeIndex >= text.Length || text[closeIndex] != closeChar) - return -1; - - var depth = 0; - for (var i = closeIndex; i >= 0; i--) - { - if (text[i] == closeChar) - { - depth++; - continue; - } - - if (text[i] != openChar) - continue; - - depth--; - if (depth == 0) - return i; - } - - return -1; - } - - private static IEnumerable EnumerateNestedGenericCallCandidates( - string preparedLine, - HashSet matchedCallIndices) - { - for (var i = 0; i < preparedLine.Length; i++) - { - if (!IsAtAwareAsciiIdentifierStart(preparedLine, i)) - continue; - if (i > 0 && (IsIdentifierChar(preparedLine[i - 1]) || preparedLine[i - 1] == '$' || preparedLine[i - 1] == '@')) - continue; - - var nameStart = i; - i = ConsumeAtAwareAsciiIdentifier(preparedLine, i); - - if (matchedCallIndices.Contains(nameStart)) - { - i--; - continue; - } - - var scan = i; - if (scan + 1 < preparedLine.Length - && preparedLine[scan] == '?' - && preparedLine[scan + 1] == '.') - { - scan += 2; - } - - if (scan >= preparedLine.Length || preparedLine[scan] != '<') - { - i--; - continue; - } - - if (!TrySkipBalancedGenericArgs(preparedLine, ref scan, out var sawNestedGeneric) || !sawNestedGeneric) - { - i--; - continue; - } - - while (scan < preparedLine.Length && char.IsWhiteSpace(preparedLine[scan])) - scan++; - - if (scan < preparedLine.Length && preparedLine[scan] == '(') - yield return new NestedGenericCallCandidate(preparedLine[nameStart..i], nameStart); - - i--; - } - } - - private static IEnumerable EnumerateNestedGenericInitializerCandidates( - string preparedLine, - HashSet matchedInitializerIndices, - bool requireOpeningBrace) - { - for (var i = 0; i < preparedLine.Length; i++) - { - if (!IsStandaloneNewKeyword(preparedLine, i)) - continue; - - var scan = i + 3; - if (!TryReadQualifiedTypeName(preparedLine, ref scan, out var name, out var nameIndex)) - { - i += 2; - continue; - } - - if (matchedInitializerIndices.Contains(nameIndex)) - { - i = scan - 1; - continue; - } - - if (!TrySkipBalancedGenericArgs(preparedLine, ref scan, out var sawNestedGeneric) || !sawNestedGeneric) - { - i = scan - 1; - continue; - } - - if (!TrySkipArraySuffixes(preparedLine, ref scan)) - { - i = scan - 1; - continue; - } - - while (scan < preparedLine.Length && char.IsWhiteSpace(preparedLine[scan])) - scan++; - - if (requireOpeningBrace) - { - if (scan < preparedLine.Length && preparedLine[scan] == '{') - yield return new NestedGenericCallCandidate(name, nameIndex); - } - else if (scan == preparedLine.Length) - { - yield return new NestedGenericCallCandidate(name, nameIndex); - } - - i = scan - 1; - } - } - - private static bool TryReadQualifiedTypeName( - string preparedLine, - ref int scan, - out string name, - out int nameIndex) - { - name = string.Empty; - nameIndex = -1; - - while (true) - { - while (scan < preparedLine.Length && char.IsWhiteSpace(preparedLine[scan])) - scan++; - - if (scan >= preparedLine.Length || !IsAtAwareAsciiIdentifierStart(preparedLine, scan)) - return false; - - var segmentStart = scan; - scan = ConsumeAtAwareAsciiIdentifier(preparedLine, scan); - - name = preparedLine[segmentStart..scan]; - nameIndex = segmentStart; - - var separatorScan = scan; - while (separatorScan < preparedLine.Length && char.IsWhiteSpace(preparedLine[separatorScan])) - separatorScan++; - - if (separatorScan + 1 < preparedLine.Length - && preparedLine[separatorScan] == ':' - && preparedLine[separatorScan + 1] == ':') - { - scan = separatorScan + 2; - continue; - } - - if (separatorScan < preparedLine.Length && preparedLine[separatorScan] == '.') - { - scan = separatorScan + 1; - continue; - } - - scan = separatorScan; - return true; - } - } - - private static bool TrySkipArraySuffixes(string preparedLine, ref int scan) - { - while (true) - { - while (scan < preparedLine.Length && char.IsWhiteSpace(preparedLine[scan])) - scan++; - - if (scan >= preparedLine.Length || preparedLine[scan] != '[') - return true; - - scan++; - while (scan < preparedLine.Length && preparedLine[scan] != ']') - scan++; - - if (scan >= preparedLine.Length || preparedLine[scan] != ']') - return false; - - scan++; - } - } - - private static bool ShouldSkipInitializerName(string language, string name) => - (language == "csharp" && CSharpBuiltInTypeNames.Contains(name)) - || (language == "java" && JavaPrimitiveTypeNames.Contains(name)) - || IsIgnoredCallName(language, name); - - private static bool IsStandaloneNewKeyword(string preparedLine, int index) - { - if (index < 0 || index + 3 > preparedLine.Length) - return false; - if (preparedLine[index] != 'n' - || preparedLine[index + 1] != 'e' - || preparedLine[index + 2] != 'w') - { - return false; - } - - if (index > 0 && IsIdentifierChar(preparedLine[index - 1])) - return false; - - return index + 3 >= preparedLine.Length || !IsIdentifierChar(preparedLine[index + 3]); - } - - private static bool TrySkipBalancedGenericArgs(string preparedLine, ref int scan, out bool sawNestedGeneric) - { - sawNestedGeneric = false; - if (scan >= preparedLine.Length || preparedLine[scan] != '<') - return false; - - var depth = 0; - while (scan < preparedLine.Length) - { - var ch = preparedLine[scan++]; - if (ch == '<') - { - depth++; - if (depth > 1) - sawNestedGeneric = true; - } - else if (ch == '>') - { - depth--; - if (depth == 0) - return true; - if (depth < 0) - return false; - } - } - - return false; - } - - private static bool IsAsciiIdentifierStartChar(char ch) => - ch == '_' || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); - - private static bool IsAtAwareAsciiIdentifierStart(string text, int index) - { - if (index < 0 || index >= text.Length) - return false; - - if (text[index] == '@') - return index + 1 < text.Length && IsAsciiIdentifierStartChar(text[index + 1]); - - return IsAsciiIdentifierStartChar(text[index]); - } - - private static int ConsumeAtAwareAsciiIdentifier(string text, int startIndex) - { - var index = startIndex; - if (index < text.Length && text[index] == '@') - index++; - - if (index >= text.Length || !IsAsciiIdentifierStartChar(text[index])) - return startIndex; - - index++; - while (index < text.Length && IsIdentifierChar(text[index])) - index++; - - return index; - } - - private static bool IsIdentifierChar(char ch) => - char.IsLetterOrDigit(ch) || ch == '_'; - - /// - /// Classify a call-looking identifier as an attribute/annotation when it appears inside - /// a C# `[...]` attribute list or is preceded by a Java-family `@` marker. Returns null - /// for ordinary method calls so the caller emits the default `call` reference kind. - /// 呼び出しに見える識別子を、C# の `[...]` 属性リスト内や Java 系 `@` 付き注釈に該当する - /// 場合に専用の reference kind へ分類する。通常の呼び出しは null を返して既定の `call` を維持する。 - /// - private static string? TryClassifyMetadataReference( - string language, - string preparedLine, - int nameIndex, - bool insideCSharpAttributeRange) - { - if (language == "csharp") - return insideCSharpAttributeRange ? "attribute" : null; - - if (nameIndex >= 0 - && nameIndex < preparedLine.Length - && preparedLine[nameIndex] == '@' - && AnnotationLanguages.Contains(language)) - { - return "annotation"; - } - - var probe = nameIndex - 1; - while (probe >= 0 && char.IsWhiteSpace(preparedLine[probe])) - probe--; - if (probe < 0) - return null; - - if (AnnotationLanguages.Contains(language)) - return IsAnnotationContext(preparedLine, probe) ? "annotation" : null; - - return null; - } - - /// - /// Build per-line column ranges that identify C# `[...]` attribute sections. Handles - /// declaration-position detection (including parameter attributes preceded by `(` / `,` - /// via forward look-ahead) and multi-line `[\n ... \n]` sections. Each inner list holds - /// ordered `(startColumn, endColumnExclusive)` ranges that are inside an attribute section - /// on that line. Call sites whose name column falls inside one of these ranges are - /// reclassified as `attribute` instead of `call`. - /// C# の `[...]` 属性セクションを行ごとの列範囲で表すテーブルを構築する。 - /// `(` / `,` の直後に置かれるパラメータ属性を forward lookahead で、複数行にわたる - /// `[\n ... \n]` 属性を跨行トラッキングで検出する。各行のリストは属性セクションに含まれる - /// `(開始列, 終端列 (exclusive))` のレンジを保持し、呼び出し名の列がどれかのレンジに含まれる場合に - /// `call` ではなく `attribute` へ再分類する。 - /// - private static (List<(int start, int end)>?[] Ranges, List<(int start, int end)>?[] TopLevelRanges) BuildCSharpAttributeRanges(string[] preparedLines) - { - var perLine = new List<(int start, int end)>?[preparedLines.Length]; - var perLineTopLevel = new List<(int start, int end)>?[preparedLines.Length]; - - // Stack entries capture the opening `[` position, whether that bracket was at - // a C# declaration (attribute) position, and a snapshot of the global paren depth - // at that moment. The snapshot lets us compute an attribute-section-local paren - // depth (`parenDepth - parenDepthAtOpen`), which is what the top-level zone tracking - // uses so that parameter attributes like `void M([Attr] int x)` still have their - // attribute-list top level at section-local depth 0 even though the global depth - // is inside the method's parameter list. - // スタックは `[` の位置、その bracket が属性位置だったか、および開いた瞬間の - // グローバル paren 深さのスナップショットを保持する。スナップショットを使うと - // 属性セクション内ローカルの paren 深さ (`parenDepth - parenDepthAtOpen`) が - // 得られるので、`void M([Attr] int x)` のように外側の method 引数リストの中で - // 開く属性セクションでも、セクション内では top-level (local depth 0) として扱える。 - var bracketStack = new Stack<(int li, int ci, bool isAttr, int parenDepthAtOpen)>(); - char lastMeaningful = '\0'; - int parenDepth = 0; - bool lastClosedBracketWasAttribute = false; - - // Top-level zone tracking: while we are inside an attribute section and the paren - // depth is at the section's open snapshot (section-local depth 0), the current zone - // span is open. When parens open inside the section we close it; when they fully - // close again we reopen. When the attribute section itself closes, we emit the span. - // top-level ゾーン追跡: 属性セクション内かつセクションローカルの paren 深さが 0 の - // あいだだけゾーンを開いておき、セクション内の `(` で閉じ、`)` で再び開く。 - // セクションが閉じる `]` で確定させる。 - int topZoneStartLi = -1; - int topZoneStartCi = 0; - - void EmitTopZone(int endLi, int endCi) - { - if (topZoneStartLi < 0) - return; - for (var l = topZoneStartLi; l <= endLi; l++) - { - int s = (l == topZoneStartLi) ? topZoneStartCi : 0; - int e = (l == endLi) ? endCi : preparedLines[l].Length; - if (e > s) - AddCSharpAttributeRange(perLineTopLevel, l, s, e); - } - topZoneStartLi = -1; - } - - for (var li = 0; li < preparedLines.Length; li++) - { - var line = preparedLines[li]; - for (var ci = 0; ci < line.Length; ci++) - { - var c = line[ci]; - if (c == '/' && ci + 1 < line.Length && line[ci + 1] == '/') - break; - - if (char.IsWhiteSpace(c)) - continue; - - if (c == '(') - { - // If the innermost enclosing bracket is an attribute section and we are - // currently at that section's local top level, close the top-level zone - // just before the `(`. Use the stack top's `parenDepthAtOpen` snapshot so - // parameter attributes inside an outer `(...)` still get their top level - // tracked correctly. - // 直近の `[` が属性セクションで、かつその section-local 深さで top-level のとき、 - // `(` 直前でゾーンを閉じる。外側の `(...)` の中で開く属性セクションにも対応するため、 - // グローバル depth ではなくスタック top の開いたときの snapshot と比較する。 - if (bracketStack.Count > 0) - { - var top = bracketStack.Peek(); - if (top.isAttr && parenDepth == top.parenDepthAtOpen && topZoneStartLi >= 0) - EmitTopZone(li, ci); - } - parenDepth++; - lastMeaningful = c; - continue; - } - if (c == ')') - { - if (parenDepth > 0) - { - parenDepth--; - // If the innermost `[` is an attribute section and we just returned - // to that section's local top level, reopen the top-level zone. - // 直近の `[` が属性セクションで、section-local top-level に戻ってきたら - // top-level ゾーンを再開する。 - if (bracketStack.Count > 0) - { - var top = bracketStack.Peek(); - if (top.isAttr && parenDepth == top.parenDepthAtOpen && topZoneStartLi < 0) - { - topZoneStartLi = li; - topZoneStartCi = ci + 1; - } - } - } - lastMeaningful = c; - continue; - } - - if (c == '[') - { - bool isAttr = EvaluateCSharpAttributePosition( - lastMeaningful, lastClosedBracketWasAttribute, preparedLines, li, ci); - bracketStack.Push((li, ci, isAttr, parenDepth)); - if (isAttr && topZoneStartLi < 0) - { - // Start top-level zone just after the `[` so the `[` itself is not - // inside the zone. Section-local depth is 0 by construction at the - // open bracket. - // `[` 直後から top-level ゾーンを開始する。開いた瞬間は section-local 深さ 0。 - topZoneStartLi = li; - topZoneStartCi = ci + 1; - } - lastMeaningful = c; - continue; - } - - if (c == ']') - { - if (bracketStack.Count > 0) - { - var opened = bracketStack.Pop(); - lastClosedBracketWasAttribute = opened.isAttr; - if (opened.isAttr) - { - // Record the attribute section span for every line it covers so - // cross-line `[\n Foo("x")\n]` also classifies `Foo` as attribute. - // 属性セクションがまたぐ全ての行に対して範囲を記録し、 - // `[\n Foo("x")\n]` のような跨行ケースでも `Foo` が属性として分類されるようにする。 - for (var l = opened.li; l <= li; l++) - { - int s = (l == opened.li) ? opened.ci : 0; - int e = (l == li) ? ci + 1 : preparedLines[l].Length; - AddCSharpAttributeRange(perLine, l, s, e); - } - // Close the top-level zone at the `]`. Section-local depth should - // be 0 here (we are at the closing bracket of this section) — if - // it is not, we drop the open zone because paren balancing was - // malformed. - // `]` で top-level ゾーンを確定する。section-local 深さが 0 のはず。 - // 不整合入力ならゾーンを捨てる。 - if (parenDepth == opened.parenDepthAtOpen) - { - EmitTopZone(li, ci + 1); - } - else - { - topZoneStartLi = -1; - } - } - } - else - { - lastClosedBracketWasAttribute = false; - } - lastMeaningful = c; - continue; - } - - lastMeaningful = c; - } - } - - return (perLine, perLineTopLevel); - } - - private static void AddCSharpAttributeRange( - List<(int start, int end)>?[] rangesByLine, - int lineIndex, - int start, - int end) - { - (rangesByLine[lineIndex] ??= []).Add((start, end)); - } - - /// - /// Decide whether a `[` token sits at a C# attribute position based on the immediately - /// preceding meaningful character. `(` / `,` (parameter attributes) are disambiguated via - /// forward look-ahead because both attributes and C# 12 collection expressions can follow. - /// `[` が C# の属性位置にあるかを、直前の非空白文字から判定する。`(` / `,` の直後は - /// パラメータ属性にも collection expression にもなりうるため、forward lookahead で区別する。 - /// - private static bool EvaluateCSharpAttributePosition( - char lastMeaningful, - bool lastClosedBracketWasAttribute, - string[] preparedLines, - int startLi, - int startCi) - { - // Start of file or after a scope/statement boundary — attribute position. - // ファイル先頭、あるいはスコープ・文境界の直後は属性位置。 - if (lastMeaningful is '\0' or '{' or '}' or ';') - return true; - - // Chained attribute list `[A][B]`: the prior `]` must have closed an attribute section. - // `arr[i][Compute()]` → the prior `]` closed an indexer, so stays `call`. - // 連続した属性リスト `[A][B]` は、直前の `]` が属性セクションを閉じていたときのみ属性扱い。 - // `arr[i][Compute()]` の `]` は indexer を閉じているため `call` のまま。 - if (lastMeaningful == ']') - return lastClosedBracketWasAttribute; - - // Parameter / type-parameter / lambda attribute candidates (`(`, `,`, `<`, `=`): - // `void M([Attr] T x)`, `class C<[Attr] T>`, `var f = [Attr] () => body`, or - // `Consume([Make()])`. Disambiguate by scanning forward to the matching `]` and - // checking whether the next meaningful token begins a declaration (identifier / - // `@` / `(` for tuple types or lambda parameter lists / `[` chained). - // パラメータ / 型パラメータ / ラムダ属性候補 (`(`, `,`, `<`, `=`) は - // `void M([Attr] T x)`・`class C<[Attr] T>`・`var f = [Attr] () => body`・ - // `Consume([Make()])` いずれにもなりうる。対応する `]` まで進んで次トークンが - // 宣言やラムダを開始するか(識別子 / `@` / tuple・ラムダ仮引数の `(` / chained `[`)で区別する。 - if (lastMeaningful is '(' or ',' or '<' or '=') - return IsCSharpAttributeFollowedByDeclaration(preparedLines, startLi, startCi); - - return false; - } - - /// - /// Keywords that indicate the preceding `[...]` is an expression (collection / pattern / - /// switch target) rather than an attribute section when they appear after `]`. - /// `]` の直後に現れると、直前の `[...]` が属性ではなく式(collection / pattern / switch 対象) - /// であることを示す C# のキーワード集合。 - /// - private static readonly HashSet CSharpExpressionContinuationKeywords = new(StringComparer.Ordinal) - { - "is", "as", "switch", "with", "when", - }; - - /// - /// Scan forward from a `[` to its matching `]` (skipping balanced parens) and return true - /// when the next meaningful character begins an identifier-like token. Works across lines so - /// `void M(\n [Attr]\n T x\n)` is recognized as a parameter attribute. - /// `[` から対応する `]` まで進んで、`]` の次の非空白文字が識別子を始める場合に true を返す。 - /// 行を跨ぐ走査にも対応しているため `void M(\n [Attr]\n T x\n)` も属性として認識される。 - /// - private static bool IsCSharpAttributeFollowedByDeclaration(string[] preparedLines, int startLi, int startCi) - { - var bracketDepth = 1; - var parenDepth = 0; - var li = startLi; - var ci = startCi + 1; - while (li < preparedLines.Length) - { - var line = preparedLines[li]; - while (ci < line.Length) - { - var c = line[ci]; - if (c == '/' && ci + 1 < line.Length && line[ci + 1] == '/' && parenDepth == 0) - break; - - if (c == '(') - { - parenDepth++; - ci++; - continue; - } - if (c == ')') - { - if (parenDepth > 0) - parenDepth--; - ci++; - continue; - } - if (parenDepth > 0) - { - ci++; - continue; - } - if (c == '[') - { - bracketDepth++; - ci++; - continue; - } - if (c == ']') - { - bracketDepth--; - if (bracketDepth == 0) - { - ci++; - return NextTokenStartsDeclaration(preparedLines, li, ci); - } - ci++; - continue; - } - ci++; - } - li++; - ci = 0; - } - return false; - } - - /// - /// After the closing `]` of a candidate `[...]`, inspect the next meaningful token to decide - /// whether it begins a declaration. Accepts identifiers (except expression-continuation - /// keywords like `is` / `as` / `switch` / `with` / `when`), leading `@` (verbatim identifier), - /// `(` (tuple-typed parameter), and chained `[` (recurse for `[A][B]`). - /// 閉じ `]` の直後のトークンで宣言が始まるかを判定する。識別子(式継続の `is` / `as` / - /// `switch` / `with` / `when` は除外)、`@`(verbatim 識別子)、`(`(tuple パラメータ型)、 - /// `[`(`[A][B]` の連結)を受け入れる。 - /// - private static bool NextTokenStartsDeclaration(string[] preparedLines, int li, int ci) - { - while (li < preparedLines.Length) - { - var line = preparedLines[li]; - while (ci < line.Length && char.IsWhiteSpace(line[ci])) - ci++; - if (ci < line.Length) - { - var first = line[ci]; - if (first == '@' || first == '(') - return true; - if (first == '[') - return IsCSharpAttributeFollowedByDeclaration(preparedLines, li, ci); - if (!IsIdentifierChar(first)) - return false; - var start = ci; - while (ci < line.Length && IsIdentifierChar(line[ci])) - ci++; - var token = line.Substring(start, ci - start); - return !CSharpExpressionContinuationKeywords.Contains(token); - } - li++; - ci = 0; - } - return false; - } - - private static bool IsInsideCSharpAttributeRange(IReadOnlyList<(int start, int end)> ranges, int index) - { - for (var i = 0; i < ranges.Count; i++) - { - var (start, end) = ranges[i]; - if (index >= start && index < end) - return true; - } - return false; - } - - private static bool IsAnnotationContext(string line, int probe) - { - // `@Annotation(args)` — direct marker. 直接 `@Annotation(args)` の場合。 - if (line[probe] == '@') - return true; - - // `@module.Annotation(args)` — walk past the dotted qualifier chain first so that - // both `@module.Annotation` and `@field:com.example.Annotation` land the probe on - // either `@` or the Kotlin use-site target `:`. - // `@module.Annotation(args)` や `@field:com.example.Annotation(args)` のように修飾子が - // 付く場合も対応するため、先にドット区切り修飾子チェーンを剥がしてから `@` または - // Kotlin の use-site target `:` を判定する。 - while (probe >= 0 && line[probe] == '.') - { - probe--; - while (probe >= 0 && IsIdentifierChar(line[probe])) - probe--; - while (probe >= 0 && char.IsWhiteSpace(line[probe])) - probe--; - } - - if (probe < 0) - return false; - - if (line[probe] == '@') - return true; - - // Kotlin use-site target: `@field:Deprecated("msg")` or - // `@field:com.example.Deprecated("msg")`. After unwinding the dotted qualifier, the - // probe lands on `:`; walk past the target identifier and confirm `@`. - // Kotlin の use-site target `@field:Deprecated("msg")` や - // `@field:com.example.Deprecated("msg")` では、ドット修飾子を剥がしたあと probe が `:` - // に着地するため、target 識別子を読み飛ばして `@` を確認する。 - if (line[probe] == ':') - { - var j = probe - 1; - var idEnd = j; - while (j >= 0 && IsIdentifierChar(line[j])) - j--; - if (j + 1 <= idEnd) - { - var target = line[(j + 1)..(idEnd + 1)]; - if (KotlinAnnotationTargets.Contains(target)) - { - var k = j; - while (k >= 0 && char.IsWhiteSpace(line[k])) - k--; - if (k >= 0 && line[k] == '@') - return true; - } - } - } - - return false; - } - - private static bool UsesHashComments(string lang) => - lang is "python" or "ruby" or "perl" or "php" or "elixir" or "r" or "powershell" - or "shell" or "makefile" or "terraform" or "dockerfile" or "protobuf" - or "nim" or "julia" or "cython"; - - private static bool UsesSlashComments(string lang) => - lang is not "python" and not "ruby" and not "r" and not "haskell" - and not "makefile" and not "terraform" and not "dockerfile" - and not "css" and not "fortran" and not "crystal" and not "tcl" - and not "prolog" and not "ambiguous_pl" and not "nim" and not "matlab" - and not "julia" and not "cython" and not "ada"; - - private static bool UsesDashDashComments(string lang) => - lang is "lua" or "sql" or "haskell" or "ada"; - - } From b5cd752e2ce185464984e417a635d09debe42fea Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:16:22 +0900 Subject: [PATCH 041/101] Split reference type syntax analysis --- ...eferenceExtractor.CatchAndTemplateTypes.cs | 616 +++++ .../ReferenceExtractor.DeclarationTypes.cs | 774 ++++++ ...eferenceExtractor.PatternTypeReferences.cs | 2126 ----------------- ...eferenceExtractor.TypeScriptTypeQueries.cs | 201 ++ .../ReferenceExtractor.TypeSyntax.cs | 575 +++++ 5 files changed, 2166 insertions(+), 2126 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CatchAndTemplateTypes.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.TypeScriptTypeQueries.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.TypeSyntax.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CatchAndTemplateTypes.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CatchAndTemplateTypes.cs new file mode 100644 index 000000000..879a1791b --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CatchAndTemplateTypes.cs @@ -0,0 +1,616 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + internal static void EmitCatchTypeReferences( + string language, + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (language is not ("csharp" or "java" or "kotlin")) + return; + + var catchIndex = FindTopLevelKeyword(line, "catch"); + if (catchIndex < 0) + return; + + var openParen = line.IndexOf('(', catchIndex + "catch".Length); + if (openParen < 0) + return; + + var closeParen = FindMatchingChar(line, openParen, '(', ')'); + if (closeParen < 0 || closeParen <= openParen + 1) + return; + + var clauseStart = openParen + 1; + var clause = line.Substring(clauseStart, closeParen - clauseStart); + if (language == "kotlin") + { + EmitKotlinCatchTypeReference( + clause, + clauseStart, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + return; + } + + EmitCStyleCatchTypeReferences( + language, + clause, + clauseStart, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + + private static void EmitKotlinCatchTypeReference( + string clause, + int clauseStartInLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(clause, ':'); + if (colonIndex < 0) + return; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(clause, colonIndex + 1); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(clause, typeStart); + if (typeEnd <= typeStart) + return; + + var absoluteStart = clauseStartInLine + typeStart; + AddTypeExpressionSegments( + references, + seen, + fileId, + clause.Substring(typeStart, typeEnd - typeStart), + absoluteStart, + context, + lineNumber, + resolveContainerForColumn(absoluteStart), + "kotlin"); + } + + private static void EmitCStyleCatchTypeReferences( + string language, + string clause, + int clauseStartInLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var start = SkipCatchParameterPrefix(language, clause, 0); + var end = clause.Length; + while (end > start && char.IsWhiteSpace(clause[end - 1])) + end--; + if (end <= start) + return; + + var typeEnd = FindCatchTypeEndBeforeVariable(language, clause, start, end); + if (typeEnd <= start) + return; + + var typeExpression = clause.AsSpan(start, typeEnd - start); + foreach (var (segmentStart, segmentLength) in SplitTopLevelPipeSpans(typeExpression)) + { + var leading = CountLeadingWhitespace(typeExpression, segmentStart, segmentLength); + var trimmedLength = segmentLength - leading; + while (trimmedLength > 0 && char.IsWhiteSpace(typeExpression[segmentStart + leading + trimmedLength - 1])) + trimmedLength--; + if (trimmedLength <= 0) + continue; + + var absoluteStart = clauseStartInLine + start + segmentStart + leading; + AddTypeExpressionSegments( + references, + seen, + fileId, + typeExpression.Slice(segmentStart + leading, trimmedLength).ToString(), + absoluteStart, + context, + lineNumber, + resolveContainerForColumn(absoluteStart), + language); + } + } + + private static int SkipCatchParameterPrefix(string language, string clause, int start) + { + var i = start; + while (i < clause.Length) + { + while (i < clause.Length && char.IsWhiteSpace(clause[i])) + i++; + + if (language == "java" && i < clause.Length && clause[i] == '@') + { + i = SkipJavaAnnotation(clause, i) + 1; + continue; + } + + if (language == "java" && IsWordAt(clause, i, "final")) + { + i += "final".Length; + continue; + } + + break; + } + + return i; + } + + private static int FindCatchTypeEndBeforeVariable(string language, string clause, int start, int end) + { + if (!TryFindLastIdentifier(clause, start, end, out var lastStart, out _)) + return end; + + var before = clause.AsSpan(start, lastStart - start).TrimEnd(); + if (language == "csharp" && before.EndsWith("@", StringComparison.Ordinal)) + { + var prefix = before[..^1].TrimEnd(); + if (prefix.Length == 0 + || prefix.EndsWith(".", StringComparison.Ordinal) + || prefix.EndsWith("::", StringComparison.Ordinal)) + { + return end; + } + + return lastStart - 1; + } + + if (before.Length == 0 + || before.EndsWith(".", StringComparison.Ordinal) + || before.EndsWith("::", StringComparison.Ordinal)) + { + return end; + } + + return lastStart; + } + + private static bool TryFindLastIdentifier(string text, int start, int end, out int identifierStart, out int identifierEnd) + { + identifierStart = -1; + identifierEnd = -1; + var i = end - 1; + while (i >= start && char.IsWhiteSpace(text[i])) + i--; + if (i < start || !IsJavaIdentifierPart(text[i])) + return false; + + identifierEnd = i + 1; + while (i >= start && IsJavaIdentifierPart(text[i])) + i--; + identifierStart = i + 1; + return identifierStart < identifierEnd; + } + + private static List<(int Start, int Length)> SplitTopLevelPipeSpans(string text) => SplitTopLevelPipeSpans(text.AsSpan()); + + private static List<(int Start, int Length)> SplitTopLevelPipeSpans(ReadOnlySpan text) + { + if (text.IndexOf('|') < 0) + return [(0, text.Length)]; + + var spans = new List<(int Start, int Length)>(4); + var angleDepth = 0; + var parenDepth = 0; + var squareDepth = 0; + var start = 0; + for (var i = 0; i < text.Length; i++) + { + switch (text[i]) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) + angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) + parenDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) + squareDepth--; + break; + case '|' when angleDepth == 0 && parenDepth == 0 && squareDepth == 0: + spans.Add((start, i - start)); + start = i + 1; + break; + } + } + + spans.Add((start, text.Length - start)); + return spans; + } + + private static bool IsWordAt(string text, int index, string word) + { + if (index + word.Length > text.Length) + return false; + if (string.CompareOrdinal(text, index, word, 0, word.Length) != 0) + return false; + if (index > 0 && IsJavaIdentifierPart(text[index - 1])) + return false; + var after = index + word.Length; + return after >= text.Length || !IsJavaIdentifierPart(text[after]); + } + + private static bool TryFindFirstTopLevelCSharpArrow(string text, out int arrowIndex) + { + arrowIndex = -1; + var angleDepth = 0; + var parenDepth = 0; + var squareDepth = 0; + var braceDepth = 0; + for (var i = 0; i + 1 < text.Length; i++) + { + switch (text[i]) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) + angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) + parenDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) + squareDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) + braceDepth--; + break; + case '=': + if (text[i + 1] == '>' + && angleDepth == 0 + && parenDepth == 0 + && squareDepth == 0 + && braceDepth == 0) + { + arrowIndex = i; + return true; + } + + break; + } + } + + return false; + } + + private static bool IsRustLifetimeStart(char ch) => + ch == '_' || char.IsLetter(ch); + + private static bool IsRustLifetimePart(char ch) => + ch == '_' || char.IsLetterOrDigit(ch); + + private static bool IsSwiftTupleElementLabelSegment(string expression, int segmentStart, int segmentEnd) + { + var next = segmentEnd; + while (next < expression.Length && char.IsWhiteSpace(expression[next])) + next++; + if (next >= expression.Length || expression[next] != ':') + return false; + if (next + 1 < expression.Length && expression[next + 1] == ':') + return false; + + var previous = segmentStart - 1; + while (previous >= 0 && char.IsWhiteSpace(expression[previous])) + previous--; + + return previous >= 0 && expression[previous] is '(' or ','; + } + + private static bool IsSwiftMetatypeSuffixSegment(string expression, int segmentStart, string segment) + { + if (segment is not ("Type" or "Protocol")) + return false; + + var previous = segmentStart - 1; + while (previous >= 0 && char.IsWhiteSpace(expression[previous])) + previous--; + + return previous >= 0 && expression[previous] == '.'; + } + + private static void AddTypeScriptTypeExpressionSegments( + List references, + ReferenceDedupeSet seen, + long fileId, + string expression, + int expressionStartInLine, + string context, + int lineNumber, + SymbolRecord? container, + IReadOnlySet? ignoredSegments = null) + { + ignoredSegments ??= TypeScriptTypeExpressionIgnoredSegments; + + int i = 0; + while (i < expression.Length) + { + char c = expression[i]; + if (c == '\'' || c == '"') + { + i = SkipTypeScriptStringLiteral(expression, i); + continue; + } + + if (c == '`') + { + i = ScanTypeScriptTemplateLiteralForTypeExpression( + expression, + i, + expressionStartInLine, + context, + lineNumber, + references, + seen, + fileId, + container, + ignoredSegments); + continue; + } + + if (!IsJavaIdentifierStart(c)) + { + i++; + continue; + } + + int segmentStart = i; + i++; + while (i < expression.Length && IsJavaIdentifierPart(expression[i])) + i++; + + var segment = expression.Substring(segmentStart, i - segmentStart); + if (TypeScriptTypeExpressionIgnoredSegments.Contains(segment) + || ignoredSegments != null && ignoredSegments.Contains(segment)) + { + continue; + } + if (IsTypeScriptTypeLabelSegment(expression, i)) + continue; + + AddTypeReferenceSegment( + references, + seen, + fileId, + segment, + expressionStartInLine + segmentStart, + context, + lineNumber, + container, + "typescript", + ignoredSegments: ignoredSegments); + } + } + + private static bool IsTypeScriptTypeLabelSegment(string expression, int segmentEnd) + { + var next = segmentEnd; + while (next < expression.Length && char.IsWhiteSpace(expression[next])) + next++; + + return next < expression.Length + && expression[next] == ':' + && (next + 1 >= expression.Length || expression[next + 1] != ':'); + } + + private static int ScanTypeScriptTemplateLiteralForTypeExpression( + string expression, + int startIndex, + int expressionStartInLine, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + SymbolRecord? container, + IReadOnlySet? ignoredSegments) + { + int i = startIndex + 1; + while (i < expression.Length) + { + char c = expression[i]; + if (c == '\\') + { + i += Math.Min(2, expression.Length - i); + continue; + } + + if (c == '\'' || c == '"') + { + i = SkipTypeScriptStringLiteral(expression, i); + continue; + } + + if (c == '$' && i + 1 < expression.Length && expression[i + 1] == '{') + { + int holeStart = i + 2; + int holeEnd = FindMatchingTypeScriptHoleEndForTypeExpression(expression, holeStart); + if (holeEnd < 0) + return expression.Length; + + AddTypeScriptTypeExpressionSegments( + references, + seen, + fileId, + expression.Substring(holeStart, holeEnd - holeStart), + expressionStartInLine + holeStart, + context, + lineNumber, + container, + ignoredSegments); + i = holeEnd + 1; + continue; + } + + if (c == '`') + return i + 1; + + i++; + } + + return expression.Length; + } + + private static int FindMatchingTypeScriptHoleEndForTypeExpression(string text, int startIndex) + { + int braceDepth = 0; + int i = startIndex; + while (i < text.Length) + { + char c = text[i]; + if (c == '\\') + { + i += Math.Min(2, text.Length - i); + continue; + } + + if (c == '\'' || c == '"') + { + i = SkipTypeScriptStringLiteral(text, i); + continue; + } + + if (c == '`') + { + i = ScanTypeScriptTemplateLiteralForTypeExpression( + text, + i, + 0, + string.Empty, + 0, + [], + new ReferenceDedupeSet(), + 0, + null, + null); + continue; + } + + if (c == '{') + { + braceDepth++; + i++; + continue; + } + + if (c == '}') + { + if (braceDepth == 0) + return i; + braceDepth--; + i++; + continue; + } + + i++; + } + + return -1; + } + + private static int SkipTypeScriptStringLiteral(string text, int startIndex) + { + char quote = text[startIndex]; + int i = startIndex + 1; + while (i < text.Length) + { + if (text[i] == '\\') + { + i += Math.Min(2, text.Length - i); + continue; + } + + if (text[i] == quote) + return i + 1; + + i++; + } + + return text.Length; + } + + private static int SkipTypeScriptBlockCommentForTypeExpression(string text, int startIndex) + { + for (int i = startIndex; i + 1 < text.Length; i++) + { + if (text[i] == '*' && text[i + 1] == '/') + return i + 1; + } + + return text.Length - 1; + } + + private static int SkipBalanced(string line, int start, char open, char close) + { + int depth = 0; + int i = start; + while (i < line.Length) + { + char c = line[i]; + if (c == open) + depth++; + else if (c == close) + { + depth--; + if (depth <= 0) + return i + 1; + } + i++; + } + return i; + } + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs new file mode 100644 index 000000000..0e3b7491a --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs @@ -0,0 +1,774 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + internal static void EmitDeclarationTypeReferences( + string language, + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlySet? ignoredSegments = null) + { + if (TryFindCallableParameterList(line, language, out var callableNameStart, out var paramStart, out var paramEnd)) + { + if (TryGetCallableReturnTypeSpan(line, callableNameStart, language, out var typeStart, out var typeLength)) + { + AddTypeExpressionSegmentsForLanguage( + language, + references, + seen, + fileId, + line.Substring(typeStart, typeLength), + typeStart, + context, + lineNumber, + resolveContainerForColumn(typeStart), + ignoredSegments); + } + + EmitParameterTypeReferences( + language, + line, + paramStart, + paramEnd, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + ignoredSegments); + } + + if (TryGetSimpleDeclarationTypeSpan(line, language, out var declarationTypeStart, out var declarationTypeLength)) + { + AddTypeExpressionSegmentsForLanguage( + language, + references, + seen, + fileId, + line.Substring(declarationTypeStart, declarationTypeLength), + declarationTypeStart, + context, + lineNumber, + resolveContainerForColumn(declarationTypeStart), + ignoredSegments); + } + } + + internal static void EmitTypeScriptDeclarationTypeReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + int equalsIndex = FindTopLevelAssignmentIndex(line); + if (equalsIndex < 0) + return; + + var head = line.Substring(0, equalsIndex); + var tokens = GetTopLevelTokenSpans(head); + if (tokens.Count < 2) + return; + + int first = 0; + while (first < tokens.Count) + { + var token = head.Substring(tokens[first].Start, tokens[first].Length); + if (token is "export" or "declare") + { + first++; + continue; + } + + break; + } + + if (first >= tokens.Count - 1) + return; + + var keyword = head.Substring(tokens[first].Start, tokens[first].Length); + if (!string.Equals(keyword, "type", StringComparison.Ordinal)) + return; + + int typeStart = SkipWhitespace(line, equalsIndex + 1); + if (typeStart >= line.Length) + return; + + int typeEnd = FindTypeScriptTypeExpressionTerminator(line, typeStart); + if (typeEnd < 0) + typeEnd = line.Length; + + AddTypeScriptTypeExpressionSegments( + references, + seen, + fileId, + line.Substring(typeStart, typeEnd - typeStart), + typeStart, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + + private static int FindTypeScriptTypeExpressionTerminator(string line, int startIndex) + { + int angleDepth = 0; + int parenDepth = 0; + int squareDepth = 0; + int braceDepth = 0; + + for (int i = startIndex; i < line.Length; i++) + { + char c = line[i]; + if (c == '\'' || c == '"') + { + i = SkipTypeScriptStringLiteral(line, i) - 1; + continue; + } + + if (c == '`') + { + i = ScanTypeScriptTemplateLiteralForTypeExpression( + line, + i, + 0, + string.Empty, + 0, + [], + new ReferenceDedupeSet(), + 0, + null, + null) - 1; + continue; + } + + if (c == '/' && i + 1 < line.Length) + { + if (line[i + 1] == '/') + return i; + if (line[i + 1] == '*') + { + i = SkipTypeScriptBlockCommentForTypeExpression(line, i + 2); + continue; + } + } + + switch (c) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) + angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) + parenDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) + squareDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) + braceDepth--; + break; + case ';' when angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0: + return i; + } + } + + return line.Length; + } + + internal static bool TryFindCallableParameterList( + string line, + string language, + out int callableNameStart, + out int paramStart, + out int paramEnd) + { + callableNameStart = -1; + paramStart = -1; + paramEnd = -1; + + if (IsDefinitelyNotTypeDeclarationLine(line, language)) + return false; + + int openParen = FindFirstTopLevelChar(line, '('); + if (openParen <= 0) + return false; + if (!TryFindCallableName(line, openParen, language, out callableNameStart)) + return false; + + int closeParen = FindMatchingChar(line, openParen, '(', ')'); + if (closeParen < 0) + return false; + + paramStart = openParen + 1; + paramEnd = closeParen; + return true; + } + + private static bool TryFindCallableName(string line, int openParen, string language, out int nameStart) + { + nameStart = -1; + int i = openParen - 1; + while (i >= 0 && char.IsWhiteSpace(line[i])) + i--; + if (i < 0) + return false; + + if (line[i] == '>') + { + int depth = 1; + i--; + while (i >= 0 && depth > 0) + { + if (line[i] == '>') + depth++; + else if (line[i] == '<') + depth--; + i--; + } + while (i >= 0 && char.IsWhiteSpace(line[i])) + i--; + } + + if (i < 0 || !IsTypeExpressionIdentifierPart(language, line[i])) + return false; + int end = i + 1; + while (i >= 0 && IsTypeExpressionIdentifierPart(language, line[i])) + i--; + nameStart = i + 1; + + var name = line.Substring(nameStart, end - nameStart); + if (IsIgnoredCallName(language, name)) + return false; + return true; + } + + internal static bool TryGetCallableReturnTypeSpan(string line, int callableNameStart, string language, out int typeStart, out int typeLength) + { + typeStart = -1; + typeLength = 0; + var prefix = line.Substring(0, callableNameStart); + if (prefix.IndexOf('=') >= 0 || prefix.Contains("=>", StringComparison.Ordinal)) + return false; + + var tokens = GetTopLevelTokenSpans(prefix); + if (tokens.Count == 0) + return false; + + for (int i = tokens.Count - 1; i >= 0; i--) + { + var token = prefix.Substring(tokens[i].Start, tokens[i].Length); + if (IsCallablePrefixModifier(language, token) || token.StartsWith("[", StringComparison.Ordinal) || token.StartsWith("@", StringComparison.Ordinal)) + continue; + if (!HasWhitespaceGap(prefix, tokens[i].Start + tokens[i].Length)) + return false; + typeStart = tokens[i].Start; + typeLength = tokens[i].Length; + return true; + } + + return false; + } + + private static void EmitParameterTypeReferences( + string language, + string line, + int paramStart, + int paramEnd, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlySet? ignoredSegments = null) + { + if (paramEnd <= paramStart) + return; + + var parameterList = line.AsSpan(paramStart, paramEnd - paramStart); + foreach (var (segmentStart, segmentLength) in SplitTopLevelCommaSpans(parameterList)) + { + var fragment = parameterList.Slice(segmentStart, segmentLength).ToString(); + if (!TryGetParameterTypeRelativeSpan(fragment, language, out var typeRelativeStart, out var typeRelativeLength)) + continue; + + int absoluteStart = paramStart + segmentStart + typeRelativeStart; + AddTypeExpressionSegmentsForLanguage( + language, + references, + seen, + fileId, + fragment.Substring(typeRelativeStart, typeRelativeLength), + absoluteStart, + context, + lineNumber, + resolveContainerForColumn(absoluteStart), + ignoredSegments); + } + } + + private static void AddTypeExpressionSegmentsForLanguage( + string language, + List references, + ReferenceDedupeSet seen, + long fileId, + string expression, + int expressionStartInLine, + string context, + int lineNumber, + SymbolRecord? container, + IReadOnlySet? ignoredSegments = null) + { + if (language == "typescript") + { + AddTypeScriptTypeExpressionSegments( + references, + seen, + fileId, + expression, + expressionStartInLine, + context, + lineNumber, + container); + return; + } + + AddTypeExpressionSegments( + references, + seen, + fileId, + expression, + expressionStartInLine, + context, + lineNumber, + container, + language, + ignoredSegments: ignoredSegments); + } + + internal static void AddTypeScriptTypeExpressionSegments( + List references, + ReferenceDedupeSet seen, + long fileId, + string expression, + int expressionStartInLine, + string context, + int lineNumber, + SymbolRecord? container) + { + if (TypedLanguageReferenceExtractor.TryEmitTypeScriptFunctionTypeExpressionReferences( + expression, + expressionStartInLine, + references, + seen, + fileId, + context, + lineNumber, + container)) + { + return; + } + + for (int i = 0; i < expression.Length; i++) + { + char c = expression[i]; + + if (c is '\'' or '"') + { + i = SkipTypeScriptQuotedString(expression, i); + continue; + } + + if (c == '`') + { + i = SkipTypeScriptTemplateLiteral(expression, i, references, seen, fileId, expressionStartInLine, context, lineNumber, container); + continue; + } + + if (c == '/' && i + 1 < expression.Length) + { + if (expression[i + 1] == '/') + { + i = SkipTypeScriptLineComment(expression, i + 2); + continue; + } + + if (expression[i + 1] == '*') + { + i = SkipTypeScriptBlockCommentForTypeExpression(expression, i + 2); + continue; + } + } + + if (!IsTypeExpressionIdentifierStart("typescript", c)) + continue; + + int segmentStart = i; + while (i < expression.Length && IsTypeExpressionIdentifierPart("typescript", expression[i])) + i++; + + var segment = expression.Substring(segmentStart, i - segmentStart); + if (TypeScriptTypeExpressionIgnoredSegments.Contains(segment)) + { + i--; + continue; + } + + AddTypeReferenceSegment(references, seen, fileId, segment, expressionStartInLine + segmentStart, context, lineNumber, container, "typescript"); + i--; + } + } + + private static int SkipTypeScriptQuotedString(string text, int start) + { + char quote = text[start]; + int i = start + 1; + while (i < text.Length) + { + if (text[i] == '\\' && i + 1 < text.Length) + { + i += 2; + continue; + } + + if (text[i] == quote) + return i; + + i++; + } + + return text.Length - 1; + } + + private static int SkipTypeScriptLineComment(string text, int start) + { + int i = start; + while (i < text.Length && text[i] != '\n' && text[i] != '\r') + i++; + return Math.Max(start - 1, i - 1); + } + + private static int SkipTypeScriptBlockComment(string text, int start) + { + for (int i = start; i + 1 < text.Length; i++) + { + if (text[i] == '*' && text[i + 1] == '/') + return i + 1; + } + + return text.Length - 1; + } + + private static int SkipTypeScriptTemplateLiteral( + string text, + int start, + List references, + ReferenceDedupeSet seen, + long fileId, + int expressionStartInLine, + string context, + int lineNumber, + SymbolRecord? container) + { + int i = start + 1; + while (i < text.Length) + { + char c = text[i]; + if (c == '\\' && i + 1 < text.Length) + { + i += 2; + continue; + } + + if (c == '`') + return i; + + if (c == '$' && i + 1 < text.Length && text[i + 1] == '{') + { + int holeStart = i + 2; + int holeEnd = FindMatchingTypeScriptTemplateHoleEnd(text, holeStart); + if (holeEnd < 0) + return text.Length - 1; + + var hole = text.Substring(holeStart, holeEnd - holeStart); + AddTypeScriptTypeExpressionSegments( + references, + seen, + fileId, + hole, + expressionStartInLine + holeStart, + context, + lineNumber, + container); + i = holeEnd + 1; + continue; + } + + i++; + } + + return text.Length - 1; + } + + private static int SkipTypeScriptTemplateLiteralForMatching(string text, int start) + { + int i = start + 1; + while (i < text.Length) + { + char c = text[i]; + if (c == '\\' && i + 1 < text.Length) + { + i += 2; + continue; + } + + if (c == '`') + return i; + + if (c == '$' && i + 1 < text.Length && text[i + 1] == '{') + { + int holeEnd = FindMatchingTypeScriptTemplateHoleEnd(text, i + 2); + if (holeEnd < 0) + return text.Length - 1; + + i = holeEnd + 1; + continue; + } + + i++; + } + + return text.Length - 1; + } + + private static int FindMatchingTypeScriptTemplateHoleEnd(string text, int start) + { + int braceDepth = 1; + for (int i = start; i < text.Length; i++) + { + char c = text[i]; + + if (c is '\'' or '"') + { + i = SkipTypeScriptQuotedString(text, i); + continue; + } + + if (c == '`') + { + i = SkipTypeScriptTemplateLiteralForMatching(text, i); + continue; + } + + if (c == '/' && i + 1 < text.Length) + { + if (text[i + 1] == '/') + { + i = SkipTypeScriptLineComment(text, i + 2); + continue; + } + + if (text[i + 1] == '*') + { + i = SkipTypeScriptBlockComment(text, i + 2); + continue; + } + } + + if (c == '{') + { + braceDepth++; + continue; + } + + if (c == '}') + { + braceDepth--; + if (braceDepth == 0) + return i; + } + } + + return -1; + } + + private static bool TryGetParameterTypeRelativeSpan(string parameterFragment, string language, out int typeStart, out int typeLength) + { + typeStart = -1; + typeLength = 0; + + int end = FindTopLevelAssignmentIndex(parameterFragment); + if (end < 0) + end = parameterFragment.Length; + var candidate = parameterFragment.Substring(0, end); + var tokens = GetTopLevelTokenSpans(candidate); + if (tokens.Count < 2) + return false; + + int first = 0; + while (first < tokens.Count) + { + var token = candidate.Substring(tokens[first].Start, tokens[first].Length); + if (token.StartsWith("[", StringComparison.Ordinal) || token.StartsWith("@", StringComparison.Ordinal) || IsParameterModifier(language, token)) + { + first++; + continue; + } + + break; + } + + if (first >= tokens.Count - 1) + return false; + + typeStart = tokens[first].Start; + int lastTypeToken = tokens.Count - 2; + while (lastTypeToken >= first) + { + var token = candidate.Substring(tokens[lastTypeToken].Start, tokens[lastTypeToken].Length); + if (IsParameterModifier(language, token)) + { + lastTypeToken--; + continue; + } + + break; + } + + if (lastTypeToken < first) + return false; + typeLength = tokens[lastTypeToken].Start + tokens[lastTypeToken].Length - typeStart; + return true; + } + + private static bool TryGetSimpleDeclarationTypeSpan(string line, string language, out int typeStart, out int typeLength) + { + typeStart = -1; + typeLength = 0; + + if (IsDefinitelyNotTypeDeclarationLine(line, language)) + return false; + + int firstParen = FindFirstTopLevelChar(line, '('); + int firstTerminator = FindFirstTopLevelChar(line, ';'); + int firstBrace = FindFirstTopLevelChar(line, '{'); + int firstEquals = FindFirstTopLevelChar(line, '='); + int firstComma = FindFirstTopLevelChar(line, ','); + int boundary = int.MaxValue; + if (firstTerminator >= 0) boundary = Math.Min(boundary, firstTerminator); + if (firstBrace >= 0) boundary = Math.Min(boundary, firstBrace); + if (firstEquals >= 0) boundary = Math.Min(boundary, firstEquals); + if (firstComma >= 0) boundary = Math.Min(boundary, firstComma); + if (boundary == int.MaxValue) + return false; + if (firstParen >= 0 && firstParen < boundary) + return false; + + var head = line.Substring(0, boundary); + var tokens = GetTopLevelTokenSpans(head); + if (tokens.Count < 2) + return false; + + int first = 0; + while (first < tokens.Count) + { + var token = head.Substring(tokens[first].Start, tokens[first].Length); + if (token.StartsWith("[", StringComparison.Ordinal) || token.StartsWith("@", StringComparison.Ordinal) || IsDeclarationModifier(language, token)) + { + first++; + continue; + } + + break; + } + + if (first >= tokens.Count - 1) + return false; + + var declaredNameToken = head.Substring(tokens[^1].Start, tokens[^1].Length); + if (!IsSimpleDeclarationIdentifier(language, declaredNameToken)) + return false; + + typeStart = tokens[first].Start; + int lastTypeToken = tokens.Count - 2; + typeLength = tokens[lastTypeToken].Start + tokens[lastTypeToken].Length - typeStart; + return true; + } + + private static bool IsDefinitelyNotTypeDeclarationLine(string line, string language) + { + var trimmed = line.TrimStart(); + if (trimmed.Length == 0) + return true; + if (language == "csharp" + && TryFindFirstTopLevelCSharpArrow(line, out var arrowIndex)) + { + var commaIndex = FindFirstTopLevelChar(line, ','); + var semicolonIndex = FindFirstTopLevelChar(line, ';'); + if (commaIndex > arrowIndex && (semicolonIndex < 0 || commaIndex < semicolonIndex)) + return true; + } + + if (trimmed.StartsWith("using ", StringComparison.Ordinal) + || trimmed.StartsWith("namespace ", StringComparison.Ordinal) + || trimmed.StartsWith("package ", StringComparison.Ordinal) + || trimmed.StartsWith("import ", StringComparison.Ordinal) + || trimmed.StartsWith("return ", StringComparison.Ordinal) + || trimmed.StartsWith("throw ", StringComparison.Ordinal) + || trimmed.StartsWith("if ", StringComparison.Ordinal) + || trimmed.StartsWith("if(", StringComparison.Ordinal) + || trimmed.StartsWith("switch ", StringComparison.Ordinal) + || trimmed.StartsWith("switch(", StringComparison.Ordinal) + || trimmed.StartsWith("while ", StringComparison.Ordinal) + || trimmed.StartsWith("while(", StringComparison.Ordinal) + || trimmed.StartsWith("for ", StringComparison.Ordinal) + || trimmed.StartsWith("for(", StringComparison.Ordinal) + || trimmed.StartsWith("foreach ", StringComparison.Ordinal) + || trimmed.StartsWith("foreach(", StringComparison.Ordinal) + || trimmed.StartsWith("catch ", StringComparison.Ordinal) + || trimmed.StartsWith("catch(", StringComparison.Ordinal) + || trimmed.StartsWith("lock ", StringComparison.Ordinal) + || trimmed.StartsWith("lock(", StringComparison.Ordinal) + || trimmed.StartsWith("case ", StringComparison.Ordinal) + || trimmed.StartsWith("else", StringComparison.Ordinal) + || trimmed.StartsWith("do", StringComparison.Ordinal)) + { + return true; + } + + return trimmed.StartsWith("class ", StringComparison.Ordinal) + || trimmed.StartsWith("struct ", StringComparison.Ordinal) + || trimmed.StartsWith("interface ", StringComparison.Ordinal) + || trimmed.StartsWith("record ", StringComparison.Ordinal) + || (language == "java" && trimmed.StartsWith("enum ", StringComparison.Ordinal)); + } + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.PatternTypeReferences.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.PatternTypeReferences.cs index 857e58732..c6eba458e 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.PatternTypeReferences.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.PatternTypeReferences.cs @@ -810,2130 +810,4 @@ private static void EmitCSharpWhereConstraintSegments( } } - internal static void EmitDeclarationTypeReferences( - string language, - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - IReadOnlySet? ignoredSegments = null) - { - if (TryFindCallableParameterList(line, language, out var callableNameStart, out var paramStart, out var paramEnd)) - { - if (TryGetCallableReturnTypeSpan(line, callableNameStart, language, out var typeStart, out var typeLength)) - { - AddTypeExpressionSegmentsForLanguage( - language, - references, - seen, - fileId, - line.Substring(typeStart, typeLength), - typeStart, - context, - lineNumber, - resolveContainerForColumn(typeStart), - ignoredSegments); - } - - EmitParameterTypeReferences( - language, - line, - paramStart, - paramEnd, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - ignoredSegments); - } - - if (TryGetSimpleDeclarationTypeSpan(line, language, out var declarationTypeStart, out var declarationTypeLength)) - { - AddTypeExpressionSegmentsForLanguage( - language, - references, - seen, - fileId, - line.Substring(declarationTypeStart, declarationTypeLength), - declarationTypeStart, - context, - lineNumber, - resolveContainerForColumn(declarationTypeStart), - ignoredSegments); - } - } - - internal static void EmitTypeScriptDeclarationTypeReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - int equalsIndex = FindTopLevelAssignmentIndex(line); - if (equalsIndex < 0) - return; - - var head = line.Substring(0, equalsIndex); - var tokens = GetTopLevelTokenSpans(head); - if (tokens.Count < 2) - return; - - int first = 0; - while (first < tokens.Count) - { - var token = head.Substring(tokens[first].Start, tokens[first].Length); - if (token is "export" or "declare") - { - first++; - continue; - } - - break; - } - - if (first >= tokens.Count - 1) - return; - - var keyword = head.Substring(tokens[first].Start, tokens[first].Length); - if (!string.Equals(keyword, "type", StringComparison.Ordinal)) - return; - - int typeStart = SkipWhitespace(line, equalsIndex + 1); - if (typeStart >= line.Length) - return; - - int typeEnd = FindTypeScriptTypeExpressionTerminator(line, typeStart); - if (typeEnd < 0) - typeEnd = line.Length; - - AddTypeScriptTypeExpressionSegments( - references, - seen, - fileId, - line.Substring(typeStart, typeEnd - typeStart), - typeStart, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - - private static int FindTypeScriptTypeExpressionTerminator(string line, int startIndex) - { - int angleDepth = 0; - int parenDepth = 0; - int squareDepth = 0; - int braceDepth = 0; - - for (int i = startIndex; i < line.Length; i++) - { - char c = line[i]; - if (c == '\'' || c == '"') - { - i = SkipTypeScriptStringLiteral(line, i) - 1; - continue; - } - - if (c == '`') - { - i = ScanTypeScriptTemplateLiteralForTypeExpression( - line, - i, - 0, - string.Empty, - 0, - [], - new ReferenceDedupeSet(), - 0, - null, - null) - 1; - continue; - } - - if (c == '/' && i + 1 < line.Length) - { - if (line[i + 1] == '/') - return i; - if (line[i + 1] == '*') - { - i = SkipTypeScriptBlockCommentForTypeExpression(line, i + 2); - continue; - } - } - - switch (c) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) - angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) - parenDepth--; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) - squareDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) - braceDepth--; - break; - case ';' when angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0: - return i; - } - } - - return line.Length; - } - - internal static bool TryFindCallableParameterList( - string line, - string language, - out int callableNameStart, - out int paramStart, - out int paramEnd) - { - callableNameStart = -1; - paramStart = -1; - paramEnd = -1; - - if (IsDefinitelyNotTypeDeclarationLine(line, language)) - return false; - - int openParen = FindFirstTopLevelChar(line, '('); - if (openParen <= 0) - return false; - if (!TryFindCallableName(line, openParen, language, out callableNameStart)) - return false; - - int closeParen = FindMatchingChar(line, openParen, '(', ')'); - if (closeParen < 0) - return false; - - paramStart = openParen + 1; - paramEnd = closeParen; - return true; - } - - private static bool TryFindCallableName(string line, int openParen, string language, out int nameStart) - { - nameStart = -1; - int i = openParen - 1; - while (i >= 0 && char.IsWhiteSpace(line[i])) - i--; - if (i < 0) - return false; - - if (line[i] == '>') - { - int depth = 1; - i--; - while (i >= 0 && depth > 0) - { - if (line[i] == '>') - depth++; - else if (line[i] == '<') - depth--; - i--; - } - while (i >= 0 && char.IsWhiteSpace(line[i])) - i--; - } - - if (i < 0 || !IsTypeExpressionIdentifierPart(language, line[i])) - return false; - int end = i + 1; - while (i >= 0 && IsTypeExpressionIdentifierPart(language, line[i])) - i--; - nameStart = i + 1; - - var name = line.Substring(nameStart, end - nameStart); - if (IsIgnoredCallName(language, name)) - return false; - return true; - } - - internal static bool TryGetCallableReturnTypeSpan(string line, int callableNameStart, string language, out int typeStart, out int typeLength) - { - typeStart = -1; - typeLength = 0; - var prefix = line.Substring(0, callableNameStart); - if (prefix.IndexOf('=') >= 0 || prefix.Contains("=>", StringComparison.Ordinal)) - return false; - - var tokens = GetTopLevelTokenSpans(prefix); - if (tokens.Count == 0) - return false; - - for (int i = tokens.Count - 1; i >= 0; i--) - { - var token = prefix.Substring(tokens[i].Start, tokens[i].Length); - if (IsCallablePrefixModifier(language, token) || token.StartsWith("[", StringComparison.Ordinal) || token.StartsWith("@", StringComparison.Ordinal)) - continue; - if (!HasWhitespaceGap(prefix, tokens[i].Start + tokens[i].Length)) - return false; - typeStart = tokens[i].Start; - typeLength = tokens[i].Length; - return true; - } - - return false; - } - - private static void EmitParameterTypeReferences( - string language, - string line, - int paramStart, - int paramEnd, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - IReadOnlySet? ignoredSegments = null) - { - if (paramEnd <= paramStart) - return; - - var parameterList = line.AsSpan(paramStart, paramEnd - paramStart); - foreach (var (segmentStart, segmentLength) in SplitTopLevelCommaSpans(parameterList)) - { - var fragment = parameterList.Slice(segmentStart, segmentLength).ToString(); - if (!TryGetParameterTypeRelativeSpan(fragment, language, out var typeRelativeStart, out var typeRelativeLength)) - continue; - - int absoluteStart = paramStart + segmentStart + typeRelativeStart; - AddTypeExpressionSegmentsForLanguage( - language, - references, - seen, - fileId, - fragment.Substring(typeRelativeStart, typeRelativeLength), - absoluteStart, - context, - lineNumber, - resolveContainerForColumn(absoluteStart), - ignoredSegments); - } - } - - private static void AddTypeExpressionSegmentsForLanguage( - string language, - List references, - ReferenceDedupeSet seen, - long fileId, - string expression, - int expressionStartInLine, - string context, - int lineNumber, - SymbolRecord? container, - IReadOnlySet? ignoredSegments = null) - { - if (language == "typescript") - { - AddTypeScriptTypeExpressionSegments( - references, - seen, - fileId, - expression, - expressionStartInLine, - context, - lineNumber, - container); - return; - } - - AddTypeExpressionSegments( - references, - seen, - fileId, - expression, - expressionStartInLine, - context, - lineNumber, - container, - language, - ignoredSegments: ignoredSegments); - } - - internal static void AddTypeScriptTypeExpressionSegments( - List references, - ReferenceDedupeSet seen, - long fileId, - string expression, - int expressionStartInLine, - string context, - int lineNumber, - SymbolRecord? container) - { - if (TypedLanguageReferenceExtractor.TryEmitTypeScriptFunctionTypeExpressionReferences( - expression, - expressionStartInLine, - references, - seen, - fileId, - context, - lineNumber, - container)) - { - return; - } - - for (int i = 0; i < expression.Length; i++) - { - char c = expression[i]; - - if (c is '\'' or '"') - { - i = SkipTypeScriptQuotedString(expression, i); - continue; - } - - if (c == '`') - { - i = SkipTypeScriptTemplateLiteral(expression, i, references, seen, fileId, expressionStartInLine, context, lineNumber, container); - continue; - } - - if (c == '/' && i + 1 < expression.Length) - { - if (expression[i + 1] == '/') - { - i = SkipTypeScriptLineComment(expression, i + 2); - continue; - } - - if (expression[i + 1] == '*') - { - i = SkipTypeScriptBlockCommentForTypeExpression(expression, i + 2); - continue; - } - } - - if (!IsTypeExpressionIdentifierStart("typescript", c)) - continue; - - int segmentStart = i; - while (i < expression.Length && IsTypeExpressionIdentifierPart("typescript", expression[i])) - i++; - - var segment = expression.Substring(segmentStart, i - segmentStart); - if (TypeScriptTypeExpressionIgnoredSegments.Contains(segment)) - { - i--; - continue; - } - - AddTypeReferenceSegment(references, seen, fileId, segment, expressionStartInLine + segmentStart, context, lineNumber, container, "typescript"); - i--; - } - } - - private static int SkipTypeScriptQuotedString(string text, int start) - { - char quote = text[start]; - int i = start + 1; - while (i < text.Length) - { - if (text[i] == '\\' && i + 1 < text.Length) - { - i += 2; - continue; - } - - if (text[i] == quote) - return i; - - i++; - } - - return text.Length - 1; - } - - private static int SkipTypeScriptLineComment(string text, int start) - { - int i = start; - while (i < text.Length && text[i] != '\n' && text[i] != '\r') - i++; - return Math.Max(start - 1, i - 1); - } - - private static int SkipTypeScriptBlockComment(string text, int start) - { - for (int i = start; i + 1 < text.Length; i++) - { - if (text[i] == '*' && text[i + 1] == '/') - return i + 1; - } - - return text.Length - 1; - } - - private static int SkipTypeScriptTemplateLiteral( - string text, - int start, - List references, - ReferenceDedupeSet seen, - long fileId, - int expressionStartInLine, - string context, - int lineNumber, - SymbolRecord? container) - { - int i = start + 1; - while (i < text.Length) - { - char c = text[i]; - if (c == '\\' && i + 1 < text.Length) - { - i += 2; - continue; - } - - if (c == '`') - return i; - - if (c == '$' && i + 1 < text.Length && text[i + 1] == '{') - { - int holeStart = i + 2; - int holeEnd = FindMatchingTypeScriptTemplateHoleEnd(text, holeStart); - if (holeEnd < 0) - return text.Length - 1; - - var hole = text.Substring(holeStart, holeEnd - holeStart); - AddTypeScriptTypeExpressionSegments( - references, - seen, - fileId, - hole, - expressionStartInLine + holeStart, - context, - lineNumber, - container); - i = holeEnd + 1; - continue; - } - - i++; - } - - return text.Length - 1; - } - - private static int SkipTypeScriptTemplateLiteralForMatching(string text, int start) - { - int i = start + 1; - while (i < text.Length) - { - char c = text[i]; - if (c == '\\' && i + 1 < text.Length) - { - i += 2; - continue; - } - - if (c == '`') - return i; - - if (c == '$' && i + 1 < text.Length && text[i + 1] == '{') - { - int holeEnd = FindMatchingTypeScriptTemplateHoleEnd(text, i + 2); - if (holeEnd < 0) - return text.Length - 1; - - i = holeEnd + 1; - continue; - } - - i++; - } - - return text.Length - 1; - } - - private static int FindMatchingTypeScriptTemplateHoleEnd(string text, int start) - { - int braceDepth = 1; - for (int i = start; i < text.Length; i++) - { - char c = text[i]; - - if (c is '\'' or '"') - { - i = SkipTypeScriptQuotedString(text, i); - continue; - } - - if (c == '`') - { - i = SkipTypeScriptTemplateLiteralForMatching(text, i); - continue; - } - - if (c == '/' && i + 1 < text.Length) - { - if (text[i + 1] == '/') - { - i = SkipTypeScriptLineComment(text, i + 2); - continue; - } - - if (text[i + 1] == '*') - { - i = SkipTypeScriptBlockComment(text, i + 2); - continue; - } - } - - if (c == '{') - { - braceDepth++; - continue; - } - - if (c == '}') - { - braceDepth--; - if (braceDepth == 0) - return i; - } - } - - return -1; - } - - private static bool TryGetParameterTypeRelativeSpan(string parameterFragment, string language, out int typeStart, out int typeLength) - { - typeStart = -1; - typeLength = 0; - - int end = FindTopLevelAssignmentIndex(parameterFragment); - if (end < 0) - end = parameterFragment.Length; - var candidate = parameterFragment.Substring(0, end); - var tokens = GetTopLevelTokenSpans(candidate); - if (tokens.Count < 2) - return false; - - int first = 0; - while (first < tokens.Count) - { - var token = candidate.Substring(tokens[first].Start, tokens[first].Length); - if (token.StartsWith("[", StringComparison.Ordinal) || token.StartsWith("@", StringComparison.Ordinal) || IsParameterModifier(language, token)) - { - first++; - continue; - } - - break; - } - - if (first >= tokens.Count - 1) - return false; - - typeStart = tokens[first].Start; - int lastTypeToken = tokens.Count - 2; - while (lastTypeToken >= first) - { - var token = candidate.Substring(tokens[lastTypeToken].Start, tokens[lastTypeToken].Length); - if (IsParameterModifier(language, token)) - { - lastTypeToken--; - continue; - } - - break; - } - - if (lastTypeToken < first) - return false; - typeLength = tokens[lastTypeToken].Start + tokens[lastTypeToken].Length - typeStart; - return true; - } - - private static bool TryGetSimpleDeclarationTypeSpan(string line, string language, out int typeStart, out int typeLength) - { - typeStart = -1; - typeLength = 0; - - if (IsDefinitelyNotTypeDeclarationLine(line, language)) - return false; - - int firstParen = FindFirstTopLevelChar(line, '('); - int firstTerminator = FindFirstTopLevelChar(line, ';'); - int firstBrace = FindFirstTopLevelChar(line, '{'); - int firstEquals = FindFirstTopLevelChar(line, '='); - int firstComma = FindFirstTopLevelChar(line, ','); - int boundary = int.MaxValue; - if (firstTerminator >= 0) boundary = Math.Min(boundary, firstTerminator); - if (firstBrace >= 0) boundary = Math.Min(boundary, firstBrace); - if (firstEquals >= 0) boundary = Math.Min(boundary, firstEquals); - if (firstComma >= 0) boundary = Math.Min(boundary, firstComma); - if (boundary == int.MaxValue) - return false; - if (firstParen >= 0 && firstParen < boundary) - return false; - - var head = line.Substring(0, boundary); - var tokens = GetTopLevelTokenSpans(head); - if (tokens.Count < 2) - return false; - - int first = 0; - while (first < tokens.Count) - { - var token = head.Substring(tokens[first].Start, tokens[first].Length); - if (token.StartsWith("[", StringComparison.Ordinal) || token.StartsWith("@", StringComparison.Ordinal) || IsDeclarationModifier(language, token)) - { - first++; - continue; - } - - break; - } - - if (first >= tokens.Count - 1) - return false; - - var declaredNameToken = head.Substring(tokens[^1].Start, tokens[^1].Length); - if (!IsSimpleDeclarationIdentifier(language, declaredNameToken)) - return false; - - typeStart = tokens[first].Start; - int lastTypeToken = tokens.Count - 2; - typeLength = tokens[lastTypeToken].Start + tokens[lastTypeToken].Length - typeStart; - return true; - } - - private static bool IsDefinitelyNotTypeDeclarationLine(string line, string language) - { - var trimmed = line.TrimStart(); - if (trimmed.Length == 0) - return true; - if (language == "csharp" - && TryFindFirstTopLevelCSharpArrow(line, out var arrowIndex)) - { - var commaIndex = FindFirstTopLevelChar(line, ','); - var semicolonIndex = FindFirstTopLevelChar(line, ';'); - if (commaIndex > arrowIndex && (semicolonIndex < 0 || commaIndex < semicolonIndex)) - return true; - } - - if (trimmed.StartsWith("using ", StringComparison.Ordinal) - || trimmed.StartsWith("namespace ", StringComparison.Ordinal) - || trimmed.StartsWith("package ", StringComparison.Ordinal) - || trimmed.StartsWith("import ", StringComparison.Ordinal) - || trimmed.StartsWith("return ", StringComparison.Ordinal) - || trimmed.StartsWith("throw ", StringComparison.Ordinal) - || trimmed.StartsWith("if ", StringComparison.Ordinal) - || trimmed.StartsWith("if(", StringComparison.Ordinal) - || trimmed.StartsWith("switch ", StringComparison.Ordinal) - || trimmed.StartsWith("switch(", StringComparison.Ordinal) - || trimmed.StartsWith("while ", StringComparison.Ordinal) - || trimmed.StartsWith("while(", StringComparison.Ordinal) - || trimmed.StartsWith("for ", StringComparison.Ordinal) - || trimmed.StartsWith("for(", StringComparison.Ordinal) - || trimmed.StartsWith("foreach ", StringComparison.Ordinal) - || trimmed.StartsWith("foreach(", StringComparison.Ordinal) - || trimmed.StartsWith("catch ", StringComparison.Ordinal) - || trimmed.StartsWith("catch(", StringComparison.Ordinal) - || trimmed.StartsWith("lock ", StringComparison.Ordinal) - || trimmed.StartsWith("lock(", StringComparison.Ordinal) - || trimmed.StartsWith("case ", StringComparison.Ordinal) - || trimmed.StartsWith("else", StringComparison.Ordinal) - || trimmed.StartsWith("do", StringComparison.Ordinal)) - { - return true; - } - - return trimmed.StartsWith("class ", StringComparison.Ordinal) - || trimmed.StartsWith("struct ", StringComparison.Ordinal) - || trimmed.StartsWith("interface ", StringComparison.Ordinal) - || trimmed.StartsWith("record ", StringComparison.Ordinal) - || (language == "java" && trimmed.StartsWith("enum ", StringComparison.Ordinal)); - } - - internal static void EmitCatchTypeReferences( - string language, - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (language is not ("csharp" or "java" or "kotlin")) - return; - - var catchIndex = FindTopLevelKeyword(line, "catch"); - if (catchIndex < 0) - return; - - var openParen = line.IndexOf('(', catchIndex + "catch".Length); - if (openParen < 0) - return; - - var closeParen = FindMatchingChar(line, openParen, '(', ')'); - if (closeParen < 0 || closeParen <= openParen + 1) - return; - - var clauseStart = openParen + 1; - var clause = line.Substring(clauseStart, closeParen - clauseStart); - if (language == "kotlin") - { - EmitKotlinCatchTypeReference( - clause, - clauseStart, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - return; - } - - EmitCStyleCatchTypeReferences( - language, - clause, - clauseStart, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - - private static void EmitKotlinCatchTypeReference( - string clause, - int clauseStartInLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(clause, ':'); - if (colonIndex < 0) - return; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(clause, colonIndex + 1); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(clause, typeStart); - if (typeEnd <= typeStart) - return; - - var absoluteStart = clauseStartInLine + typeStart; - AddTypeExpressionSegments( - references, - seen, - fileId, - clause.Substring(typeStart, typeEnd - typeStart), - absoluteStart, - context, - lineNumber, - resolveContainerForColumn(absoluteStart), - "kotlin"); - } - - private static void EmitCStyleCatchTypeReferences( - string language, - string clause, - int clauseStartInLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var start = SkipCatchParameterPrefix(language, clause, 0); - var end = clause.Length; - while (end > start && char.IsWhiteSpace(clause[end - 1])) - end--; - if (end <= start) - return; - - var typeEnd = FindCatchTypeEndBeforeVariable(language, clause, start, end); - if (typeEnd <= start) - return; - - var typeExpression = clause.AsSpan(start, typeEnd - start); - foreach (var (segmentStart, segmentLength) in SplitTopLevelPipeSpans(typeExpression)) - { - var leading = CountLeadingWhitespace(typeExpression, segmentStart, segmentLength); - var trimmedLength = segmentLength - leading; - while (trimmedLength > 0 && char.IsWhiteSpace(typeExpression[segmentStart + leading + trimmedLength - 1])) - trimmedLength--; - if (trimmedLength <= 0) - continue; - - var absoluteStart = clauseStartInLine + start + segmentStart + leading; - AddTypeExpressionSegments( - references, - seen, - fileId, - typeExpression.Slice(segmentStart + leading, trimmedLength).ToString(), - absoluteStart, - context, - lineNumber, - resolveContainerForColumn(absoluteStart), - language); - } - } - - private static int SkipCatchParameterPrefix(string language, string clause, int start) - { - var i = start; - while (i < clause.Length) - { - while (i < clause.Length && char.IsWhiteSpace(clause[i])) - i++; - - if (language == "java" && i < clause.Length && clause[i] == '@') - { - i = SkipJavaAnnotation(clause, i) + 1; - continue; - } - - if (language == "java" && IsWordAt(clause, i, "final")) - { - i += "final".Length; - continue; - } - - break; - } - - return i; - } - - private static int FindCatchTypeEndBeforeVariable(string language, string clause, int start, int end) - { - if (!TryFindLastIdentifier(clause, start, end, out var lastStart, out _)) - return end; - - var before = clause.AsSpan(start, lastStart - start).TrimEnd(); - if (language == "csharp" && before.EndsWith("@", StringComparison.Ordinal)) - { - var prefix = before[..^1].TrimEnd(); - if (prefix.Length == 0 - || prefix.EndsWith(".", StringComparison.Ordinal) - || prefix.EndsWith("::", StringComparison.Ordinal)) - { - return end; - } - - return lastStart - 1; - } - - if (before.Length == 0 - || before.EndsWith(".", StringComparison.Ordinal) - || before.EndsWith("::", StringComparison.Ordinal)) - { - return end; - } - - return lastStart; - } - - private static bool TryFindLastIdentifier(string text, int start, int end, out int identifierStart, out int identifierEnd) - { - identifierStart = -1; - identifierEnd = -1; - var i = end - 1; - while (i >= start && char.IsWhiteSpace(text[i])) - i--; - if (i < start || !IsJavaIdentifierPart(text[i])) - return false; - - identifierEnd = i + 1; - while (i >= start && IsJavaIdentifierPart(text[i])) - i--; - identifierStart = i + 1; - return identifierStart < identifierEnd; - } - - private static List<(int Start, int Length)> SplitTopLevelPipeSpans(string text) => SplitTopLevelPipeSpans(text.AsSpan()); - - private static List<(int Start, int Length)> SplitTopLevelPipeSpans(ReadOnlySpan text) - { - if (text.IndexOf('|') < 0) - return [(0, text.Length)]; - - var spans = new List<(int Start, int Length)>(4); - var angleDepth = 0; - var parenDepth = 0; - var squareDepth = 0; - var start = 0; - for (var i = 0; i < text.Length; i++) - { - switch (text[i]) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) - angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) - parenDepth--; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) - squareDepth--; - break; - case '|' when angleDepth == 0 && parenDepth == 0 && squareDepth == 0: - spans.Add((start, i - start)); - start = i + 1; - break; - } - } - - spans.Add((start, text.Length - start)); - return spans; - } - - private static bool IsWordAt(string text, int index, string word) - { - if (index + word.Length > text.Length) - return false; - if (string.CompareOrdinal(text, index, word, 0, word.Length) != 0) - return false; - if (index > 0 && IsJavaIdentifierPart(text[index - 1])) - return false; - var after = index + word.Length; - return after >= text.Length || !IsJavaIdentifierPart(text[after]); - } - - private static bool TryFindFirstTopLevelCSharpArrow(string text, out int arrowIndex) - { - arrowIndex = -1; - var angleDepth = 0; - var parenDepth = 0; - var squareDepth = 0; - var braceDepth = 0; - for (var i = 0; i + 1 < text.Length; i++) - { - switch (text[i]) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) - angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) - parenDepth--; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) - squareDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) - braceDepth--; - break; - case '=': - if (text[i + 1] == '>' - && angleDepth == 0 - && parenDepth == 0 - && squareDepth == 0 - && braceDepth == 0) - { - arrowIndex = i; - return true; - } - - break; - } - } - - return false; - } - - private static bool IsRustLifetimeStart(char ch) => - ch == '_' || char.IsLetter(ch); - - private static bool IsRustLifetimePart(char ch) => - ch == '_' || char.IsLetterOrDigit(ch); - - private static bool IsSwiftTupleElementLabelSegment(string expression, int segmentStart, int segmentEnd) - { - var next = segmentEnd; - while (next < expression.Length && char.IsWhiteSpace(expression[next])) - next++; - if (next >= expression.Length || expression[next] != ':') - return false; - if (next + 1 < expression.Length && expression[next + 1] == ':') - return false; - - var previous = segmentStart - 1; - while (previous >= 0 && char.IsWhiteSpace(expression[previous])) - previous--; - - return previous >= 0 && expression[previous] is '(' or ','; - } - - private static bool IsSwiftMetatypeSuffixSegment(string expression, int segmentStart, string segment) - { - if (segment is not ("Type" or "Protocol")) - return false; - - var previous = segmentStart - 1; - while (previous >= 0 && char.IsWhiteSpace(expression[previous])) - previous--; - - return previous >= 0 && expression[previous] == '.'; - } - - private static void AddTypeScriptTypeExpressionSegments( - List references, - ReferenceDedupeSet seen, - long fileId, - string expression, - int expressionStartInLine, - string context, - int lineNumber, - SymbolRecord? container, - IReadOnlySet? ignoredSegments = null) - { - ignoredSegments ??= TypeScriptTypeExpressionIgnoredSegments; - - int i = 0; - while (i < expression.Length) - { - char c = expression[i]; - if (c == '\'' || c == '"') - { - i = SkipTypeScriptStringLiteral(expression, i); - continue; - } - - if (c == '`') - { - i = ScanTypeScriptTemplateLiteralForTypeExpression( - expression, - i, - expressionStartInLine, - context, - lineNumber, - references, - seen, - fileId, - container, - ignoredSegments); - continue; - } - - if (!IsJavaIdentifierStart(c)) - { - i++; - continue; - } - - int segmentStart = i; - i++; - while (i < expression.Length && IsJavaIdentifierPart(expression[i])) - i++; - - var segment = expression.Substring(segmentStart, i - segmentStart); - if (TypeScriptTypeExpressionIgnoredSegments.Contains(segment) - || ignoredSegments != null && ignoredSegments.Contains(segment)) - { - continue; - } - if (IsTypeScriptTypeLabelSegment(expression, i)) - continue; - - AddTypeReferenceSegment( - references, - seen, - fileId, - segment, - expressionStartInLine + segmentStart, - context, - lineNumber, - container, - "typescript", - ignoredSegments: ignoredSegments); - } - } - - private static bool IsTypeScriptTypeLabelSegment(string expression, int segmentEnd) - { - var next = segmentEnd; - while (next < expression.Length && char.IsWhiteSpace(expression[next])) - next++; - - return next < expression.Length - && expression[next] == ':' - && (next + 1 >= expression.Length || expression[next + 1] != ':'); - } - - private static int ScanTypeScriptTemplateLiteralForTypeExpression( - string expression, - int startIndex, - int expressionStartInLine, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - SymbolRecord? container, - IReadOnlySet? ignoredSegments) - { - int i = startIndex + 1; - while (i < expression.Length) - { - char c = expression[i]; - if (c == '\\') - { - i += Math.Min(2, expression.Length - i); - continue; - } - - if (c == '\'' || c == '"') - { - i = SkipTypeScriptStringLiteral(expression, i); - continue; - } - - if (c == '$' && i + 1 < expression.Length && expression[i + 1] == '{') - { - int holeStart = i + 2; - int holeEnd = FindMatchingTypeScriptHoleEndForTypeExpression(expression, holeStart); - if (holeEnd < 0) - return expression.Length; - - AddTypeScriptTypeExpressionSegments( - references, - seen, - fileId, - expression.Substring(holeStart, holeEnd - holeStart), - expressionStartInLine + holeStart, - context, - lineNumber, - container, - ignoredSegments); - i = holeEnd + 1; - continue; - } - - if (c == '`') - return i + 1; - - i++; - } - - return expression.Length; - } - - private static int FindMatchingTypeScriptHoleEndForTypeExpression(string text, int startIndex) - { - int braceDepth = 0; - int i = startIndex; - while (i < text.Length) - { - char c = text[i]; - if (c == '\\') - { - i += Math.Min(2, text.Length - i); - continue; - } - - if (c == '\'' || c == '"') - { - i = SkipTypeScriptStringLiteral(text, i); - continue; - } - - if (c == '`') - { - i = ScanTypeScriptTemplateLiteralForTypeExpression( - text, - i, - 0, - string.Empty, - 0, - [], - new ReferenceDedupeSet(), - 0, - null, - null); - continue; - } - - if (c == '{') - { - braceDepth++; - i++; - continue; - } - - if (c == '}') - { - if (braceDepth == 0) - return i; - braceDepth--; - i++; - continue; - } - - i++; - } - - return -1; - } - - private static int SkipTypeScriptStringLiteral(string text, int startIndex) - { - char quote = text[startIndex]; - int i = startIndex + 1; - while (i < text.Length) - { - if (text[i] == '\\') - { - i += Math.Min(2, text.Length - i); - continue; - } - - if (text[i] == quote) - return i + 1; - - i++; - } - - return text.Length; - } - - private static int SkipTypeScriptBlockCommentForTypeExpression(string text, int startIndex) - { - for (int i = startIndex; i + 1 < text.Length; i++) - { - if (text[i] == '*' && text[i + 1] == '/') - return i + 1; - } - - return text.Length - 1; - } - - private static int SkipBalanced(string line, int start, char open, char close) - { - int depth = 0; - int i = start; - while (i < line.Length) - { - char c = line[i]; - if (c == open) - depth++; - else if (c == close) - { - depth--; - if (depth <= 0) - return i + 1; - } - i++; - } - return i; - } - - internal static int SkipJavaAnnotation(string text, int start) => SkipJavaAnnotation(text.AsSpan(), start); - - internal static int SkipJavaAnnotation(ReadOnlySpan text, int start) - { - int i = start + 1; - var annotationStart = i; - while (i < text.Length && IsJavaIdentifierPart(text[i])) - i++; - if (i < text.Length && text[i] == ':') - { - i++; - while (i < text.Length && char.IsWhiteSpace(text[i])) - i++; - } - else - { - i = annotationStart; - } - - if (i < text.Length && text[i] == '`') - { - var closeOffset = text[(i + 1)..].IndexOf('`'); - if (closeOffset < 0) - return start; - var closeIndex = i + 1 + closeOffset; - i = closeIndex + 1; - } - else - { - while (i < text.Length && (IsJavaIdentifierPart(text[i]) || text[i] == '.')) - i++; - } - - if (i < text.Length && text[i] == '(') - { - int close = FindMatchingChar(text, i, '(', ')'); - if (close >= 0) - return close; - } - - return i - 1; - } - - internal static int FindMatchingChar(string text, int openIndex, char open, char close) => FindMatchingChar(text.AsSpan(), openIndex, open, close); - - internal static int FindMatchingChar(ReadOnlySpan text, int openIndex, char open, char close) - { - int depth = 0; - for (int i = openIndex; i < text.Length; i++) - { - if (text[i] == open) - depth++; - else if (text[i] == close) - { - depth--; - if (depth == 0) - return i; - } - } - - return -1; - } - - private static int FindFirstTopLevelChar(string text, char target) => FindFirstTopLevelChar(text.AsSpan(), target); - - private static int FindFirstTopLevelChar(ReadOnlySpan text, char target) - { - int angleDepth = 0; - int parenDepth = 0; - int squareDepth = 0; - int braceDepth = 0; - for (int i = 0; i < text.Length; i++) - { - if (text[i] == target && angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0) - return i; - - switch (text[i]) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) parenDepth--; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) squareDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) braceDepth--; - break; - } - } - - return -1; - } - - private static int FindTopLevelAssignmentIndex(string text) - { - int angleDepth = 0; - int parenDepth = 0; - int squareDepth = 0; - int braceDepth = 0; - for (int i = 0; i < text.Length; i++) - { - switch (text[i]) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) parenDepth--; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) squareDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) braceDepth--; - break; - case '=' when angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0: - if (i + 1 >= text.Length || text[i + 1] != '>') - return i; - break; - } - } - - return -1; - } - - internal static List<(int Start, int Length)> GetTopLevelTokenSpans(string text) - { - var tokens = new List<(int Start, int Length)>(); - int angleDepth = 0; - int parenDepth = 0; - int squareDepth = 0; - int braceDepth = 0; - int tokenStart = -1; - - for (int i = 0; i < text.Length; i++) - { - char c = text[i]; - switch (c) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) parenDepth--; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) squareDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) braceDepth--; - break; - } - - bool topLevelWhitespace = char.IsWhiteSpace(c) && angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0; - if (topLevelWhitespace) - { - if (tokenStart >= 0) - { - tokens.Add((tokenStart, i - tokenStart)); - tokenStart = -1; - } - continue; - } - - if (tokenStart < 0) - tokenStart = i; - } - - if (tokenStart >= 0) - tokens.Add((tokenStart, text.Length - tokenStart)); - return tokens; - } - - internal static List<(int Start, int Length)> SplitTopLevelCommaSpans(string text) => SplitTopLevelCommaSpans(text.AsSpan()); - - internal static List<(int Start, int Length)> SplitTopLevelCommaSpans(ReadOnlySpan text) - { - if (text.IndexOf(',') < 0) - return [(0, text.Length)]; - - var spans = new List<(int Start, int Length)>(4); - int angleDepth = 0; - int parenDepth = 0; - int squareDepth = 0; - int braceDepth = 0; - int start = 0; - - for (int i = 0; i < text.Length; i++) - { - switch (text[i]) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) parenDepth--; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) squareDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) braceDepth--; - break; - case ',' when angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0: - spans.Add((start, i - start)); - start = i + 1; - break; - } - } - - spans.Add((start, text.Length - start)); - return spans; - } - - internal static (int Start, int Length) GetFirstTopLevelCommaSpan(string text) - { - if (text.IndexOf(',') < 0) - return (0, text.Length); - - int angleDepth = 0; - int parenDepth = 0; - int squareDepth = 0; - int braceDepth = 0; - - for (int i = 0; i < text.Length; i++) - { - switch (text[i]) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) parenDepth--; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) squareDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) braceDepth--; - break; - case ',' when angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0: - return (0, i); - } - } - - return (0, text.Length); - } - - internal static List<(int Start, int Length)> SplitTopLevelAmpersandSpans(string text) => SplitTopLevelAmpersandSpans(text.AsSpan()); - - internal static List<(int Start, int Length)> SplitTopLevelAmpersandSpans(ReadOnlySpan text) - { - if (text.IndexOf('&') < 0) - return [(0, text.Length)]; - - var spans = new List<(int Start, int Length)>(4); - int angleDepth = 0; - int parenDepth = 0; - int squareDepth = 0; - int braceDepth = 0; - int start = 0; - - for (int i = 0; i < text.Length; i++) - { - switch (text[i]) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) parenDepth--; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) squareDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) braceDepth--; - break; - case '&' when angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0: - spans.Add((start, i - start)); - start = i + 1; - break; - } - } - - spans.Add((start, text.Length - start)); - return spans; - } - - internal static int CountLeadingWhitespace(string text, int start, int length) => CountLeadingWhitespace(text.AsSpan(), start, length); - - internal static int CountLeadingWhitespace(ReadOnlySpan text, int start, int length) - { - int count = 0; - while (count < length && char.IsWhiteSpace(text[start + count])) - count++; - return count; - } - - internal static int FindTypeListTerminator(string text, bool allowArrow) => FindTypeListTerminator(text.AsSpan(), allowArrow); - - internal static int FindTypeListTerminator(ReadOnlySpan text, bool allowArrow) - { - int brace = FindFirstTopLevelChar(text, '{'); - int semi = FindFirstTopLevelChar(text, ';'); - int end = -1; - if (brace >= 0) end = brace; - if (semi >= 0 && (end < 0 || semi < end)) end = semi; - if (allowArrow) - { - int arrow = text.IndexOf("=>", StringComparison.Ordinal); - if (arrow >= 0 && (end < 0 || arrow < end)) - end = arrow; - } - return end; - } - - private static string TrimTrailingTypeListTerminator(string text) - { - int end = FindTypeListTerminator(text, allowArrow: true); - return end >= 0 ? text.Substring(0, end) : text; - } - - internal static int FindJavaTypeListTerminator(string text, int start) - { - int terminator = FindJavaTypeListTerminator(text.AsSpan(start)); - return terminator >= 0 ? start + terminator : -1; - } - - internal static int FindJavaTypeListTerminator(ReadOnlySpan text) - { - int angleDepth = 0; - int parenDepth = 0; - for (int i = 0; i < text.Length; i++) - { - char c = text[i]; - if (c == '<') - angleDepth++; - else if (c == '>') - { - if (angleDepth > 0) angleDepth--; - } - else if (c == '(') - parenDepth++; - else if (c == ')') - { - if (parenDepth > 0) parenDepth--; - } - else if (angleDepth == 0 && parenDepth == 0) - { - if (c == '{' || c == ';') - return i; - if (IsJavaBaseListTerminatorKeyword(text, i, 0, "implements") - || IsJavaBaseListTerminatorKeyword(text, i, 0, "permits") - || IsJavaBaseListTerminatorKeyword(text, i, 0, "throws")) - { - return i; - } - } - } - - return -1; - } - - internal static int FindTopLevelKeyword(string text, string keyword) => FindTopLevelKeyword(text.AsSpan(), keyword); - - internal static int FindTopLevelKeyword(ReadOnlySpan text, string keyword) - { - var keywordSpan = keyword.AsSpan(); - int angleDepth = 0; - int parenDepth = 0; - int squareDepth = 0; - int braceDepth = 0; - for (int i = 0; i < text.Length; i++) - { - char c = text[i]; - switch (c) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) parenDepth--; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) squareDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) braceDepth--; - break; - } - - if (angleDepth != 0 || parenDepth != 0 || squareDepth != 0 || braceDepth != 0) - continue; - if (i > 0 && IsJavaIdentifierPart(text[i - 1])) - continue; - if (i + keywordSpan.Length > text.Length || !text.Slice(i, keywordSpan.Length).SequenceEqual(keywordSpan)) - continue; - int after = i + keywordSpan.Length; - if (after < text.Length && IsJavaIdentifierPart(text[after])) - continue; - return i; - } - - return -1; - } - - private static bool IsCallablePrefixModifier(string language, string token) => - language == "csharp" - ? token is "public" or "private" or "protected" or "internal" or "file" or "static" or "readonly" or "required" or "volatile" or "const" - or "unsafe" or "new" or "sealed" or "abstract" or "virtual" or "override" or "extern" or "partial" or "async" or "ref" or "scoped" - : token is "public" or "private" or "protected" or "static" or "final" or "abstract" or "synchronized" or "native" or "strictfp" or "default"; - - private static bool IsParameterModifier(string language, string token) => - language == "csharp" - ? token is "ref" or "out" or "in" or "params" or "this" or "scoped" or "readonly" - : token is "final"; - - private static bool IsDeclarationModifier(string language, string token) => - language == "csharp" - ? token is "public" or "private" or "protected" or "internal" or "file" or "static" or "readonly" or "required" or "volatile" or "const" - or "unsafe" or "new" or "sealed" or "abstract" or "virtual" or "override" or "extern" or "partial" or "async" or "ref" or "scoped" or "event" - : token is "public" or "private" or "protected" or "static" or "final" or "abstract" or "volatile" or "transient" or "synchronized" or "native" or "strictfp"; - - private static bool IsSimpleDeclarationIdentifier(string language, string token) - { - if (string.IsNullOrWhiteSpace(token)) - return false; - if (!IsTypeExpressionIdentifierStart(language, token[0])) - return false; - for (int i = 1; i < token.Length; i++) - { - if (!IsTypeExpressionIdentifierPart(language, token[i])) - return false; - } - - return true; - } - - private static bool HasWhitespaceGap(string text, int start) - { - if (start >= text.Length) - return false; - for (int i = start; i < text.Length; i++) - { - if (!char.IsWhiteSpace(text[i])) - return false; - } - - return true; - } - - private static string NormalizeCSharpDocCref(string cref) - { - var text = cref.AsSpan().Trim(); - if (text.Length >= 2 && char.IsLetter(text[0]) && text[1] == ':') - text = text[2..]; - int paren = text.IndexOf('('); - if (paren >= 0) - text = text[..paren]; - int brace = text.IndexOf('{'); - if (brace >= 0) - text = text[..brace]; - return text.Trim().ToString(); - } - - private static bool IsCSharpIdentifierStart(char c) => - c == '_' || c == '@' || char.IsLetter(c); - - private static bool IsJavaIdentifierStart(char c) => - c == '_' || c == '$' || char.IsLetter(c); - - private static bool IsTypeExpressionIdentifierStart(string language, char c) => - language == "csharp" ? IsCSharpIdentifierStart(c) : IsJavaIdentifierStart(c); - - private static bool IsTypeExpressionIdentifierPart(string language, char c) => - language == "csharp" ? IsCSharpIdentifierPart(c) : IsJavaIdentifierPart(c); - - private static bool IsTypeScriptTypeQueryContext( - IReadOnlyList preparedLines, - int lineIndex, - string line, - List<(int Start, int Length)> tokens, - int keywordIndex) - { - for (int i = 0; i < keywordIndex; i++) - { - var token = line.Substring(tokens[i].Start, tokens[i].Length); - if (TypeScriptTypeQueryDisqualifyingTokens.Contains(token)) - return false; - - if (TypeScriptTypeQueryContextTokens.Contains(token)) - return true; - } - - if (keywordIndex == 0) - return HasTypeScriptTypeQueryLeadingContext(preparedLines, lineIndex); - - var previousToken = line.Substring(tokens[keywordIndex - 1].Start, tokens[keywordIndex - 1].Length); - return previousToken.EndsWith(':'); - } - - private static bool HasTypeScriptTypeQueryLeadingContext(IReadOnlyList preparedLines, int lineIndex) - { - for (int previousIndex = lineIndex - 1; previousIndex >= 0; previousIndex--) - { - var previousLine = preparedLines[previousIndex]; - if (string.IsNullOrWhiteSpace(previousLine)) - continue; - - if (IsTypeScriptTypeQueryLineContext(previousLine)) - return true; - - if (!IsTypeScriptTypeQueryContinuationLine(previousLine)) - return false; - } - - return false; - } - - private static bool IsTypeScriptTypeQueryLineContext(string line) - { - var tokens = GetTopLevelTokenSpans(line); - foreach (var token in tokens) - { - var text = line.Substring(token.Start, token.Length); - if (TypeScriptTypeQueryDisqualifyingTokens.Contains(text)) - return false; - if (TypeScriptTypeQueryContextTokens.Contains(text)) - return true; - } - - return false; - } - - private static bool IsTypeScriptTypeQueryContinuationLine(string line) - { - var trimmed = line.TrimEnd(); - if (trimmed.Length == 0) - return false; - - return trimmed[^1] is '<' or '(' or '[' or ',' or '|' or '&' or ':'; - } - - private static bool TryExtractTypeScriptTypeQueryTarget( - string line, - int startIndex, - out int targetStart, - out int targetLength, - out string? literalTarget) - { - targetStart = 0; - targetLength = 0; - literalTarget = null; - - var cursor = startIndex; - while (cursor < line.Length) - { - cursor = SkipWhitespace(line, cursor); - if (cursor >= line.Length) - return false; - - if (TryConsumeTypeScriptTypeQueryWrapper(line, cursor, "typeof", out cursor)) - continue; - - if (TryConsumeTypeScriptImportTypeWrapper(line, cursor, out cursor, out var importModuleStart, out var importModuleLength)) - { - if (cursor >= line.Length || line[cursor] != '.') - { - targetStart = importModuleStart; - targetLength = importModuleLength; - literalTarget = line.Substring(importModuleStart, importModuleLength); - return targetLength > 0; - } - - continue; - } - - if (line[cursor] == '(' || line[cursor] == '[') - { - cursor++; - continue; - } - - break; - } - - cursor = SkipWhitespace(line, cursor); - while (cursor < line.Length && line[cursor] == '.') - { - cursor++; - cursor = SkipWhitespace(line, cursor); - } - - if (cursor >= line.Length || !IsJavaIdentifierStart(line[cursor])) - return false; - - var end = cursor + 1; - while (end < line.Length && (IsJavaIdentifierPart(line[end]) || line[end] == '.')) - end++; - - targetStart = cursor; - targetLength = end - cursor; - return targetLength > 0; - } - - private static bool TryConsumeTypeScriptTypeQueryWrapper( - string line, - int cursor, - string keyword, - out int nextCursor) - { - nextCursor = cursor; - if (cursor + keyword.Length > line.Length - || !line.AsSpan(cursor, keyword.Length).Equals(keyword, StringComparison.Ordinal)) - { - return false; - } - - var nextIndex = cursor + keyword.Length; - if (nextIndex < line.Length && (char.IsLetterOrDigit(line[nextIndex]) || line[nextIndex] == '_')) - return false; - - nextCursor = nextIndex; - return true; - } - - private static bool TryConsumeTypeScriptImportTypeWrapper( - string line, - int cursor, - out int nextCursor, - out int moduleStart, - out int moduleLength) - { - nextCursor = cursor; - moduleStart = -1; - moduleLength = 0; - if (cursor + "import".Length > line.Length - || !line.AsSpan(cursor, "import".Length).Equals("import", StringComparison.Ordinal)) - { - return false; - } - - var nextIndex = cursor + "import".Length; - if (nextIndex < line.Length && (char.IsLetterOrDigit(line[nextIndex]) || line[nextIndex] == '_')) - return false; - - nextIndex = SkipWhitespace(line, nextIndex); - if (nextIndex >= line.Length || line[nextIndex] != '(') - return false; - - var moduleQuoteIndex = SkipWhitespace(line, nextIndex + 1); - if (moduleQuoteIndex >= line.Length || line[moduleQuoteIndex] is not '\'' and not '"') - return false; - - var moduleLiteralStart = moduleQuoteIndex + 1; - var moduleLiteralEnd = SkipTypeScriptStringLiteral(line, moduleQuoteIndex) - 1; - if (moduleLiteralEnd < moduleLiteralStart) - return false; - - var closeIndex = SkipBalanced(line, nextIndex, '(', ')'); - if (closeIndex <= nextIndex) - return false; - - moduleStart = moduleLiteralStart; - moduleLength = moduleLiteralEnd - moduleLiteralStart; - nextCursor = closeIndex; - return true; - } } diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeScriptTypeQueries.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeScriptTypeQueries.cs new file mode 100644 index 000000000..c9c9d0386 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeScriptTypeQueries.cs @@ -0,0 +1,201 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static bool IsTypeScriptTypeQueryContext( + IReadOnlyList preparedLines, + int lineIndex, + string line, + List<(int Start, int Length)> tokens, + int keywordIndex) + { + for (int i = 0; i < keywordIndex; i++) + { + var token = line.Substring(tokens[i].Start, tokens[i].Length); + if (TypeScriptTypeQueryDisqualifyingTokens.Contains(token)) + return false; + + if (TypeScriptTypeQueryContextTokens.Contains(token)) + return true; + } + + if (keywordIndex == 0) + return HasTypeScriptTypeQueryLeadingContext(preparedLines, lineIndex); + + var previousToken = line.Substring(tokens[keywordIndex - 1].Start, tokens[keywordIndex - 1].Length); + return previousToken.EndsWith(':'); + } + + private static bool HasTypeScriptTypeQueryLeadingContext(IReadOnlyList preparedLines, int lineIndex) + { + for (int previousIndex = lineIndex - 1; previousIndex >= 0; previousIndex--) + { + var previousLine = preparedLines[previousIndex]; + if (string.IsNullOrWhiteSpace(previousLine)) + continue; + + if (IsTypeScriptTypeQueryLineContext(previousLine)) + return true; + + if (!IsTypeScriptTypeQueryContinuationLine(previousLine)) + return false; + } + + return false; + } + + private static bool IsTypeScriptTypeQueryLineContext(string line) + { + var tokens = GetTopLevelTokenSpans(line); + foreach (var token in tokens) + { + var text = line.Substring(token.Start, token.Length); + if (TypeScriptTypeQueryDisqualifyingTokens.Contains(text)) + return false; + if (TypeScriptTypeQueryContextTokens.Contains(text)) + return true; + } + + return false; + } + + private static bool IsTypeScriptTypeQueryContinuationLine(string line) + { + var trimmed = line.TrimEnd(); + if (trimmed.Length == 0) + return false; + + return trimmed[^1] is '<' or '(' or '[' or ',' or '|' or '&' or ':'; + } + + private static bool TryExtractTypeScriptTypeQueryTarget( + string line, + int startIndex, + out int targetStart, + out int targetLength, + out string? literalTarget) + { + targetStart = 0; + targetLength = 0; + literalTarget = null; + + var cursor = startIndex; + while (cursor < line.Length) + { + cursor = SkipWhitespace(line, cursor); + if (cursor >= line.Length) + return false; + + if (TryConsumeTypeScriptTypeQueryWrapper(line, cursor, "typeof", out cursor)) + continue; + + if (TryConsumeTypeScriptImportTypeWrapper(line, cursor, out cursor, out var importModuleStart, out var importModuleLength)) + { + if (cursor >= line.Length || line[cursor] != '.') + { + targetStart = importModuleStart; + targetLength = importModuleLength; + literalTarget = line.Substring(importModuleStart, importModuleLength); + return targetLength > 0; + } + + continue; + } + + if (line[cursor] == '(' || line[cursor] == '[') + { + cursor++; + continue; + } + + break; + } + + cursor = SkipWhitespace(line, cursor); + while (cursor < line.Length && line[cursor] == '.') + { + cursor++; + cursor = SkipWhitespace(line, cursor); + } + + if (cursor >= line.Length || !IsJavaIdentifierStart(line[cursor])) + return false; + + var end = cursor + 1; + while (end < line.Length && (IsJavaIdentifierPart(line[end]) || line[end] == '.')) + end++; + + targetStart = cursor; + targetLength = end - cursor; + return targetLength > 0; + } + + private static bool TryConsumeTypeScriptTypeQueryWrapper( + string line, + int cursor, + string keyword, + out int nextCursor) + { + nextCursor = cursor; + if (cursor + keyword.Length > line.Length + || !line.AsSpan(cursor, keyword.Length).Equals(keyword, StringComparison.Ordinal)) + { + return false; + } + + var nextIndex = cursor + keyword.Length; + if (nextIndex < line.Length && (char.IsLetterOrDigit(line[nextIndex]) || line[nextIndex] == '_')) + return false; + + nextCursor = nextIndex; + return true; + } + + private static bool TryConsumeTypeScriptImportTypeWrapper( + string line, + int cursor, + out int nextCursor, + out int moduleStart, + out int moduleLength) + { + nextCursor = cursor; + moduleStart = -1; + moduleLength = 0; + if (cursor + "import".Length > line.Length + || !line.AsSpan(cursor, "import".Length).Equals("import", StringComparison.Ordinal)) + { + return false; + } + + var nextIndex = cursor + "import".Length; + if (nextIndex < line.Length && (char.IsLetterOrDigit(line[nextIndex]) || line[nextIndex] == '_')) + return false; + + nextIndex = SkipWhitespace(line, nextIndex); + if (nextIndex >= line.Length || line[nextIndex] != '(') + return false; + + var moduleQuoteIndex = SkipWhitespace(line, nextIndex + 1); + if (moduleQuoteIndex >= line.Length || line[moduleQuoteIndex] is not '\'' and not '"') + return false; + + var moduleLiteralStart = moduleQuoteIndex + 1; + var moduleLiteralEnd = SkipTypeScriptStringLiteral(line, moduleQuoteIndex) - 1; + if (moduleLiteralEnd < moduleLiteralStart) + return false; + + var closeIndex = SkipBalanced(line, nextIndex, '(', ')'); + if (closeIndex <= nextIndex) + return false; + + moduleStart = moduleLiteralStart; + moduleLength = moduleLiteralEnd - moduleLiteralStart; + nextCursor = closeIndex; + return true; + } +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeSyntax.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeSyntax.cs new file mode 100644 index 000000000..266a54333 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeSyntax.cs @@ -0,0 +1,575 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + internal static int SkipJavaAnnotation(string text, int start) => SkipJavaAnnotation(text.AsSpan(), start); + + internal static int SkipJavaAnnotation(ReadOnlySpan text, int start) + { + int i = start + 1; + var annotationStart = i; + while (i < text.Length && IsJavaIdentifierPart(text[i])) + i++; + if (i < text.Length && text[i] == ':') + { + i++; + while (i < text.Length && char.IsWhiteSpace(text[i])) + i++; + } + else + { + i = annotationStart; + } + + if (i < text.Length && text[i] == '`') + { + var closeOffset = text[(i + 1)..].IndexOf('`'); + if (closeOffset < 0) + return start; + var closeIndex = i + 1 + closeOffset; + i = closeIndex + 1; + } + else + { + while (i < text.Length && (IsJavaIdentifierPart(text[i]) || text[i] == '.')) + i++; + } + + if (i < text.Length && text[i] == '(') + { + int close = FindMatchingChar(text, i, '(', ')'); + if (close >= 0) + return close; + } + + return i - 1; + } + + internal static int FindMatchingChar(string text, int openIndex, char open, char close) => FindMatchingChar(text.AsSpan(), openIndex, open, close); + + internal static int FindMatchingChar(ReadOnlySpan text, int openIndex, char open, char close) + { + int depth = 0; + for (int i = openIndex; i < text.Length; i++) + { + if (text[i] == open) + depth++; + else if (text[i] == close) + { + depth--; + if (depth == 0) + return i; + } + } + + return -1; + } + + private static int FindFirstTopLevelChar(string text, char target) => FindFirstTopLevelChar(text.AsSpan(), target); + + private static int FindFirstTopLevelChar(ReadOnlySpan text, char target) + { + int angleDepth = 0; + int parenDepth = 0; + int squareDepth = 0; + int braceDepth = 0; + for (int i = 0; i < text.Length; i++) + { + if (text[i] == target && angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0) + return i; + + switch (text[i]) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) parenDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) squareDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) braceDepth--; + break; + } + } + + return -1; + } + + private static int FindTopLevelAssignmentIndex(string text) + { + int angleDepth = 0; + int parenDepth = 0; + int squareDepth = 0; + int braceDepth = 0; + for (int i = 0; i < text.Length; i++) + { + switch (text[i]) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) parenDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) squareDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) braceDepth--; + break; + case '=' when angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0: + if (i + 1 >= text.Length || text[i + 1] != '>') + return i; + break; + } + } + + return -1; + } + + internal static List<(int Start, int Length)> GetTopLevelTokenSpans(string text) + { + var tokens = new List<(int Start, int Length)>(); + int angleDepth = 0; + int parenDepth = 0; + int squareDepth = 0; + int braceDepth = 0; + int tokenStart = -1; + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + switch (c) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) parenDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) squareDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) braceDepth--; + break; + } + + bool topLevelWhitespace = char.IsWhiteSpace(c) && angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0; + if (topLevelWhitespace) + { + if (tokenStart >= 0) + { + tokens.Add((tokenStart, i - tokenStart)); + tokenStart = -1; + } + continue; + } + + if (tokenStart < 0) + tokenStart = i; + } + + if (tokenStart >= 0) + tokens.Add((tokenStart, text.Length - tokenStart)); + return tokens; + } + + internal static List<(int Start, int Length)> SplitTopLevelCommaSpans(string text) => SplitTopLevelCommaSpans(text.AsSpan()); + + internal static List<(int Start, int Length)> SplitTopLevelCommaSpans(ReadOnlySpan text) + { + if (text.IndexOf(',') < 0) + return [(0, text.Length)]; + + var spans = new List<(int Start, int Length)>(4); + int angleDepth = 0; + int parenDepth = 0; + int squareDepth = 0; + int braceDepth = 0; + int start = 0; + + for (int i = 0; i < text.Length; i++) + { + switch (text[i]) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) parenDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) squareDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) braceDepth--; + break; + case ',' when angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0: + spans.Add((start, i - start)); + start = i + 1; + break; + } + } + + spans.Add((start, text.Length - start)); + return spans; + } + + internal static (int Start, int Length) GetFirstTopLevelCommaSpan(string text) + { + if (text.IndexOf(',') < 0) + return (0, text.Length); + + int angleDepth = 0; + int parenDepth = 0; + int squareDepth = 0; + int braceDepth = 0; + + for (int i = 0; i < text.Length; i++) + { + switch (text[i]) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) parenDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) squareDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) braceDepth--; + break; + case ',' when angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0: + return (0, i); + } + } + + return (0, text.Length); + } + + internal static List<(int Start, int Length)> SplitTopLevelAmpersandSpans(string text) => SplitTopLevelAmpersandSpans(text.AsSpan()); + + internal static List<(int Start, int Length)> SplitTopLevelAmpersandSpans(ReadOnlySpan text) + { + if (text.IndexOf('&') < 0) + return [(0, text.Length)]; + + var spans = new List<(int Start, int Length)>(4); + int angleDepth = 0; + int parenDepth = 0; + int squareDepth = 0; + int braceDepth = 0; + int start = 0; + + for (int i = 0; i < text.Length; i++) + { + switch (text[i]) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) parenDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) squareDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) braceDepth--; + break; + case '&' when angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0: + spans.Add((start, i - start)); + start = i + 1; + break; + } + } + + spans.Add((start, text.Length - start)); + return spans; + } + + internal static int CountLeadingWhitespace(string text, int start, int length) => CountLeadingWhitespace(text.AsSpan(), start, length); + + internal static int CountLeadingWhitespace(ReadOnlySpan text, int start, int length) + { + int count = 0; + while (count < length && char.IsWhiteSpace(text[start + count])) + count++; + return count; + } + + internal static int FindTypeListTerminator(string text, bool allowArrow) => FindTypeListTerminator(text.AsSpan(), allowArrow); + + internal static int FindTypeListTerminator(ReadOnlySpan text, bool allowArrow) + { + int brace = FindFirstTopLevelChar(text, '{'); + int semi = FindFirstTopLevelChar(text, ';'); + int end = -1; + if (brace >= 0) end = brace; + if (semi >= 0 && (end < 0 || semi < end)) end = semi; + if (allowArrow) + { + int arrow = text.IndexOf("=>", StringComparison.Ordinal); + if (arrow >= 0 && (end < 0 || arrow < end)) + end = arrow; + } + return end; + } + + private static string TrimTrailingTypeListTerminator(string text) + { + int end = FindTypeListTerminator(text, allowArrow: true); + return end >= 0 ? text.Substring(0, end) : text; + } + + internal static int FindJavaTypeListTerminator(string text, int start) + { + int terminator = FindJavaTypeListTerminator(text.AsSpan(start)); + return terminator >= 0 ? start + terminator : -1; + } + + internal static int FindJavaTypeListTerminator(ReadOnlySpan text) + { + int angleDepth = 0; + int parenDepth = 0; + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (c == '<') + angleDepth++; + else if (c == '>') + { + if (angleDepth > 0) angleDepth--; + } + else if (c == '(') + parenDepth++; + else if (c == ')') + { + if (parenDepth > 0) parenDepth--; + } + else if (angleDepth == 0 && parenDepth == 0) + { + if (c == '{' || c == ';') + return i; + if (IsJavaBaseListTerminatorKeyword(text, i, 0, "implements") + || IsJavaBaseListTerminatorKeyword(text, i, 0, "permits") + || IsJavaBaseListTerminatorKeyword(text, i, 0, "throws")) + { + return i; + } + } + } + + return -1; + } + + internal static int FindTopLevelKeyword(string text, string keyword) => FindTopLevelKeyword(text.AsSpan(), keyword); + + internal static int FindTopLevelKeyword(ReadOnlySpan text, string keyword) + { + var keywordSpan = keyword.AsSpan(); + int angleDepth = 0; + int parenDepth = 0; + int squareDepth = 0; + int braceDepth = 0; + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + switch (c) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) parenDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) squareDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) braceDepth--; + break; + } + + if (angleDepth != 0 || parenDepth != 0 || squareDepth != 0 || braceDepth != 0) + continue; + if (i > 0 && IsJavaIdentifierPart(text[i - 1])) + continue; + if (i + keywordSpan.Length > text.Length || !text.Slice(i, keywordSpan.Length).SequenceEqual(keywordSpan)) + continue; + int after = i + keywordSpan.Length; + if (after < text.Length && IsJavaIdentifierPart(text[after])) + continue; + return i; + } + + return -1; + } + + private static bool IsCallablePrefixModifier(string language, string token) => + language == "csharp" + ? token is "public" or "private" or "protected" or "internal" or "file" or "static" or "readonly" or "required" or "volatile" or "const" + or "unsafe" or "new" or "sealed" or "abstract" or "virtual" or "override" or "extern" or "partial" or "async" or "ref" or "scoped" + : token is "public" or "private" or "protected" or "static" or "final" or "abstract" or "synchronized" or "native" or "strictfp" or "default"; + + private static bool IsParameterModifier(string language, string token) => + language == "csharp" + ? token is "ref" or "out" or "in" or "params" or "this" or "scoped" or "readonly" + : token is "final"; + + private static bool IsDeclarationModifier(string language, string token) => + language == "csharp" + ? token is "public" or "private" or "protected" or "internal" or "file" or "static" or "readonly" or "required" or "volatile" or "const" + or "unsafe" or "new" or "sealed" or "abstract" or "virtual" or "override" or "extern" or "partial" or "async" or "ref" or "scoped" or "event" + : token is "public" or "private" or "protected" or "static" or "final" or "abstract" or "volatile" or "transient" or "synchronized" or "native" or "strictfp"; + + private static bool IsSimpleDeclarationIdentifier(string language, string token) + { + if (string.IsNullOrWhiteSpace(token)) + return false; + if (!IsTypeExpressionIdentifierStart(language, token[0])) + return false; + for (int i = 1; i < token.Length; i++) + { + if (!IsTypeExpressionIdentifierPart(language, token[i])) + return false; + } + + return true; + } + + private static bool HasWhitespaceGap(string text, int start) + { + if (start >= text.Length) + return false; + for (int i = start; i < text.Length; i++) + { + if (!char.IsWhiteSpace(text[i])) + return false; + } + + return true; + } + + private static string NormalizeCSharpDocCref(string cref) + { + var text = cref.AsSpan().Trim(); + if (text.Length >= 2 && char.IsLetter(text[0]) && text[1] == ':') + text = text[2..]; + int paren = text.IndexOf('('); + if (paren >= 0) + text = text[..paren]; + int brace = text.IndexOf('{'); + if (brace >= 0) + text = text[..brace]; + return text.Trim().ToString(); + } + + private static bool IsCSharpIdentifierStart(char c) => + c == '_' || c == '@' || char.IsLetter(c); + + private static bool IsJavaIdentifierStart(char c) => + c == '_' || c == '$' || char.IsLetter(c); + + private static bool IsTypeExpressionIdentifierStart(string language, char c) => + language == "csharp" ? IsCSharpIdentifierStart(c) : IsJavaIdentifierStart(c); + + private static bool IsTypeExpressionIdentifierPart(string language, char c) => + language == "csharp" ? IsCSharpIdentifierPart(c) : IsJavaIdentifierPart(c); + +} From 80843dc7258972846394ea78058e109667713650 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:19:16 +0900 Subject: [PATCH 042/101] Split dynamic language reference extractors --- ...cDeclarativeReferenceExtractor.Dispatch.cs | 843 +++++ ...clarativeReferenceExtractor.PrologGoals.cs | 702 ++++ ...larativeReferenceExtractor.PrologSyntax.cs | 271 ++ ...larativeReferenceExtractor.TclArguments.cs | 390 +++ ...cDeclarativeReferenceExtractor.TclCalls.cs | 734 +++++ .../DynamicDeclarativeReferenceExtractor.cs | 2892 +---------------- ...ionalLanguageReferenceExtractor.Clojure.cs | 239 ++ ...tionalLanguageReferenceExtractor.Erlang.cs | 101 + ...ctionalLanguageReferenceExtractor.Ocaml.cs | 128 + ...nctionalLanguageReferenceExtractor.Raku.cs | 100 + .../FunctionalLanguageReferenceExtractor.cs | 528 --- 11 files changed, 3509 insertions(+), 3419 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.Dispatch.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.PrologGoals.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.PrologSyntax.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.TclArguments.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.TclCalls.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Clojure.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Erlang.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Ocaml.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Raku.cs diff --git a/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.Dispatch.cs b/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.Dispatch.cs new file mode 100644 index 000000000..5b60e99f9 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.Dispatch.cs @@ -0,0 +1,843 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using CodeIndex.Models; +using Regex = CodeIndex.Indexer.BoundedRegex; + +namespace CodeIndex.Indexer; + +internal static partial class DynamicDeclarativeReferenceExtractor +{ + public static void EmitAdditionalReferences( + string language, + string preparedLine, + string structuralLine, + ExtractionState state, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForCall, + Action addCallLikeReference) + { + var importScanLine = language == "tcl" + ? state.GetCallScanLine(language, lineNumber, structuralLine) + : structuralLine; + EmitImportReference( + language, + importScanLine, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForCall); + + if (language is "prolog" or "ambiguous_pl") + { + foreach (var call in state.GetPrologGoalCalls(lineNumber)) + { + if (!state.CallableNames.Contains(call.Name)) + continue; + + var prologContainer = call.IsTopLevelDirective + ? null + : state.ResolveContainer(lineNumber, call.Column, fallback: null); + if (call.IsTopLevelDirective) + { + ReferenceExtractor.AddReference( + references, + seen, + fileId, + call.Name, + call.Column, + "call", + context, + lineNumber, + container: null, + language); + continue; + } + + if (prologContainer != null + && !string.Equals(prologContainer.Name, call.Name, StringComparison.Ordinal)) + { + ReferenceExtractor.AddReference( + references, + seen, + fileId, + call.Name, + call.Column, + "call", + context, + lineNumber, + prologContainer, + language); + continue; + } + + addCallLikeReference(call.Name, call.Column); + } + + return; + } + + var callRegex = language switch + { + "crystal" => CrystalBareCallRegex, + "groovy" => GroovyBareCallRegex, + "tcl" => TclCommandRegex, + _ => null, + }; + if (callRegex == null) + return; + + if (language == "crystal") + { + foreach (Match match in BoundedRegex.EnumerateMatches(CrystalSuffixedParenthesizedCallRegex, preparedLine)) + { + var nameGroup = match.Groups["name"]; + if (state.CallableNames.Contains(nameGroup.Value)) + addCallLikeReference(nameGroup.Value, nameGroup.Index); + } + + foreach (Match match in BoundedRegex.EnumerateMatches(CrystalControlPredicateCallRegex, preparedLine)) + { + var nameGroup = match.Groups["name"]; + if (state.CallableNames.Contains(nameGroup.Value)) + addCallLikeReference(nameGroup.Value, nameGroup.Index); + } + } + else if (language == "groovy") + { + EmitGroovyControlBodyBareCalls( + preparedLine, + state.CallableNames, + addCallLikeReference); + } + + foreach (Match match in BoundedRegex.EnumerateMatches(callRegex, preparedLine)) + { + var nameGroup = match.Groups["name"]; + if (language == "tcl") + { + if (!state.TryResolveTclCallable( + nameGroup.Value, + out var referenceName, + out var targetQualifier, + out var referenceNameOffset)) + { + continue; + } + + if (targetQualifier != null + || !string.Equals(referenceName, nameGroup.Value, StringComparison.Ordinal)) + { + ReferenceExtractor.AddReference( + references, + seen, + fileId, + referenceName, + nameGroup.Index + referenceNameOffset, + "call", + context, + lineNumber, + resolveContainerForCall(nameGroup.Index), + language, + targetQualifier); + } + else + { + addCallLikeReference(nameGroup.Value, nameGroup.Index); + } + + continue; + } + + if (!state.CallableNames.Contains(nameGroup.Value)) + continue; + if (language == "groovy" + && IsGroovyClosureParameterHeader(preparedLine, nameGroup.Index)) + { + continue; + } + + addCallLikeReference(nameGroup.Value, nameGroup.Index); + } + + } + + private static void EmitGroovyControlBodyBareCalls( + string line, + IReadOnlySet callableNames, + Action addCallLikeReference) + { + for (var keywordColumn = 0; keywordColumn < line.Length; keywordColumn++) + { + if (!char.IsLetter(line[keywordColumn])) + continue; + + var keywordEnd = keywordColumn + 1; + while (keywordEnd < line.Length + && (char.IsLetterOrDigit(line[keywordEnd]) || line[keywordEnd] == '_')) + { + keywordEnd++; + } + + var keyword = line.AsSpan(keywordColumn, keywordEnd - keywordColumn); + if (!keyword.SequenceEqual("if") + && !keyword.SequenceEqual("while") + && !keyword.SequenceEqual("for")) + { + keywordColumn = keywordEnd - 1; + continue; + } + + var openingColumn = SkipWhitespace(line, keywordEnd); + if (openingColumn >= line.Length || line[openingColumn] != '(') + { + keywordColumn = keywordEnd - 1; + continue; + } + + var closingColumn = FindMatchingParenthesis(line, openingColumn); + if (closingColumn < 0) + { + keywordColumn = keywordEnd - 1; + continue; + } + + var nameColumn = SkipWhitespace(line, closingColumn + 1); + if (nameColumn >= line.Length + || !IsIdentifierStart(line[nameColumn])) + { + keywordColumn = closingColumn; + continue; + } + + var nameEnd = nameColumn + 1; + while (nameEnd < line.Length + && (char.IsLetterOrDigit(line[nameEnd]) || line[nameEnd] == '_')) + { + nameEnd++; + } + + var name = line[nameColumn..nameEnd]; + var nextColumn = SkipWhitespace(line, nameEnd); + if (callableNames.Contains(name) + && (nextColumn >= line.Length + || (line[nextColumn] != '(' + && line[nextColumn] != ':' + && line[nextColumn] != '='))) + { + addCallLikeReference(name, nameColumn); + } + + keywordColumn = closingColumn; + } + } + + private static int FindMatchingParenthesis(string line, int openingColumn) + { + var depth = 0; + for (var column = openingColumn; column < line.Length; column++) + { + if (line[column] == '(') + { + depth++; + } + else if (line[column] == ')' && --depth == 0) + { + return column; + } + } + + return -1; + } + + private static bool IsGroovyClosureParameterHeader(string line, int nameColumn) + { + var openingBrace = line.LastIndexOf('{', Math.Max(0, nameColumn - 1)); + if (openingBrace < 0) + return false; + + var closingBraceBeforeName = line.LastIndexOf('}', Math.Max(0, nameColumn - 1)); + if (closingBraceBeforeName > openingBrace) + return false; + + var arrowColumn = line.IndexOf("->", nameColumn, StringComparison.Ordinal); + if (arrowColumn < 0) + return false; + + var closingBraceAfterName = line.IndexOf('}', nameColumn); + if (closingBraceAfterName >= 0 && closingBraceAfterName < arrowColumn) + return false; + + return line.AsSpan(nameColumn, arrowColumn - nameColumn) + .IndexOfAny(';', '{', '}') < 0; + } + + private static int SkipWhitespace(string line, int column) + { + while (column < line.Length && char.IsWhiteSpace(line[column])) + column++; + return column; + } + + private static bool IsIdentifierStart(char value) => + value == '_' || char.IsLetter(value); + + public static bool ShouldSuppressGenericCall( + string language, + string preparedLine, + string name, + int callIndex, + int lineNumber, + ExtractionState? state, + SymbolRecord? container) + { + if (language == "ambiguous_pl") + return state?.HasPrologContainer(lineNumber) == true + || state?.IsPrologDirectiveLine(lineNumber) == true; + if (state?.IsDeclarationAt(lineNumber, callIndex, name) == true) + return true; + if (language == "crystal") + { + return MatchesDeclarationAt(CrystalMethodDeclarationRegex, preparedLine, name, callIndex) + || MatchesDeclarationAt(CrystalFunDeclarationRegex, preparedLine, name, callIndex); + } + if (language != "groovy") + return false; + if (name is "super" or "synchronized" or "this") + return true; + + if (MatchesDeclarationAt(GroovyMethodDeclarationRegex, preparedLine, name, callIndex)) + return true; + return container?.Kind == "class" + && string.Equals(container.Name, name, StringComparison.Ordinal) + && MatchesDeclarationAt(GroovyConstructorDeclarationRegex, preparedLine, name, callIndex); + } + + private static bool MatchesDeclarationAt( + Regex regex, + string line, + string name, + int callIndex) + { + foreach (Match declaration in BoundedRegex.EnumerateMatches(regex, line)) + { + var nameGroup = declaration.Groups["name"]; + if (nameGroup.Index == callIndex + && string.Equals(nameGroup.Value, name, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static int SkipQuotedToken(string line, int startColumn, char delimiter) + { + for (var column = startColumn + 1; column < line.Length; column++) + { + if (line[column] == '\\') + { + column++; + continue; + } + + if (line[column] != delimiter) + continue; + + if (column + 1 < line.Length && line[column + 1] == delimiter) + { + column++; + continue; + } + + return column + 1; + } + + return line.Length; + } + + private static bool HasClosingQuotedDelimiter( + string line, + int startColumn, + char delimiter) + { + for (var column = startColumn + 1; column < line.Length; column++) + { + if (line[column] == '\\') + { + column++; + continue; + } + + if (line[column] == delimiter) + return true; + } + + return false; + } + + private static void FillWithSpaces(char[] buffer, int startColumn) + { + for (var column = startColumn; column < buffer.Length; column++) + buffer[column] = ' '; + } + + private static void FillWithSpaces(char[] buffer, int startColumn, int endColumn) + { + for (var column = startColumn; column < endColumn && column < buffer.Length; column++) + buffer[column] = ' '; + } + + private static bool IsPrologHashOperator(string line, int column) + { + if (column + 1 >= line.Length) + return false; + + return line[column + 1] is '=' or '<' or '>' or '\\' or '/' or '#'; + } + + private static bool IsPerlLastIndexVariable(string line, int column) + { + if (column <= 0 + || line[column - 1] != '$' + || column + 1 >= line.Length) + { + return false; + } + + return line[column + 1] == '{' + || line[column + 1] == '_' + || char.IsLetter(line[column + 1]); + } + + private static bool IsPerlHashSigil(string line, int column) + { + if (column + 1 >= line.Length + || (line[column + 1] != '{' + && line[column + 1] != '_' + && !char.IsLetter(line[column + 1]))) + { + return false; + } + + var previousColumn = column - 1; + while (previousColumn >= 0 && char.IsWhiteSpace(line[previousColumn])) + previousColumn--; + + var tokenEnd = column + 1; + if (line[tokenEnd] == '{') + return true; + while (tokenEnd < line.Length + && (char.IsLetterOrDigit(line[tokenEnd]) || line[tokenEnd] == '_')) + { + tokenEnd++; + } + while (tokenEnd < line.Length && char.IsWhiteSpace(line[tokenEnd])) + tokenEnd++; + + if (previousColumn < 0) + return tokenEnd < line.Length && line[tokenEnd] is '=' or '{' or '['; + + var prefix = line.AsSpan(0, previousColumn + 1); + if (prefix.Contains(":-", StringComparison.Ordinal) + || prefix.Contains("-->", StringComparison.Ordinal) + || line[previousColumn] == '.') + { + return false; + } + + if (line[previousColumn] is '=' or '(' or '[' or '{' or ',' or '\\') + return true; + if (line[previousColumn] == '>' + && previousColumn > 0 + && line[previousColumn - 1] == '=') + { + return true; + } + + var previousTokenStart = previousColumn; + while (previousTokenStart >= 0 + && (char.IsLetterOrDigit(line[previousTokenStart]) + || line[previousTokenStart] == '_')) + { + previousTokenStart--; + } + var previousToken = line.AsSpan(previousTokenStart + 1, previousColumn - previousTokenStart); + return previousToken.Equals("my", StringComparison.Ordinal) + || previousToken.Equals("our", StringComparison.Ordinal) + || previousToken.Equals("state", StringComparison.Ordinal) + || previousToken.Equals("local", StringComparison.Ordinal) + || previousToken.Equals("return", StringComparison.Ordinal) + || previousToken.Equals("keys", StringComparison.Ordinal) + || previousToken.Equals("values", StringComparison.Ordinal) + || previousToken.Equals("each", StringComparison.Ordinal) + || previousToken.Equals("delete", StringComparison.Ordinal) + || previousToken.Equals("exists", StringComparison.Ordinal) + || previousToken.Equals("defined", StringComparison.Ordinal) + || previousToken.Equals("scalar", StringComparison.Ordinal); + } + + private static bool IsLikelyPerlModuloOperator(string line, int column) + { + var prefix = line.AsSpan(0, column); + var hasPerlContext = prefix.Contains('$') + || prefix.Contains('@') + || StartsWithPerlStatementKeyword(prefix); + if (!hasPerlContext) + return false; + + var previousColumn = column - 1; + while (previousColumn >= 0 && char.IsWhiteSpace(line[previousColumn])) + previousColumn--; + if (previousColumn < 0) + return false; + + if (column + 1 < line.Length && line[column + 1] == '=') + return true; + + var nextColumn = column + 1; + while (nextColumn < line.Length && char.IsWhiteSpace(line[nextColumn])) + nextColumn++; + if (nextColumn >= line.Length) + return false; + + var previous = line[previousColumn]; + var next = line[nextColumn]; + return (char.IsLetterOrDigit(previous) || previous is '_' or ')' or ']' or '}') + && (char.IsLetterOrDigit(next) || next is '_' or '$' or '@' or '(' or '+' or '-'); + } + + private static bool StartsWithPerlStatementKeyword(ReadOnlySpan prefix) + { + prefix = prefix.TrimStart(); + foreach (var keyword in new[] { "my", "our", "state", "local", "return" }) + { + if (!prefix.StartsWith(keyword, StringComparison.Ordinal)) + continue; + if (prefix.Length == keyword.Length || char.IsWhiteSpace(prefix[keyword.Length])) + return true; + } + + return false; + } + + private static bool IsLikelySlashyLiteralStart(string line, int column) + { + if (column + 1 >= line.Length || line[column + 1] is '/' or '*') + return false; + + var previousColumn = column - 1; + while (previousColumn >= 0 && char.IsWhiteSpace(line[previousColumn])) + previousColumn--; + + if (previousColumn < 0) + return true; + + if (line[previousColumn] is '=' or '(' or '[' or '{' or ',' or ':' or ';' + or '!' or '&' or '|' or '?' or '+' or '-' or '*' or '%' or '~') + { + return true; + } + + var tokenEnd = previousColumn + 1; + while (previousColumn >= 0 + && (char.IsLetterOrDigit(line[previousColumn]) || line[previousColumn] == '_')) + { + previousColumn--; + } + + var token = line.AsSpan(previousColumn + 1, tokenEnd - previousColumn - 1); + return token.SequenceEqual("return") + || token.SequenceEqual("case") + || token.SequenceEqual("throw") + || token.SequenceEqual("assert") + || token.SequenceEqual("in") + || token.SequenceEqual("when") + || token.SequenceEqual("if") + || token.SequenceEqual("elsif") + || token.SequenceEqual("unless") + || token.SequenceEqual("while") + || token.SequenceEqual("until"); + } + + private static void EnqueueAmbiguousPerlHeredocDelimiters( + string line, + string maskedLine, + Queue delimiters) + { + for (var column = 0; column + 1 < line.Length;) + { + if (line[column] is '\'' or '"' or '`') + { + column = SkipQuotedToken(line, column, line[column]); + continue; + } + + if (line[column] != '<' + || line[column + 1] != '<' + || maskedLine[column] != '<' + || maskedLine[column + 1] != '<') + { + column++; + continue; + } + + var delimiterColumn = column + 2; + var allowIndent = delimiterColumn < line.Length + && line[delimiterColumn] == '~'; + if (allowIndent) + delimiterColumn++; + + var beforeWhitespace = delimiterColumn; + delimiterColumn = SkipWhitespace(line, delimiterColumn); + var hasWhitespace = delimiterColumn > beforeWhitespace; + if (delimiterColumn >= line.Length) + break; + + string? delimiter = null; + var nextColumn = delimiterColumn + 1; + if (line[delimiterColumn] is '\'' or '"' or '`') + { + var quote = line[delimiterColumn]; + var closingColumn = delimiterColumn + 1; + while (closingColumn < line.Length + && line[closingColumn] != quote) + { + if (line[closingColumn] == '\\' + && closingColumn + 1 < line.Length) + { + closingColumn += 2; + } + else + { + closingColumn++; + } + } + + if (closingColumn < line.Length) + { + delimiter = line[(delimiterColumn + 1)..closingColumn]; + nextColumn = closingColumn + 1; + } + } + else + { + if (line[delimiterColumn] == '\\') + { + delimiterColumn++; + } + else if (hasWhitespace) + { + column += 2; + continue; + } + + var delimiterEnd = delimiterColumn; + while (delimiterEnd < line.Length + && (char.IsLetterOrDigit(line[delimiterEnd]) + || line[delimiterEnd] == '_')) + { + delimiterEnd++; + } + + if (delimiterEnd > delimiterColumn + && (char.IsLetter(line[delimiterColumn]) + || line[delimiterColumn] == '_')) + { + delimiter = line[delimiterColumn..delimiterEnd]; + nextColumn = delimiterEnd; + } + } + + if (!string.IsNullOrEmpty(delimiter)) + delimiters.Enqueue(new AmbiguousPerlHeredocDelimiter(delimiter, allowIndent)); + column = Math.Max(nextColumn, column + 2); + } + } + + private static bool TryBeginAmbiguousPerlQuoteLikeLiteral( + string line, + char[] buffer, + int column, + out AmbiguousPerlQuoteLikeState state, + out int contentColumn) + { + state = null!; + contentColumn = column; + if (column > 0 + && (char.IsLetterOrDigit(line[column - 1]) || line[column - 1] == '_')) + { + return false; + } + + var operatorLength = line.AsSpan(column) switch + { + var span when span.StartsWith("qq", StringComparison.Ordinal) + || span.StartsWith("qr", StringComparison.Ordinal) + || span.StartsWith("qw", StringComparison.Ordinal) + || span.StartsWith("qx", StringComparison.Ordinal) + || span.StartsWith("tr", StringComparison.Ordinal) => 2, + var span when span.StartsWith("q", StringComparison.Ordinal) + || span.StartsWith("m", StringComparison.Ordinal) + || span.StartsWith("s", StringComparison.Ordinal) + || span.StartsWith("y", StringComparison.Ordinal) => 1, + _ => 0, + }; + if (operatorLength == 0) + return false; + + var delimiterColumn = SkipWhitespace(line, column + operatorLength); + if (delimiterColumn >= line.Length + || char.IsLetterOrDigit(line[delimiterColumn]) + || line[delimiterColumn] == '_') + { + return false; + } + var delimiterSpan = line.AsSpan(delimiterColumn); + if (delimiterSpan.StartsWith("=>", StringComparison.Ordinal) + || delimiterSpan.StartsWith(":-", StringComparison.Ordinal) + || delimiterSpan.StartsWith("-->", StringComparison.Ordinal)) + { + return false; + } + + var openingDelimiter = line[delimiterColumn]; + var closingDelimiter = GetPairedClosingDelimiter(openingDelimiter); + var remainingSegments = line.AsSpan(column, operatorLength).SequenceEqual("s") + || line.AsSpan(column, operatorLength).SequenceEqual("tr") + || line.AsSpan(column, operatorLength).SequenceEqual("y") + ? 2 + : 1; + FillWithSpaces(buffer, column, delimiterColumn + 1); + state = new AmbiguousPerlQuoteLikeState( + openingDelimiter, + closingDelimiter, + remainingSegments); + contentColumn = delimiterColumn + 1; + return true; + } + + private static void MaskAmbiguousPerlQuoteLikeCharacter( + string line, + char[] buffer, + ref int column, + ref AmbiguousPerlQuoteLikeState? state) + { + var current = state!; + buffer[column] = ' '; + if (current.AwaitingNextOpeningDelimiter) + { + if (char.IsWhiteSpace(line[column])) + { + column++; + return; + } + + current.OpeningDelimiter = line[column]; + current.ClosingDelimiter = GetPairedClosingDelimiter(line[column]); + current.DelimiterDepth = current.OpeningDelimiter == current.ClosingDelimiter ? 0 : 1; + current.AwaitingNextOpeningDelimiter = false; + column++; + return; + } + + if (line[column] == '\\' && column + 1 < line.Length) + { + buffer[column + 1] = ' '; + column += 2; + return; + } + + if (current.OpeningDelimiter != current.ClosingDelimiter + && line[column] == current.OpeningDelimiter) + { + current.DelimiterDepth++; + column++; + return; + } + + if (line[column] != current.ClosingDelimiter) + { + column++; + return; + } + + if (current.OpeningDelimiter != current.ClosingDelimiter + && --current.DelimiterDepth > 0) + { + column++; + return; + } + + current.RemainingSegments--; + if (current.RemainingSegments == 0) + { + state = null; + } + else if (current.OpeningDelimiter != current.ClosingDelimiter) + { + current.AwaitingNextOpeningDelimiter = true; + } + else + { + current.DelimiterDepth = 0; + } + + column++; + } + + private static char GetPairedClosingDelimiter(char openingDelimiter) => + openingDelimiter switch + { + '(' => ')', + '[' => ']', + '{' => '}', + '<' => '>', + _ => openingDelimiter, + }; + + private static bool TryBeginCrystalPercentLiteral( + string line, + int column, + out char openingDelimiter, + out char closingDelimiter, + out int contentColumn) + { + openingDelimiter = '\0'; + closingDelimiter = '\0'; + contentColumn = column; + if (line[column] != '%' || column + 1 >= line.Length) + return false; + + var delimiterColumn = column + 1; + var hasTypePrefix = line[delimiterColumn] is 'q' or 'Q' or 'w' or 'W' or 'i' or 'I' or 'x' or 'r'; + if (hasTypePrefix) + delimiterColumn++; + if (delimiterColumn >= line.Length + || char.IsLetterOrDigit(line[delimiterColumn]) + || char.IsWhiteSpace(line[delimiterColumn]) + || (!hasTypePrefix && line[delimiterColumn] is not ('(' or '[' or '{' or '<'))) + { + return false; + } + + openingDelimiter = line[delimiterColumn]; + closingDelimiter = openingDelimiter switch + { + '(' => ')', + '[' => ']', + '{' => '}', + '<' => '>', + _ => openingDelimiter, + }; + contentColumn = delimiterColumn + 1; + return true; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.PrologGoals.cs b/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.PrologGoals.cs new file mode 100644 index 000000000..e0cd84eed --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.PrologGoals.cs @@ -0,0 +1,702 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using CodeIndex.Models; +using Regex = CodeIndex.Indexer.BoundedRegex; + +namespace CodeIndex.Indexer; + +internal static partial class DynamicDeclarativeReferenceExtractor +{ + private static IReadOnlyDictionary> BuildPrologGoalCalls( + IReadOnlyList lines, + IReadOnlyDictionary containersByLine, + IReadOnlySet callableNames) + { + var result = new Dictionary>(); + var frames = new Stack(); + var expectGoal = true; + SymbolRecord? activeContainer = null; + var scanningMultilineHead = false; + var multilineHeadParenthesisDepth = 0; + var multilineHeadParenthesesClosed = false; + var scanningDirective = false; + + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + var lineNumber = lineIndex + 1; + if (!containersByLine.TryGetValue(lineNumber, out var container)) + { + activeContainer = null; + scanningMultilineHead = false; + multilineHeadParenthesisDepth = 0; + multilineHeadParenthesesClosed = false; + if (!scanningDirective + && !StartsWithPrologGoalDirective(lines[lineIndex])) + { + frames.Clear(); + expectGoal = true; + continue; + } + + if (!scanningDirective) + { + frames.Clear(); + expectGoal = true; + scanningDirective = true; + } + + var directiveCalls = new List(); + ScanPrologGoalLine( + lines, + lineIndex, + lines[lineIndex], + callableNames, + frames, + ref expectGoal, + directiveCalls); + if (directiveCalls.Count > 0) + { + result[lineNumber] = directiveCalls + .Select(static call => call with { IsTopLevelDirective = true }) + .ToList(); + } + if (ContainsPrologClauseTerminator(lines[lineIndex])) + { + frames.Clear(); + expectGoal = true; + scanningDirective = false; + } + continue; + } + + scanningDirective = false; + if (activeContainer == null + || activeContainer.StartLine != container.StartLine + || !string.Equals(activeContainer.Name, container.Name, StringComparison.Ordinal)) + { + frames.Clear(); + activeContainer = container; + expectGoal = true; + multilineHeadParenthesisDepth = 0; + multilineHeadParenthesesClosed = false; + scanningMultilineHead = TryInitializePrologMultilineHeadScan( + lines, + container, + lineIndex, + ref multilineHeadParenthesisDepth, + ref multilineHeadParenthesesClosed); + } + + string callScanLine; + if (scanningMultilineHead) + { + var multilineHeadLine = lineNumber == container.StartLine + ? MaskLineBeforeColumn(lines[lineIndex], container.StartColumn ?? 0) + : lines[lineIndex]; + callScanLine = PreparePrologMultilineHeadScanLine( + multilineHeadLine, + ref multilineHeadParenthesisDepth, + ref multilineHeadParenthesesClosed, + out var headEnded); + scanningMultilineHead = !headEnded; + } + else + { + callScanLine = PreparePrologCallScanLine( + "prolog", + lines[lineIndex], + container.StartLine < lineNumber); + } + var lineCalls = new List(); + ScanPrologGoalLine( + lines, + lineIndex, + callScanLine, + callableNames, + frames, + ref expectGoal, + lineCalls); + if (lineCalls.Count > 0) + { + result[lineNumber] = lineCalls + .Select(call => IsTopLevelPrologDirectiveGoal(lines[lineIndex], call.Column) + ? call with { IsTopLevelDirective = true } + : call) + .ToList(); + } + + if (ContainsPrologClauseTerminator(callScanLine)) + { + frames.Clear(); + activeContainer = null; + expectGoal = true; + } + } + + return result; + } + + private static bool StartsWithPrologGoalDirective(string line) + { + var column = 0; + while (column < line.Length && char.IsWhiteSpace(line[column])) + column++; + return line.AsSpan(column).StartsWith(":-", StringComparison.Ordinal); + } + + private static IReadOnlySet BuildPrologDirectiveLines( + IReadOnlyList lines) + { + var directiveLines = new HashSet(); + var scanningDirective = false; + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + if (!scanningDirective && !StartsWithPrologGoalDirective(lines[lineIndex])) + continue; + + scanningDirective = true; + directiveLines.Add(lineIndex + 1); + if (ContainsPrologClauseTerminator(lines[lineIndex])) + scanningDirective = false; + } + + return directiveLines; + } + + private static bool IsTopLevelPrologDirectiveGoal(string line, int goalColumn) + { + var segmentStartColumn = 0; + for (var column = 0; column < goalColumn; column++) + { + if (IsPrologClauseTerminator(line, column)) + segmentStartColumn = column + 1; + } + + segmentStartColumn = SkipWhitespace(line, segmentStartColumn); + return segmentStartColumn + 2 <= goalColumn + && line.AsSpan(segmentStartColumn).StartsWith(":-", StringComparison.Ordinal); + } + + private static bool TryInitializePrologMultilineHeadScan( + IReadOnlyList lines, + SymbolRecord container, + int currentLineIndex, + ref int parenthesisDepth, + ref bool parenthesesClosed) + { + var startLineIndex = container.StartLine - 1; + if (startLineIndex < 0 || startLineIndex >= lines.Count || startLineIndex > currentLineIndex) + return false; + + var startColumn = Math.Clamp( + container.StartColumn ?? 0, + 0, + lines[startLineIndex].Length); + var headLine = lines[startLineIndex][startColumn..]; + var multilineHeadMatch = PrologMultilineHeadRegex.Match(headLine); + if (PrologHeadRegex.IsMatch(headLine) || !multilineHeadMatch.Success) + return false; + parenthesesClosed = !multilineHeadMatch.Groups["open"].Success; + + for (var lineIndex = startLineIndex; lineIndex < currentLineIndex; lineIndex++) + { + var line = lineIndex == startLineIndex + ? MaskLineBeforeColumn(lines[lineIndex], startColumn) + : lines[lineIndex]; + _ = PreparePrologMultilineHeadScanLine( + line, + ref parenthesisDepth, + ref parenthesesClosed, + out var headEnded); + if (headEnded) + return false; + } + + return true; + } + + private static string MaskLineBeforeColumn(string line, int startColumn) + { + startColumn = Math.Clamp(startColumn, 0, line.Length); + if (startColumn == 0) + return line; + + var masked = line.ToCharArray(); + FillWithSpaces(masked, 0, startColumn); + return new string(masked); + } + + private static string PreparePrologMultilineHeadScanLine( + string line, + ref int parenthesisDepth, + ref bool parenthesesClosed, + out bool headEnded) + { + headEnded = false; + for (var column = 0; column < line.Length; column++) + { + var ch = line[column]; + if (ch is '\'' or '"') + { + column = SkipQuotedToken(line, column, ch) - 1; + continue; + } + + if (!parenthesesClosed) + { + if (ch == '(') + { + parenthesisDepth++; + } + else if (ch == ')' && parenthesisDepth > 0) + { + parenthesisDepth--; + parenthesesClosed = parenthesisDepth == 0; + } + continue; + } + + if (line.AsSpan(column).StartsWith("-->", StringComparison.Ordinal)) + { + var masked = line.ToCharArray(); + FillWithSpaces(masked, 0, column + 3); + headEnded = true; + return new string(masked); + } + if (line.AsSpan(column).StartsWith(":-", StringComparison.Ordinal)) + { + var masked = line.ToCharArray(); + FillWithSpaces(masked, 0, column + 2); + headEnded = true; + return new string(masked); + } + if (IsPrologClauseTerminator(line, column)) + { + headEnded = true; + return new string(' ', line.Length); + } + } + + return new string(' ', line.Length); + } + + private static void ScanPrologGoalLine( + IReadOnlyList lines, + int lineIndex, + string line, + IReadOnlySet callableNames, + Stack frames, + ref bool expectGoal, + List calls) + { + for (var column = 0; column < line.Length;) + { + var ch = line[column]; + if (char.IsWhiteSpace(ch)) + { + column++; + continue; + } + + if (ch is '\'' or '"') + { + column = SkipQuotedToken(line, column, ch); + if (expectGoal) + expectGoal = false; + continue; + } + if (IsPrologClauseTerminator(line, column)) + { + frames.Clear(); + expectGoal = true; + column++; + continue; + } + + if (expectGoal) + { + if (line.AsSpan(column).StartsWith("-->", StringComparison.Ordinal)) + { + column += 3; + continue; + } + if (line.AsSpan(column).StartsWith(":-", StringComparison.Ordinal) + || line.AsSpan(column).StartsWith(@"\+", StringComparison.Ordinal)) + { + column += 2; + continue; + } + if (ch is ',' or ';') + { + column++; + continue; + } + if (line.AsSpan(column).StartsWith("->", StringComparison.Ordinal)) + { + column += 2; + continue; + } + if (ch == '(') + { + frames.Push(new PrologLexicalFrame(PrologLexicalFrameKind.GoalGroup)); + column++; + continue; + } + if (ch == '{') + { + frames.Push(new PrologLexicalFrame( + PrologLexicalFrameKind.GoalGroup, + terminator: '}')); + column++; + continue; + } + if (ch == '[') + { + frames.Push(new PrologLexicalFrame( + PrologLexicalFrameKind.TermGroup, + terminator: ']')); + expectGoal = false; + column++; + continue; + } + if (ch == '!') + { + expectGoal = false; + column++; + continue; + } + if (char.IsLower(ch)) + { + var nameStart = column; + column++; + while (column < line.Length + && (char.IsLetterOrDigit(line[column]) || line[column] == '_')) + { + column++; + } + + var name = line[nameStart..column]; + var nextColumn = column; + while (nextColumn < line.Length && char.IsWhiteSpace(line[nextColumn])) + nextColumn++; + if (nextColumn < line.Length + && line[nextColumn] == ':' + && (nextColumn + 1 >= line.Length || line[nextColumn + 1] != '-')) + { + column = nextColumn + 1; + expectGoal = true; + continue; + } + if (callableNames.Contains(name) + && !IsPrologTermBeforeInfixOperator( + lines, + lineIndex, + column, + nextColumn)) + { + calls.Add(new PrologGoalCall(name, nameStart)); + } + + if (nextColumn < line.Length && line[nextColumn] == '(') + { + if (PrologMetaGoalArguments.TryGetValue(name, out var goalArgumentIndices)) + { + var metaFrame = new PrologLexicalFrame( + PrologLexicalFrameKind.MetaArguments, + goalArgumentIndices); + frames.Push(metaFrame); + expectGoal = metaFrame.CurrentArgumentIsGoal; + } + else + { + frames.Push(new PrologLexicalFrame( + PrologLexicalFrameKind.PredicateArguments)); + expectGoal = false; + } + + column = nextColumn + 1; + } + else + { + expectGoal = false; + } + + continue; + } + + expectGoal = false; + column++; + continue; + } + + if (ch == '(') + { + frames.Push(new PrologLexicalFrame(PrologLexicalFrameKind.PredicateArguments)); + column++; + continue; + } + if (ch is '[' or '{') + { + frames.Push(new PrologLexicalFrame( + PrologLexicalFrameKind.TermGroup, + terminator: ch == '[' ? ']' : '}')); + column++; + continue; + } + if (ch is ')' or ']' or '}') + { + if (frames.TryPeek(out var closingFrame) + && closingFrame.Terminator == ch) + { + frames.Pop(); + } + expectGoal = false; + column++; + continue; + } + if (ch == ',') + { + if (frames.TryPeek(out var frame) + && frame.Kind == PrologLexicalFrameKind.MetaArguments) + { + frame.ArgumentIndex++; + expectGoal = frame.CurrentArgumentIsGoal; + } + else if (CanStartNextPrologGoal(frames)) + { + expectGoal = true; + } + + column++; + continue; + } + if (ch == ';' + || line.AsSpan(column).StartsWith("->", StringComparison.Ordinal)) + { + if (CanStartNextPrologGoal(frames)) + expectGoal = true; + column += ch == ';' ? 1 : 2; + continue; + } + + column++; + } + } + + private static bool IsPrologTermBeforeInfixOperator( + IReadOnlyList lines, + int lineIndex, + int nameEndColumn, + int nextColumn) + { + const int lookaheadLineLimit = 256; + var line = lines[lineIndex]; + var afterTermLine = lineIndex; + var afterTermColumn = nextColumn; + if (nextColumn < line.Length && line[nextColumn] == '(') + { + var depth = 0; + var termClosed = false; + var endLineExclusive = Math.Min(lines.Count, lineIndex + lookaheadLineLimit); + for (var scanLineIndex = lineIndex; + scanLineIndex < endLineExclusive && !termClosed; + scanLineIndex++) + { + var scanLine = lines[scanLineIndex]; + var startColumn = scanLineIndex == lineIndex ? nextColumn : 0; + for (var column = startColumn; column < scanLine.Length; column++) + { + var ch = scanLine[column]; + if (ch is '\'' or '"') + { + column = SkipQuotedToken(scanLine, column, ch) - 1; + continue; + } + + if (ch == '(') + { + depth++; + } + else if (ch == ')' && --depth == 0) + { + afterTermLine = scanLineIndex; + afterTermColumn = column + 1; + termClosed = true; + break; + } + } + } + + // An unterminated compound term is not authoritative evidence of a call. + // 未終端の compound term は call と判断できる根拠にならない。 + if (!termClosed) + return true; + } + else + { + afterTermColumn = nameEndColumn; + } + + if (!TryFindNextPrologToken( + lines, + afterTermLine, + afterTermColumn, + lookaheadLineLimit, + out var operatorLine, + out var operatorColumn)) + { + return false; + } + + var operatorSourceLine = lines[operatorLine]; + var remaining = operatorSourceLine.AsSpan(operatorColumn); + if (remaining.StartsWith("->", StringComparison.Ordinal) + || remaining.StartsWith("*->", StringComparison.Ordinal)) + { + return false; + } + + if (operatorSourceLine[operatorColumn] is '=' or '\\' or '<' or '>' or '@' or '#' + or ':' or '+' or '-' or '*' or '/' or '^') + { + return true; + } + + foreach (var operatorName in PrologInfixOperatorNames) + { + if (!remaining.StartsWith(operatorName, StringComparison.Ordinal)) + continue; + var operatorEnd = operatorColumn + operatorName.Length; + if (operatorEnd >= operatorSourceLine.Length + || !char.IsLetterOrDigit(operatorSourceLine[operatorEnd]) + && operatorSourceLine[operatorEnd] != '_') + { + return true; + } + } + + return false; + } + + private static bool TryFindNextPrologToken( + IReadOnlyList lines, + int startLine, + int startColumn, + int lookaheadLineLimit, + out int tokenLine, + out int tokenColumn) + { + var endLineExclusive = Math.Min(lines.Count, startLine + lookaheadLineLimit); + for (var lineIndex = startLine; lineIndex < endLineExclusive; lineIndex++) + { + var line = lines[lineIndex]; + var column = lineIndex == startLine ? startColumn : 0; + while (column < line.Length && char.IsWhiteSpace(line[column])) + column++; + if (column < line.Length) + { + tokenLine = lineIndex; + tokenColumn = column; + return true; + } + } + + tokenLine = -1; + tokenColumn = -1; + return false; + } + + private static readonly string[] PrologInfixOperatorNames = + ["is", "mod", "rem", "xor", "div", "rdiv"]; + + private static bool CanStartNextPrologGoal( + IEnumerable frames) + { + foreach (var frame in frames) + { + if (frame.Kind == PrologLexicalFrameKind.PredicateArguments) + return false; + if (frame.Kind == PrologLexicalFrameKind.TermGroup) + return false; + if (frame.Kind == PrologLexicalFrameKind.MetaArguments) + return frame.CurrentArgumentIsGoal; + } + + return true; + } + + private static bool ContainsPrologClauseTerminator(string line) + { + for (var column = 0; column < line.Length; column++) + { + if (IsPrologClauseTerminator(line, column)) + return true; + } + + return false; + } + + private static void AddPrologContainers( + IReadOnlyList lines, + IReadOnlyList symbols, + Dictionary containersByLine, + Dictionary> declarationsByLine) + { + foreach (var symbol in symbols) + { + if (symbol.Kind != "function" || symbol.StartLine < 1 || symbol.StartLine > lines.Count) + continue; + + var startLineIndex = symbol.StartLine - 1; + var startColumn = Math.Clamp( + symbol.StartColumn ?? 0, + 0, + lines[startLineIndex].Length); + var headLine = lines[startLineIndex][startColumn..]; + var headMatch = PrologHeadRegex.Match(headLine); + if (!headMatch.Success) + headMatch = PrologMultilineHeadRegex.Match(headLine); + if (!headMatch.Success + || !string.Equals(headMatch.Groups["name"].Value, symbol.Name, StringComparison.Ordinal)) + { + continue; + } + + if (!declarationsByLine.TryGetValue(symbol.StartLine, out var declarations)) + { + declarations = []; + declarationsByLine[symbol.StartLine] = declarations; + } + declarations.Add(symbol); + + var endLineIndex = FindPrologClauseEnd(lines, startLineIndex, startColumn); + for (var lineIndex = startLineIndex; lineIndex <= endLineIndex; lineIndex++) + containersByLine.TryAdd(lineIndex + 1, symbol); + } + + foreach (var declarations in declarationsByLine.Values) + { + declarations.Sort(static (left, right) => + (left.StartColumn ?? 0).CompareTo(right.StartColumn ?? 0)); + } + } + + private static int FindPrologClauseEnd( + IReadOnlyList lines, + int startLineIndex, + int startColumn) + { + for (var lineIndex = startLineIndex; lineIndex < lines.Count; lineIndex++) + { + var line = lines[lineIndex]; + var firstColumn = lineIndex == startLineIndex ? startColumn : 0; + for (var column = firstColumn; column < line.Length; column++) + { + if (IsPrologClauseTerminator(line, column)) + return lineIndex; + } + } + + return startLineIndex; + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.PrologSyntax.cs b/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.PrologSyntax.cs new file mode 100644 index 000000000..91044b007 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.PrologSyntax.cs @@ -0,0 +1,271 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using CodeIndex.Models; +using Regex = CodeIndex.Indexer.BoundedRegex; + +namespace CodeIndex.Indexer; + +internal static partial class DynamicDeclarativeReferenceExtractor +{ + private static string PreparePrologCallScanLine( + string language, + string line, + bool isClauseContinuation) + { + if (language is not ("prolog" or "ambiguous_pl")) + return line; + + var masked = line.ToCharArray(); + var clauseStartColumn = isClauseContinuation + ? FindPrologClauseTerminator(line, 0) + 1 + : 0; + if (isClauseContinuation && clauseStartColumn == 0) + return line; + var changed = false; + while (TryFindNextPrologClauseStart(line, clauseStartColumn, out var headStartColumn) + && TryFindPrologHeadBoundary( + line, + headStartColumn, + out var bodyStartColumn, + out var clauseEndColumn)) + { + if (bodyStartColumn >= 0) + { + FillWithSpaces(masked, headStartColumn, bodyStartColumn); + changed = true; + if (clauseEndColumn < 0) + break; + } + else + { + FillWithSpaces(masked, headStartColumn, clauseEndColumn + 1); + changed = true; + } + + clauseStartColumn = clauseEndColumn + 1; + } + + return changed ? new string(masked) : line; + } + + private static bool TryFindNextPrologClauseStart( + string line, + int searchColumn, + out int clauseStartColumn) + { + for (var column = Math.Max(0, searchColumn); column < line.Length; column++) + { + if (char.IsWhiteSpace(line[column])) + continue; + + clauseStartColumn = column; + return char.IsLower(line[column]); + } + + clauseStartColumn = -1; + return false; + } + + private static bool TryFindPrologHeadBoundary( + string line, + int headStartColumn, + out int bodyStartColumn, + out int clauseEndColumn) + { + bodyStartColumn = -1; + clauseEndColumn = -1; + var parenthesisDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + for (var column = headStartColumn; column < line.Length; column++) + { + var ch = line[column]; + if (ch is '\'' or '"') + { + column = SkipQuotedToken(line, column, ch) - 1; + continue; + } + switch (ch) + { + case '(': + parenthesisDepth++; + continue; + case ')' when parenthesisDepth > 0: + parenthesisDepth--; + continue; + case '[': + bracketDepth++; + continue; + case ']' when bracketDepth > 0: + bracketDepth--; + continue; + case '{': + braceDepth++; + continue; + case '}' when braceDepth > 0: + braceDepth--; + continue; + } + if (parenthesisDepth != 0 || bracketDepth != 0 || braceDepth != 0) + continue; + + var separatorLength = line.AsSpan(column).StartsWith("-->", StringComparison.Ordinal) + ? 3 + : line.AsSpan(column).StartsWith(":-", StringComparison.Ordinal) + ? 2 + : 0; + if (separatorLength > 0) + { + bodyStartColumn = column + separatorLength; + clauseEndColumn = FindPrologClauseTerminator(line, bodyStartColumn); + return true; + } + if (IsPrologClauseTerminator(line, column)) + { + clauseEndColumn = column; + return true; + } + } + + return false; + } + + internal static bool IsPrologClauseTerminator(string line, int column) + { + if (column < 0 || column >= line.Length || line[column] != '.') + return false; + + return PrologClauseTerminatorMaps + .GetValue(line, static currentLine => new PrologClauseTerminatorMap(currentLine)) + .IsTerminator(column); + } + + private sealed class PrologClauseTerminatorMap + { + private readonly bool[] _terminatorColumns; + + public PrologClauseTerminatorMap(string line) + { + _terminatorColumns = new bool[line.Length]; + var parenthesisDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + for (var column = 0; column < line.Length; column++) + { + var ch = line[column]; + if (ch is '\'' or '"') + { + column = SkipQuotedToken(line, column, ch) - 1; + continue; + } + switch (ch) + { + case '(': + parenthesisDepth++; + continue; + case ')' when parenthesisDepth > 0: + parenthesisDepth--; + continue; + case '[': + bracketDepth++; + continue; + case ']' when bracketDepth > 0: + bracketDepth--; + continue; + case '{': + braceDepth++; + continue; + case '}' when braceDepth > 0: + braceDepth--; + continue; + } + + if (ch != '.' + || parenthesisDepth != 0 + || bracketDepth != 0 + || braceDepth != 0) + { + continue; + } + + var previous = column > 0 ? line[column - 1] : '\0'; + var next = column + 1 < line.Length ? line[column + 1] : '\0'; + if (previous != '.' + && next != '.' + && !(char.IsDigit(previous) && char.IsDigit(next)) + && (next == '\0' || char.IsWhiteSpace(next))) + { + _terminatorColumns[column] = true; + } + } + } + + public bool IsTerminator(int column) => _terminatorColumns[column]; + } + + private static int FindPrologClauseTerminator(string line, int startColumn) + { + for (var column = Math.Max(0, startColumn); column < line.Length; column++) + { + if (IsPrologClauseTerminator(line, column)) + return column; + } + + return -1; + } + + private static void EmitImportReference( + string language, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForCall) + { + var match = language switch + { + "crystal" => CrystalRequireRegex.Match(originalLine), + "groovy" => GroovyImportRegex.Match(originalLine), + "tcl" => TclPackageRegex.Match(originalLine), + "prolog" or "ambiguous_pl" => PrologImportRegex.Match(originalLine), + _ => Match.Empty, + }; + if (!match.Success) + return; + + var nameGroup = match.Groups["name"]; + var name = NormalizeImportTarget(language, nameGroup.Value); + if (name.Length == 0) + return; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name, + nameGroup.Index, + "type_reference", + context, + lineNumber, + resolveContainerForCall(nameGroup.Index), + language); + } + + private static string NormalizeImportTarget(string language, string name) + { + var normalized = name.Replace('\\', '/').TrimEnd('/'); + if (language == "groovy") + return normalized[(normalized.LastIndexOf('.') + 1)..]; + if (language is "crystal" or "prolog" or "ambiguous_pl") + { + normalized = normalized[(normalized.LastIndexOf('/') + 1)..]; + var extensionIndex = normalized.LastIndexOf('.'); + if (extensionIndex > 0) + normalized = normalized[..extensionIndex]; + } + return normalized; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.TclArguments.cs b/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.TclArguments.cs new file mode 100644 index 000000000..416c15dc0 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.TclArguments.cs @@ -0,0 +1,390 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using CodeIndex.Models; +using Regex = CodeIndex.Indexer.BoundedRegex; + +namespace CodeIndex.Indexer; + +internal static partial class DynamicDeclarativeReferenceExtractor +{ + private static bool HasTclEscapedNewline(string line) + { + var backslashCount = 0; + for (var column = line.Length - 1; column >= 0 && line[column] == '\\'; column--) + backslashCount++; + return backslashCount % 2 == 1; + } + + private static int FindTclCommentStart(string line) + { + var commandStart = true; + for (var column = 0; column < line.Length; column++) + { + var ch = line[column]; + if (ch is '\'' or '"') + { + column = SkipQuotedToken(line, column, ch) - 1; + commandStart = false; + continue; + } + if (ch == '\\') + { + column++; + commandStart = false; + continue; + } + if (ch == '#' && commandStart) + return column; + if (ch is ';' or '[') + { + commandStart = true; + continue; + } + if (char.IsWhiteSpace(ch)) + continue; + commandStart = false; + } + + return -1; + } + + private static string ReadTclBareWord(string line, int startColumn) + { + var endColumn = startColumn; + while (endColumn < line.Length + && (char.IsLetterOrDigit(line[endColumn]) + || line[endColumn] is '_' or ':' or '.' or '-' or '#')) + { + endColumn++; + } + + return endColumn == startColumn + ? string.Empty + : line[startColumn..endColumn]; + } + + private static bool IsTclScriptArgument( + TclLexicalFrame frame, + int wordIndex, + IReadOnlyList lines, + TclBraceEnd? braceEnd) + { + var isLastCommandWord = braceEnd is { } end + && IsTclLastCommandWord(lines, end); + return frame.CommandName switch + { + "if" => wordIndex == 2 + || (frame.LastBareWord == "then" + && wordIndex == frame.LastBareWordIndex + 1) + || (frame.LastBareWord == "elseif" + && wordIndex == frame.LastBareWordIndex + 2) + || (frame.LastBareWord == "else" + && wordIndex == frame.LastBareWordIndex + 1), + "foreach" or "lmap" => wordIndex >= 3 + && wordIndex % 2 == 1 + && isLastCommandWord, + "while" => wordIndex == 2, + "catch" => wordIndex == 1, + "for" => wordIndex is 1 or 3 or 4, + "proc" => wordIndex == 3, + "try" => wordIndex == frame.TryScriptWordIndex, + "dict" => wordIndex == frame.DictScriptWordIndex, + "switch" => frame.SwitchStringWordIndex >= 0 + && wordIndex - frame.SwitchStringWordIndex >= 2 + && (wordIndex - frame.SwitchStringWordIndex) % 2 == 0, + _ => false, + }; + } + + private static bool IsTclExpressionArgument(TclLexicalFrame frame, int wordIndex) => + frame.CommandName switch + { + "if" => wordIndex == 1 + || (frame.LastBareWord == "elseif" + && wordIndex == frame.LastBareWordIndex + 1), + "while" => wordIndex == 1, + "for" => wordIndex == 2, + "expr" => wordIndex >= 1, + _ => false, + }; + + private static bool IsTclBareScriptCommandArgument( + TclLexicalFrame frame, + int wordIndex, + string token, + IReadOnlyList lines, + TclBraceEnd wordEnd) + { + if (frame.CommandName == "if" && token == "then") + return false; + + return IsTclScriptArgument( + frame, + wordIndex, + lines, + wordEnd); + } + + private static bool IsTclConcatenatedScriptArgument( + TclLexicalFrame frame, + int wordIndex, + string? token) + { + if (frame.CommandName == "eval") + return wordIndex >= 1; + if (frame.CommandName == "after") + { + return wordIndex >= 2 + && frame.FirstArgument is not null + && frame.FirstArgument is not ("cancel" or "info"); + } + if (frame.CommandName == "namespace") + return wordIndex >= 3 && frame.FirstArgument == "eval"; + if (frame.CommandName != "uplevel") + return false; + if (frame.UplevelScriptWordIndex >= 0) + return wordIndex >= frame.UplevelScriptWordIndex; + if (wordIndex < 1) + return false; + + if (wordIndex == 1 && IsTclUplevelLevelToken(token)) + { + frame.UplevelScriptWordIndex = 2; + return false; + } + + frame.UplevelScriptWordIndex = wordIndex; + return true; + } + + private static bool IsTclUplevelLevelToken(string? token) + { + if (string.IsNullOrWhiteSpace(token)) + return false; + + var span = token.AsSpan().Trim(); + if (span.Length > 1 && span[0] == '#') + span = span[1..]; + if (span.IsEmpty) + return false; + + var start = span[0] is '+' or '-' ? 1 : 0; + if (start == span.Length) + return false; + for (var index = start; index < span.Length; index++) + { + if (!char.IsDigit(span[index])) + return false; + } + + return true; + } + + private static TclLexicalFrame CreateTclScriptFrame( + TclLexicalFrame owner, + char terminator, + bool concatenateArguments) + { + var frame = new TclLexicalFrame( + TclLexicalFrameKind.Script, + terminator, + concatenateArguments ? owner : null); + if (!concatenateArguments) + return frame; + + if (owner.ConcatenatedScriptState != null) + frame.CopyCommandStateFrom(owner.ConcatenatedScriptState); + // Tcl inserts a separating space while concatenating eval/uplevel arguments. + // eval/uplevel の引数連結では引数間に空白が入るため、次は word boundary。 + frame.WordStart = true; + return frame; + } + + private static void PersistTclConcatenatedScriptState(TclLexicalFrame frame) + { + if (frame.ConcatenationOwner is not { } owner) + return; + + owner.ConcatenatedScriptState ??= new TclLexicalFrame(TclLexicalFrameKind.Script); + owner.ConcatenatedScriptState.CopyCommandStateFrom(frame); + owner.ConcatenatedScriptState.WordStart = true; + } + + private static bool ProcessTclConcatenatedBareWord( + TclLexicalFrame owner, + string token) + { + owner.ConcatenatedScriptState ??= new TclLexicalFrame(TclLexicalFrameKind.Script); + var state = owner.ConcatenatedScriptState; + var isCommand = state.CommandStart; + var wordIndex = state.WordIndex++; + if (wordIndex == 0) + { + state.CommandName = token; + } + else + { + UpdateTclFirstArgument(state, wordIndex, token); + UpdateTclDictArgumentState(state, wordIndex, token); + UpdateTclSwitchArgumentState(state, wordIndex, token); + UpdateTclTryArgumentState( + state, + wordIndex, + token, + isScriptArgument: false); + } + + state.LastBareWord = token; + state.LastBareWordIndex = wordIndex; + state.CommandStart = false; + state.WordStart = false; + return isCommand; + } + + private static string? GetTclBracedWordToken( + IReadOnlyList lines, + int startLine, + int startColumn, + TclBraceEnd? braceEnd) + { + if (braceEnd is not { } end || end.Line != startLine) + return null; + var length = end.Column - startColumn - 1; + return length < 0 ? null : lines[startLine].Substring(startColumn + 1, length); + } + + private static string? GetTclQuotedWordToken(string line, int startColumn) + { + var endColumn = SkipQuotedToken(line, startColumn, '"'); + return endColumn <= startColumn + 1 + || endColumn > line.Length + || line[endColumn - 1] != '"' + ? null + : line.Substring(startColumn + 1, endColumn - startColumn - 2); + } + + private static string NormalizeTclQualifiedName(string name) + { + while (name.StartsWith("::", StringComparison.Ordinal)) + name = name[2..]; + return name; + } + + private static void UpdateTclFirstArgument( + TclLexicalFrame frame, + int wordIndex, + string? token) + { + if (wordIndex == 1 && token != null) + frame.FirstArgument = token; + } + + private static void UpdateTclDictArgumentState( + TclLexicalFrame frame, + int wordIndex, + string? token) + { + if (frame.CommandName == "dict" + && wordIndex == 1 + && token == "for") + { + frame.DictScriptWordIndex = wordIndex + 3; + } + } + + private static void MarkTclBareScriptCommandBoundary(char[] buffer, int commandColumn) + { + var boundaryColumn = commandColumn - 1; + if (boundaryColumn >= 0 && char.IsWhiteSpace(buffer[boundaryColumn])) + buffer[boundaryColumn] = ';'; + } + + private static bool IsTclSwitchTableArgument( + TclLexicalFrame frame, + int wordIndex, + IReadOnlyList lines, + TclBraceEnd? braceEnd) + { + return frame.CommandName == "switch" + && frame.SwitchStringWordIndex >= 0 + && wordIndex == frame.SwitchStringWordIndex + 1 + && braceEnd is { } end + && IsTclLastCommandWord(lines, end); + } + + private static void UpdateTclSwitchArgumentState( + TclLexicalFrame frame, + int wordIndex, + string token) + { + if (frame.CommandName != "switch" + || wordIndex == 0 + || frame.SwitchStringWordIndex >= 0) + { + return; + } + + if (frame.SwitchOptionValuePending) + { + frame.SwitchOptionValuePending = false; + return; + } + + if (!frame.SwitchOptionsEnded && token.StartsWith("-", StringComparison.Ordinal)) + { + if (token == "--") + frame.SwitchOptionsEnded = true; + else if (token is "-matchvar" or "-indexvar") + frame.SwitchOptionValuePending = true; + return; + } + + frame.SwitchStringWordIndex = wordIndex; + } + + private static void UpdateTclTryArgumentState( + TclLexicalFrame frame, + int wordIndex, + string token, + bool isScriptArgument) + { + if (frame.CommandName != "try") + return; + + if (isScriptArgument) + { + frame.TryClauseWordIndex = wordIndex + 1; + frame.TryScriptWordIndex = -1; + return; + } + + if (wordIndex != frame.TryClauseWordIndex) + return; + + frame.TryScriptWordIndex = token switch + { + "on" or "trap" => wordIndex + 3, + "finally" => wordIndex + 1, + _ => -1, + }; + } + + private static bool IsTclLastCommandWord( + IReadOnlyList lines, + TclBraceEnd braceEnd) + { + var line = lines[braceEnd.Line]; + for (var column = braceEnd.Column + 1; column < line.Length; column++) + { + if (char.IsWhiteSpace(line[column])) + continue; + return line[column] is ';' or ']' or '}'; + } + + return true; + } + + private static long GetTclPositionKey(int line, int column) => + ((long)line << 32) | (uint)column; + +} diff --git a/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.TclCalls.cs b/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.TclCalls.cs new file mode 100644 index 000000000..1777ed7e6 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.TclCalls.cs @@ -0,0 +1,734 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using CodeIndex.Models; +using Regex = CodeIndex.Indexer.BoundedRegex; + +namespace CodeIndex.Indexer; + +internal static partial class DynamicDeclarativeReferenceExtractor +{ + private static void AddTclContainers( + IReadOnlyList lines, + IReadOnlyList symbols, + IReadOnlyDictionary braceEnds, + List scopes, + HashSet scriptBodyOpenings) + { + foreach (var symbol in symbols) + { + if (symbol.Kind != "function" || symbol.StartLine < 1 || symbol.StartLine > lines.Count) + continue; + + var startLineIndex = symbol.StartLine - 1; + var declarationMatch = FindTclProcDeclaration(lines[startLineIndex], symbol); + if (declarationMatch == null + || !TryFindTclBodyEnd( + lines, + braceEnds, + startLineIndex, + declarationMatch.Index + declarationMatch.Length, + out var bodyStartLineIndex, + out var bodyStartColumn, + out var bodyEnd)) + { + continue; + } + + scopes.Add(new TclContainerScope( + symbol, + bodyStartLineIndex + 1, + lines[bodyStartLineIndex][bodyStartColumn] is '{' or '"' + ? bodyStartColumn + : bodyStartColumn - 1, + bodyEnd.Line + 1, + lines[bodyStartLineIndex][bodyStartColumn] is '{' or '"' + ? bodyEnd.Column + : bodyEnd.Column + 1)); + if (lines[bodyStartLineIndex][bodyStartColumn] == '{') + scriptBodyOpenings.Add(GetTclPositionKey(bodyStartLineIndex, bodyStartColumn)); + } + + scopes.Sort(static (left, right) => + { + var startComparison = left.BodyStartLine.CompareTo(right.BodyStartLine); + return startComparison != 0 + ? startComparison + : left.BodyStartColumn.CompareTo(right.BodyStartColumn); + }); + } + + private static Match? FindTclProcDeclaration(string line, SymbolRecord symbol) + { + Match? fallback = null; + foreach (Match match in BoundedRegex.EnumerateMatches(TclProcRegex, line)) + { + var nameGroup = match.Groups["name"]; + if (!string.Equals(nameGroup.Value, symbol.Name, StringComparison.Ordinal)) + continue; + if (symbol.StartColumn == nameGroup.Index) + return match; + fallback ??= match; + } + + return fallback; + } + + private static bool TryFindTclBodyEnd( + IReadOnlyList lines, + IReadOnlyDictionary braceEnds, + int startLineIndex, + int searchColumn, + out int bodyStartLineIndex, + out int bodyStartColumn, + out TclBraceEnd bodyEnd) + { + bodyStartLineIndex = startLineIndex; + bodyStartColumn = -1; + bodyEnd = default; + if (!TryFindNextNonWhitespace(lines[startLineIndex], searchColumn, out var argsColumn) + || !TryFindTclWordEnd( + lines, + braceEnds, + startLineIndex, + argsColumn, + out var argsEndLine, + out var argsEndColumn) + || !TryFindNextNonWhitespace( + lines, + argsEndLine, + argsEndColumn + 1, + out bodyStartLineIndex, + out bodyStartColumn) + || !TryFindTclWordEnd( + lines, + braceEnds, + bodyStartLineIndex, + bodyStartColumn, + out var bodyEndLine, + out var bodyEndColumn)) + { + return false; + } + + bodyEnd = new TclBraceEnd(bodyEndLine, bodyEndColumn); + return true; + } + + private static bool TryFindTclWordEnd( + IReadOnlyList lines, + IReadOnlyDictionary braceEnds, + int startLine, + int startColumn, + out int endLine, + out int endColumn) + { + var line = lines[startLine]; + if (line[startColumn] == '{') + { + if (braceEnds.TryGetValue(GetTclPositionKey(startLine, startColumn), out var braceEnd)) + { + endLine = braceEnd.Line; + endColumn = braceEnd.Column; + return true; + } + + endLine = -1; + endColumn = -1; + return false; + } + + if (line[startColumn] == '"') + { + for (var lineIndex = startLine; lineIndex < lines.Count; lineIndex++) + { + line = lines[lineIndex]; + var firstColumn = lineIndex == startLine ? startColumn + 1 : 0; + for (var column = firstColumn; column < line.Length; column++) + { + if (line[column] == '\\') + { + column++; + continue; + } + if (line[column] == '"') + { + endLine = lineIndex; + endColumn = column; + return true; + } + } + } + + endLine = -1; + endColumn = -1; + return false; + } + + var wordEnd = startColumn; + while (wordEnd + 1 < line.Length && !char.IsWhiteSpace(line[wordEnd + 1])) + wordEnd++; + endLine = startLine; + endColumn = wordEnd; + return true; + } + + private static bool TryFindNextNonWhitespace( + string line, + int startColumn, + out int foundColumn) + { + for (var column = startColumn; column < line.Length; column++) + { + if (!char.IsWhiteSpace(line[column])) + { + foundColumn = column; + return true; + } + } + + foundColumn = -1; + return false; + } + + private static bool TryFindNextNonWhitespace( + IReadOnlyList lines, + int startLine, + int startColumn, + out int foundLine, + out int foundColumn) + { + for (var lineIndex = startLine; lineIndex < lines.Count; lineIndex++) + { + var column = lineIndex == startLine ? startColumn : 0; + if (TryFindNextNonWhitespace(lines[lineIndex], column, out foundColumn)) + { + if (foundColumn == lines[lineIndex].Length - 1 + && lines[lineIndex][foundColumn] == '\\') + { + continue; + } + + foundLine = lineIndex; + return true; + } + } + + foundLine = -1; + foundColumn = -1; + return false; + } + + private static Dictionary BuildTclBraceEndPositions(IReadOnlyList lines) + { + var result = new Dictionary(); + var openings = new Stack<(int Line, int Column)>(); + var commandStart = true; + var wordStart = true; + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + var line = lines[lineIndex]; + for (var column = 0; column < line.Length; column++) + { + var ch = line[column]; + if (openings.Count > 0) + { + if (ch == '\\') + { + column++; + continue; + } + if (ch == '{') + { + openings.Push((lineIndex, column)); + } + else if (ch == '}') + { + var opening = openings.Pop(); + result[GetTclPositionKey(opening.Line, opening.Column)] = + new TclBraceEnd(lineIndex, column); + } + continue; + } + + if (ch == '\\') + { + column++; + commandStart = false; + wordStart = false; + continue; + } + + if (ch == '"') + { + column = SkipQuotedToken(line, column, ch) - 1; + commandStart = false; + wordStart = false; + continue; + } + + if (ch == '#' && commandStart) + break; + if (ch == ';' || ch == '[') + { + commandStart = true; + wordStart = true; + continue; + } + if (char.IsWhiteSpace(ch)) + { + wordStart = true; + continue; + } + if (ch == '{' && wordStart) + { + openings.Push((lineIndex, column)); + commandStart = false; + wordStart = false; + continue; + } + + commandStart = false; + wordStart = false; + } + + if (openings.Count == 0) + { + commandStart = true; + wordStart = true; + } + } + + return result; + } + + private static string[] BuildTclCallLines( + IReadOnlyList lines, + IReadOnlyDictionary braceEnds, + IReadOnlySet scriptBodyOpenings, + IDictionary? commentColumns = null) + { + var result = new string[lines.Count]; + var frames = new Stack(); + frames.Push(new TclLexicalFrame(TclLexicalFrameKind.Script)); + var commentContinued = false; + + for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) + { + var line = lines[lineIndex]; + if (commentContinued) + { + result[lineIndex] = new string(' ', line.Length); + commentColumns?.TryAdd(lineIndex, 0); + commentContinued = HasTclEscapedNewline(line); + continue; + } + + var buffer = line.ToCharArray(); + var lineContinued = false; + var suppressLeadingContinuedWord = frames.Peek().Kind != TclLexicalFrameKind.Script + || !frames.Peek().CommandStart; + for (var column = 0; column < line.Length;) + { + var frame = frames.Peek(); + var ch = line[column]; + if (frame.Kind == TclLexicalFrameKind.SwitchTable) + { + buffer[column] = ' '; + if (ch == frame.Terminator) + { + frames.Pop(); + column++; + } + else if (ch == '\\' && column + 1 < line.Length) + { + buffer[column + 1] = ' '; + frame.WordStart = false; + column += 2; + } + else if (char.IsWhiteSpace(ch)) + { + frame.WordStart = true; + column++; + } + else if (ch == '{' && frame.WordStart) + { + var wordIndex = frame.WordIndex++; + if (wordIndex % 2 == 1) + { + buffer[column] = ';'; + frames.Push(new TclLexicalFrame(TclLexicalFrameKind.Script, '}')); + suppressLeadingContinuedWord = false; + } + else + { + frames.Push(new TclLexicalFrame(TclLexicalFrameKind.BracedWord, '}')); + } + + frame.WordStart = false; + column++; + } + else if (ch == '"' && frame.WordStart) + { + var wordIndex = frame.WordIndex++; + if (wordIndex % 2 == 1) + { + buffer[column] = ';'; + frames.Push(new TclLexicalFrame(TclLexicalFrameKind.Script, '"')); + suppressLeadingContinuedWord = false; + } + else + { + var endColumn = SkipQuotedToken(line, column, '"'); + FillWithSpaces(buffer, column, endColumn); + column = endColumn - 1; + } + frame.WordStart = false; + column++; + } + else + { + if (frame.WordStart) + { + var wordIndex = frame.WordIndex++; + if (wordIndex % 2 == 1) + { + var endColumn = column; + while (endColumn < line.Length + && !char.IsWhiteSpace(line[endColumn]) + && line[endColumn] != frame.Terminator) + { + buffer[endColumn] = line[endColumn]; + endColumn++; + } + MarkTclBareScriptCommandBoundary(buffer, column); + column = endColumn - 1; + } + } + frame.WordStart = false; + column++; + } + + continue; + } + + if (frame.Kind == TclLexicalFrameKind.BracedWord) + { + buffer[column] = ' '; + if (ch == '\\' && column + 1 < line.Length) + { + buffer[column + 1] = ' '; + column += 2; + } + else if (ch == '{') + { + frames.Push(new TclLexicalFrame(TclLexicalFrameKind.BracedWord, '}')); + column++; + } + else if (ch == frame.Terminator) + { + frames.Pop(); + column++; + } + else + { + column++; + } + continue; + } + + if (frame.Kind == TclLexicalFrameKind.ExpressionWord) + { + buffer[column] = ' '; + if (ch == '\\' && column + 1 < line.Length) + { + buffer[column + 1] = ' '; + column += 2; + } + else if (ch == '{') + { + frames.Push(new TclLexicalFrame(TclLexicalFrameKind.ExpressionWord, '}')); + column++; + } + else if (ch == frame.Terminator) + { + frames.Pop(); + column++; + } + else if (ch == '[') + { + buffer[column] = '['; + frames.Push(new TclLexicalFrame(TclLexicalFrameKind.Script, ']')); + suppressLeadingContinuedWord = false; + column++; + } + else + { + column++; + } + continue; + } + + if (frame.Kind == TclLexicalFrameKind.Quote) + { + buffer[column] = ' '; + if (ch == '\\' && column + 1 < line.Length) + { + buffer[column + 1] = ' '; + column += 2; + } + else if (ch == frame.Terminator) + { + frames.Pop(); + column++; + } + else if (ch == '[') + { + buffer[column] = '['; + frames.Push(new TclLexicalFrame(TclLexicalFrameKind.Script, ']')); + suppressLeadingContinuedWord = false; + column++; + } + else + { + column++; + } + continue; + } + + if (frame.Terminator != '\0' && ch == frame.Terminator) + { + PersistTclConcatenatedScriptState(frame); + frames.Pop(); + buffer[column] = frame.Terminator == '}' ? ' ' : ch; + column++; + continue; + } + if (ch == '\\') + { + buffer[column] = ' '; + if (column + 1 >= line.Length) + { + lineContinued = true; + frame.WordStart = true; + column++; + continue; + } + + if (frame.WordStart) + frame.WordIndex++; + buffer[column + 1] = ' '; + column += 2; + frame.CommandStart = false; + frame.WordStart = false; + continue; + } + if (ch == '#' && frame.CommandStart) + { + FillWithSpaces(buffer, column); + commentColumns?.TryAdd(lineIndex, column); + commentContinued = HasTclEscapedNewline(line); + break; + } + if (ch == '"') + { + var isScriptArgument = false; + var isConcatenatedScriptArgument = false; + if (frame.WordStart) + { + var wordIndex = frame.WordIndex++; + var token = GetTclQuotedWordToken(line, column); + isConcatenatedScriptArgument = IsTclConcatenatedScriptArgument( + frame, + wordIndex, + token); + isScriptArgument = isConcatenatedScriptArgument + || IsTclScriptArgument( + frame, + wordIndex, + lines, + braceEnd: null); + UpdateTclFirstArgument(frame, wordIndex, token); + UpdateTclDictArgumentState(frame, wordIndex, token); + UpdateTclSwitchArgumentState(frame, wordIndex, string.Empty); + UpdateTclTryArgumentState(frame, wordIndex, string.Empty, isScriptArgument); + } + var quotedFrame = isScriptArgument + ? CreateTclScriptFrame(frame, '"', isConcatenatedScriptArgument) + : new TclLexicalFrame(TclLexicalFrameKind.Quote, '"'); + buffer[column] = isScriptArgument + && (!isConcatenatedScriptArgument || quotedFrame.CommandStart) + ? ';' + : ' '; + frames.Push(quotedFrame); + frame.CommandStart = false; + frame.WordStart = false; + if (isScriptArgument) + suppressLeadingContinuedWord = false; + column++; + continue; + } + if (ch == '[') + { + if (frame.WordStart) + { + var wordIndex = frame.WordIndex++; + UpdateTclDictArgumentState(frame, wordIndex, token: null); + UpdateTclSwitchArgumentState(frame, wordIndex, string.Empty); + UpdateTclTryArgumentState( + frame, + wordIndex, + string.Empty, + isScriptArgument: false); + } + frames.Push(new TclLexicalFrame(TclLexicalFrameKind.Script, ']')); + frame.CommandStart = false; + frame.WordStart = false; + suppressLeadingContinuedWord = false; + column++; + continue; + } + if (ch == '{' && frame.WordStart) + { + var wordIndex = frame.WordIndex++; + var positionKey = GetTclPositionKey(lineIndex, column); + TclBraceEnd? braceEnd = braceEnds.TryGetValue(positionKey, out var foundBraceEnd) + ? foundBraceEnd + : null; + var isSwitchTable = IsTclSwitchTableArgument( + frame, + wordIndex, + lines, + braceEnd); + var token = GetTclBracedWordToken( + lines, + lineIndex, + column, + braceEnd); + var isConcatenatedScriptArgument = !isSwitchTable + && IsTclConcatenatedScriptArgument( + frame, + wordIndex, + token); + var isExpressionArgument = !isSwitchTable + && IsTclExpressionArgument(frame, wordIndex); + var isScriptArgument = !isSwitchTable + && (isConcatenatedScriptArgument + || scriptBodyOpenings.Contains(positionKey) + || IsTclScriptArgument( + frame, + wordIndex, + lines, + braceEnd)); + UpdateTclFirstArgument(frame, wordIndex, token); + UpdateTclDictArgumentState(frame, wordIndex, token); + UpdateTclSwitchArgumentState(frame, wordIndex, string.Empty); + UpdateTclTryArgumentState( + frame, + wordIndex, + string.Empty, + isScriptArgument); + if (isSwitchTable) + { + buffer[column] = ' '; + frames.Push(new TclLexicalFrame(TclLexicalFrameKind.SwitchTable, '}')); + } + else if (isScriptArgument) + { + var scriptFrame = CreateTclScriptFrame( + frame, + '}', + isConcatenatedScriptArgument); + buffer[column] = !isConcatenatedScriptArgument || scriptFrame.CommandStart + ? ';' + : ' '; + frames.Push(scriptFrame); + suppressLeadingContinuedWord = false; + } + else if (isExpressionArgument) + { + buffer[column] = ' '; + frames.Push(new TclLexicalFrame(TclLexicalFrameKind.ExpressionWord, '}')); + } + else + { + buffer[column] = ' '; + frames.Push(new TclLexicalFrame(TclLexicalFrameKind.BracedWord, '}')); + } + frame.CommandStart = false; + frame.WordStart = false; + column++; + continue; + } + if (ch == ';') + { + frame.ResetCommand(); + suppressLeadingContinuedWord = false; + column++; + continue; + } + if (char.IsWhiteSpace(ch)) + { + frame.WordStart = true; + column++; + continue; + } + + if (frame.WordStart) + { + var wordIndex = frame.WordIndex++; + var token = ReadTclBareWord(line, column); + var isConcatenatedScriptArgument = token.Length > 0 + && IsTclConcatenatedScriptArgument(frame, wordIndex, token); + var isScriptCommand = token.Length > 0 + && (isConcatenatedScriptArgument + ? ProcessTclConcatenatedBareWord(frame, token) + : IsTclBareScriptCommandArgument( + frame, + wordIndex, + token, + lines, + new TclBraceEnd(lineIndex, column + token.Length - 1))); + if (wordIndex == 0) + frame.CommandName = token; + else + { + UpdateTclFirstArgument(frame, wordIndex, token); + UpdateTclDictArgumentState(frame, wordIndex, token); + UpdateTclSwitchArgumentState(frame, wordIndex, token); + UpdateTclTryArgumentState( + frame, + wordIndex, + token, + isScriptCommand); + } + if (token.Length > 0) + { + frame.LastBareWord = token; + frame.LastBareWordIndex = wordIndex; + } + + if (suppressLeadingContinuedWord) + { + FillWithSpaces(buffer, column, column + token.Length); + suppressLeadingContinuedWord = false; + } + else if (isScriptCommand) + { + MarkTclBareScriptCommandBoundary(buffer, column); + } + } + + frame.CommandStart = false; + frame.WordStart = false; + column++; + } + + result[lineIndex] = new string(buffer); + if (!lineContinued && frames.Peek().Kind == TclLexicalFrameKind.Script) + frames.Peek().ResetCommand(); + } + + return result; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.cs index 6a0d3cfad..2669a0e14 100644 --- a/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/DynamicDeclarativeReferenceExtractor.cs @@ -5,7 +5,7 @@ namespace CodeIndex.Indexer; -internal static class DynamicDeclarativeReferenceExtractor +internal static partial class DynamicDeclarativeReferenceExtractor { private static readonly ConditionalWeakTable PrologClauseTerminatorMaps = new(); @@ -987,2894 +987,4 @@ private static IReadOnlyList BuildTclDeclarationContainerScop prologDirectiveLines); } - public static void EmitAdditionalReferences( - string language, - string preparedLine, - string structuralLine, - ExtractionState state, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForCall, - Action addCallLikeReference) - { - var importScanLine = language == "tcl" - ? state.GetCallScanLine(language, lineNumber, structuralLine) - : structuralLine; - EmitImportReference( - language, - importScanLine, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForCall); - - if (language is "prolog" or "ambiguous_pl") - { - foreach (var call in state.GetPrologGoalCalls(lineNumber)) - { - if (!state.CallableNames.Contains(call.Name)) - continue; - - var prologContainer = call.IsTopLevelDirective - ? null - : state.ResolveContainer(lineNumber, call.Column, fallback: null); - if (call.IsTopLevelDirective) - { - ReferenceExtractor.AddReference( - references, - seen, - fileId, - call.Name, - call.Column, - "call", - context, - lineNumber, - container: null, - language); - continue; - } - - if (prologContainer != null - && !string.Equals(prologContainer.Name, call.Name, StringComparison.Ordinal)) - { - ReferenceExtractor.AddReference( - references, - seen, - fileId, - call.Name, - call.Column, - "call", - context, - lineNumber, - prologContainer, - language); - continue; - } - - addCallLikeReference(call.Name, call.Column); - } - - return; - } - - var callRegex = language switch - { - "crystal" => CrystalBareCallRegex, - "groovy" => GroovyBareCallRegex, - "tcl" => TclCommandRegex, - _ => null, - }; - if (callRegex == null) - return; - - if (language == "crystal") - { - foreach (Match match in BoundedRegex.EnumerateMatches(CrystalSuffixedParenthesizedCallRegex, preparedLine)) - { - var nameGroup = match.Groups["name"]; - if (state.CallableNames.Contains(nameGroup.Value)) - addCallLikeReference(nameGroup.Value, nameGroup.Index); - } - - foreach (Match match in BoundedRegex.EnumerateMatches(CrystalControlPredicateCallRegex, preparedLine)) - { - var nameGroup = match.Groups["name"]; - if (state.CallableNames.Contains(nameGroup.Value)) - addCallLikeReference(nameGroup.Value, nameGroup.Index); - } - } - else if (language == "groovy") - { - EmitGroovyControlBodyBareCalls( - preparedLine, - state.CallableNames, - addCallLikeReference); - } - - foreach (Match match in BoundedRegex.EnumerateMatches(callRegex, preparedLine)) - { - var nameGroup = match.Groups["name"]; - if (language == "tcl") - { - if (!state.TryResolveTclCallable( - nameGroup.Value, - out var referenceName, - out var targetQualifier, - out var referenceNameOffset)) - { - continue; - } - - if (targetQualifier != null - || !string.Equals(referenceName, nameGroup.Value, StringComparison.Ordinal)) - { - ReferenceExtractor.AddReference( - references, - seen, - fileId, - referenceName, - nameGroup.Index + referenceNameOffset, - "call", - context, - lineNumber, - resolveContainerForCall(nameGroup.Index), - language, - targetQualifier); - } - else - { - addCallLikeReference(nameGroup.Value, nameGroup.Index); - } - - continue; - } - - if (!state.CallableNames.Contains(nameGroup.Value)) - continue; - if (language == "groovy" - && IsGroovyClosureParameterHeader(preparedLine, nameGroup.Index)) - { - continue; - } - - addCallLikeReference(nameGroup.Value, nameGroup.Index); - } - - } - - private static void EmitGroovyControlBodyBareCalls( - string line, - IReadOnlySet callableNames, - Action addCallLikeReference) - { - for (var keywordColumn = 0; keywordColumn < line.Length; keywordColumn++) - { - if (!char.IsLetter(line[keywordColumn])) - continue; - - var keywordEnd = keywordColumn + 1; - while (keywordEnd < line.Length - && (char.IsLetterOrDigit(line[keywordEnd]) || line[keywordEnd] == '_')) - { - keywordEnd++; - } - - var keyword = line.AsSpan(keywordColumn, keywordEnd - keywordColumn); - if (!keyword.SequenceEqual("if") - && !keyword.SequenceEqual("while") - && !keyword.SequenceEqual("for")) - { - keywordColumn = keywordEnd - 1; - continue; - } - - var openingColumn = SkipWhitespace(line, keywordEnd); - if (openingColumn >= line.Length || line[openingColumn] != '(') - { - keywordColumn = keywordEnd - 1; - continue; - } - - var closingColumn = FindMatchingParenthesis(line, openingColumn); - if (closingColumn < 0) - { - keywordColumn = keywordEnd - 1; - continue; - } - - var nameColumn = SkipWhitespace(line, closingColumn + 1); - if (nameColumn >= line.Length - || !IsIdentifierStart(line[nameColumn])) - { - keywordColumn = closingColumn; - continue; - } - - var nameEnd = nameColumn + 1; - while (nameEnd < line.Length - && (char.IsLetterOrDigit(line[nameEnd]) || line[nameEnd] == '_')) - { - nameEnd++; - } - - var name = line[nameColumn..nameEnd]; - var nextColumn = SkipWhitespace(line, nameEnd); - if (callableNames.Contains(name) - && (nextColumn >= line.Length - || (line[nextColumn] != '(' - && line[nextColumn] != ':' - && line[nextColumn] != '='))) - { - addCallLikeReference(name, nameColumn); - } - - keywordColumn = closingColumn; - } - } - - private static int FindMatchingParenthesis(string line, int openingColumn) - { - var depth = 0; - for (var column = openingColumn; column < line.Length; column++) - { - if (line[column] == '(') - { - depth++; - } - else if (line[column] == ')' && --depth == 0) - { - return column; - } - } - - return -1; - } - - private static bool IsGroovyClosureParameterHeader(string line, int nameColumn) - { - var openingBrace = line.LastIndexOf('{', Math.Max(0, nameColumn - 1)); - if (openingBrace < 0) - return false; - - var closingBraceBeforeName = line.LastIndexOf('}', Math.Max(0, nameColumn - 1)); - if (closingBraceBeforeName > openingBrace) - return false; - - var arrowColumn = line.IndexOf("->", nameColumn, StringComparison.Ordinal); - if (arrowColumn < 0) - return false; - - var closingBraceAfterName = line.IndexOf('}', nameColumn); - if (closingBraceAfterName >= 0 && closingBraceAfterName < arrowColumn) - return false; - - return line.AsSpan(nameColumn, arrowColumn - nameColumn) - .IndexOfAny(';', '{', '}') < 0; - } - - private static int SkipWhitespace(string line, int column) - { - while (column < line.Length && char.IsWhiteSpace(line[column])) - column++; - return column; - } - - private static bool IsIdentifierStart(char value) => - value == '_' || char.IsLetter(value); - - public static bool ShouldSuppressGenericCall( - string language, - string preparedLine, - string name, - int callIndex, - int lineNumber, - ExtractionState? state, - SymbolRecord? container) - { - if (language == "ambiguous_pl") - return state?.HasPrologContainer(lineNumber) == true - || state?.IsPrologDirectiveLine(lineNumber) == true; - if (state?.IsDeclarationAt(lineNumber, callIndex, name) == true) - return true; - if (language == "crystal") - { - return MatchesDeclarationAt(CrystalMethodDeclarationRegex, preparedLine, name, callIndex) - || MatchesDeclarationAt(CrystalFunDeclarationRegex, preparedLine, name, callIndex); - } - if (language != "groovy") - return false; - if (name is "super" or "synchronized" or "this") - return true; - - if (MatchesDeclarationAt(GroovyMethodDeclarationRegex, preparedLine, name, callIndex)) - return true; - return container?.Kind == "class" - && string.Equals(container.Name, name, StringComparison.Ordinal) - && MatchesDeclarationAt(GroovyConstructorDeclarationRegex, preparedLine, name, callIndex); - } - - private static bool MatchesDeclarationAt( - Regex regex, - string line, - string name, - int callIndex) - { - foreach (Match declaration in BoundedRegex.EnumerateMatches(regex, line)) - { - var nameGroup = declaration.Groups["name"]; - if (nameGroup.Index == callIndex - && string.Equals(nameGroup.Value, name, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - - private static int SkipQuotedToken(string line, int startColumn, char delimiter) - { - for (var column = startColumn + 1; column < line.Length; column++) - { - if (line[column] == '\\') - { - column++; - continue; - } - - if (line[column] != delimiter) - continue; - - if (column + 1 < line.Length && line[column + 1] == delimiter) - { - column++; - continue; - } - - return column + 1; - } - - return line.Length; - } - - private static bool HasClosingQuotedDelimiter( - string line, - int startColumn, - char delimiter) - { - for (var column = startColumn + 1; column < line.Length; column++) - { - if (line[column] == '\\') - { - column++; - continue; - } - - if (line[column] == delimiter) - return true; - } - - return false; - } - - private static void FillWithSpaces(char[] buffer, int startColumn) - { - for (var column = startColumn; column < buffer.Length; column++) - buffer[column] = ' '; - } - - private static void FillWithSpaces(char[] buffer, int startColumn, int endColumn) - { - for (var column = startColumn; column < endColumn && column < buffer.Length; column++) - buffer[column] = ' '; - } - - private static bool IsPrologHashOperator(string line, int column) - { - if (column + 1 >= line.Length) - return false; - - return line[column + 1] is '=' or '<' or '>' or '\\' or '/' or '#'; - } - - private static bool IsPerlLastIndexVariable(string line, int column) - { - if (column <= 0 - || line[column - 1] != '$' - || column + 1 >= line.Length) - { - return false; - } - - return line[column + 1] == '{' - || line[column + 1] == '_' - || char.IsLetter(line[column + 1]); - } - - private static bool IsPerlHashSigil(string line, int column) - { - if (column + 1 >= line.Length - || (line[column + 1] != '{' - && line[column + 1] != '_' - && !char.IsLetter(line[column + 1]))) - { - return false; - } - - var previousColumn = column - 1; - while (previousColumn >= 0 && char.IsWhiteSpace(line[previousColumn])) - previousColumn--; - - var tokenEnd = column + 1; - if (line[tokenEnd] == '{') - return true; - while (tokenEnd < line.Length - && (char.IsLetterOrDigit(line[tokenEnd]) || line[tokenEnd] == '_')) - { - tokenEnd++; - } - while (tokenEnd < line.Length && char.IsWhiteSpace(line[tokenEnd])) - tokenEnd++; - - if (previousColumn < 0) - return tokenEnd < line.Length && line[tokenEnd] is '=' or '{' or '['; - - var prefix = line.AsSpan(0, previousColumn + 1); - if (prefix.Contains(":-", StringComparison.Ordinal) - || prefix.Contains("-->", StringComparison.Ordinal) - || line[previousColumn] == '.') - { - return false; - } - - if (line[previousColumn] is '=' or '(' or '[' or '{' or ',' or '\\') - return true; - if (line[previousColumn] == '>' - && previousColumn > 0 - && line[previousColumn - 1] == '=') - { - return true; - } - - var previousTokenStart = previousColumn; - while (previousTokenStart >= 0 - && (char.IsLetterOrDigit(line[previousTokenStart]) - || line[previousTokenStart] == '_')) - { - previousTokenStart--; - } - var previousToken = line.AsSpan(previousTokenStart + 1, previousColumn - previousTokenStart); - return previousToken.Equals("my", StringComparison.Ordinal) - || previousToken.Equals("our", StringComparison.Ordinal) - || previousToken.Equals("state", StringComparison.Ordinal) - || previousToken.Equals("local", StringComparison.Ordinal) - || previousToken.Equals("return", StringComparison.Ordinal) - || previousToken.Equals("keys", StringComparison.Ordinal) - || previousToken.Equals("values", StringComparison.Ordinal) - || previousToken.Equals("each", StringComparison.Ordinal) - || previousToken.Equals("delete", StringComparison.Ordinal) - || previousToken.Equals("exists", StringComparison.Ordinal) - || previousToken.Equals("defined", StringComparison.Ordinal) - || previousToken.Equals("scalar", StringComparison.Ordinal); - } - - private static bool IsLikelyPerlModuloOperator(string line, int column) - { - var prefix = line.AsSpan(0, column); - var hasPerlContext = prefix.Contains('$') - || prefix.Contains('@') - || StartsWithPerlStatementKeyword(prefix); - if (!hasPerlContext) - return false; - - var previousColumn = column - 1; - while (previousColumn >= 0 && char.IsWhiteSpace(line[previousColumn])) - previousColumn--; - if (previousColumn < 0) - return false; - - if (column + 1 < line.Length && line[column + 1] == '=') - return true; - - var nextColumn = column + 1; - while (nextColumn < line.Length && char.IsWhiteSpace(line[nextColumn])) - nextColumn++; - if (nextColumn >= line.Length) - return false; - - var previous = line[previousColumn]; - var next = line[nextColumn]; - return (char.IsLetterOrDigit(previous) || previous is '_' or ')' or ']' or '}') - && (char.IsLetterOrDigit(next) || next is '_' or '$' or '@' or '(' or '+' or '-'); - } - - private static bool StartsWithPerlStatementKeyword(ReadOnlySpan prefix) - { - prefix = prefix.TrimStart(); - foreach (var keyword in new[] { "my", "our", "state", "local", "return" }) - { - if (!prefix.StartsWith(keyword, StringComparison.Ordinal)) - continue; - if (prefix.Length == keyword.Length || char.IsWhiteSpace(prefix[keyword.Length])) - return true; - } - - return false; - } - - private static bool IsLikelySlashyLiteralStart(string line, int column) - { - if (column + 1 >= line.Length || line[column + 1] is '/' or '*') - return false; - - var previousColumn = column - 1; - while (previousColumn >= 0 && char.IsWhiteSpace(line[previousColumn])) - previousColumn--; - - if (previousColumn < 0) - return true; - - if (line[previousColumn] is '=' or '(' or '[' or '{' or ',' or ':' or ';' - or '!' or '&' or '|' or '?' or '+' or '-' or '*' or '%' or '~') - { - return true; - } - - var tokenEnd = previousColumn + 1; - while (previousColumn >= 0 - && (char.IsLetterOrDigit(line[previousColumn]) || line[previousColumn] == '_')) - { - previousColumn--; - } - - var token = line.AsSpan(previousColumn + 1, tokenEnd - previousColumn - 1); - return token.SequenceEqual("return") - || token.SequenceEqual("case") - || token.SequenceEqual("throw") - || token.SequenceEqual("assert") - || token.SequenceEqual("in") - || token.SequenceEqual("when") - || token.SequenceEqual("if") - || token.SequenceEqual("elsif") - || token.SequenceEqual("unless") - || token.SequenceEqual("while") - || token.SequenceEqual("until"); - } - - private static void EnqueueAmbiguousPerlHeredocDelimiters( - string line, - string maskedLine, - Queue delimiters) - { - for (var column = 0; column + 1 < line.Length;) - { - if (line[column] is '\'' or '"' or '`') - { - column = SkipQuotedToken(line, column, line[column]); - continue; - } - - if (line[column] != '<' - || line[column + 1] != '<' - || maskedLine[column] != '<' - || maskedLine[column + 1] != '<') - { - column++; - continue; - } - - var delimiterColumn = column + 2; - var allowIndent = delimiterColumn < line.Length - && line[delimiterColumn] == '~'; - if (allowIndent) - delimiterColumn++; - - var beforeWhitespace = delimiterColumn; - delimiterColumn = SkipWhitespace(line, delimiterColumn); - var hasWhitespace = delimiterColumn > beforeWhitespace; - if (delimiterColumn >= line.Length) - break; - - string? delimiter = null; - var nextColumn = delimiterColumn + 1; - if (line[delimiterColumn] is '\'' or '"' or '`') - { - var quote = line[delimiterColumn]; - var closingColumn = delimiterColumn + 1; - while (closingColumn < line.Length - && line[closingColumn] != quote) - { - if (line[closingColumn] == '\\' - && closingColumn + 1 < line.Length) - { - closingColumn += 2; - } - else - { - closingColumn++; - } - } - - if (closingColumn < line.Length) - { - delimiter = line[(delimiterColumn + 1)..closingColumn]; - nextColumn = closingColumn + 1; - } - } - else - { - if (line[delimiterColumn] == '\\') - { - delimiterColumn++; - } - else if (hasWhitespace) - { - column += 2; - continue; - } - - var delimiterEnd = delimiterColumn; - while (delimiterEnd < line.Length - && (char.IsLetterOrDigit(line[delimiterEnd]) - || line[delimiterEnd] == '_')) - { - delimiterEnd++; - } - - if (delimiterEnd > delimiterColumn - && (char.IsLetter(line[delimiterColumn]) - || line[delimiterColumn] == '_')) - { - delimiter = line[delimiterColumn..delimiterEnd]; - nextColumn = delimiterEnd; - } - } - - if (!string.IsNullOrEmpty(delimiter)) - delimiters.Enqueue(new AmbiguousPerlHeredocDelimiter(delimiter, allowIndent)); - column = Math.Max(nextColumn, column + 2); - } - } - - private static bool TryBeginAmbiguousPerlQuoteLikeLiteral( - string line, - char[] buffer, - int column, - out AmbiguousPerlQuoteLikeState state, - out int contentColumn) - { - state = null!; - contentColumn = column; - if (column > 0 - && (char.IsLetterOrDigit(line[column - 1]) || line[column - 1] == '_')) - { - return false; - } - - var operatorLength = line.AsSpan(column) switch - { - var span when span.StartsWith("qq", StringComparison.Ordinal) - || span.StartsWith("qr", StringComparison.Ordinal) - || span.StartsWith("qw", StringComparison.Ordinal) - || span.StartsWith("qx", StringComparison.Ordinal) - || span.StartsWith("tr", StringComparison.Ordinal) => 2, - var span when span.StartsWith("q", StringComparison.Ordinal) - || span.StartsWith("m", StringComparison.Ordinal) - || span.StartsWith("s", StringComparison.Ordinal) - || span.StartsWith("y", StringComparison.Ordinal) => 1, - _ => 0, - }; - if (operatorLength == 0) - return false; - - var delimiterColumn = SkipWhitespace(line, column + operatorLength); - if (delimiterColumn >= line.Length - || char.IsLetterOrDigit(line[delimiterColumn]) - || line[delimiterColumn] == '_') - { - return false; - } - var delimiterSpan = line.AsSpan(delimiterColumn); - if (delimiterSpan.StartsWith("=>", StringComparison.Ordinal) - || delimiterSpan.StartsWith(":-", StringComparison.Ordinal) - || delimiterSpan.StartsWith("-->", StringComparison.Ordinal)) - { - return false; - } - - var openingDelimiter = line[delimiterColumn]; - var closingDelimiter = GetPairedClosingDelimiter(openingDelimiter); - var remainingSegments = line.AsSpan(column, operatorLength).SequenceEqual("s") - || line.AsSpan(column, operatorLength).SequenceEqual("tr") - || line.AsSpan(column, operatorLength).SequenceEqual("y") - ? 2 - : 1; - FillWithSpaces(buffer, column, delimiterColumn + 1); - state = new AmbiguousPerlQuoteLikeState( - openingDelimiter, - closingDelimiter, - remainingSegments); - contentColumn = delimiterColumn + 1; - return true; - } - - private static void MaskAmbiguousPerlQuoteLikeCharacter( - string line, - char[] buffer, - ref int column, - ref AmbiguousPerlQuoteLikeState? state) - { - var current = state!; - buffer[column] = ' '; - if (current.AwaitingNextOpeningDelimiter) - { - if (char.IsWhiteSpace(line[column])) - { - column++; - return; - } - - current.OpeningDelimiter = line[column]; - current.ClosingDelimiter = GetPairedClosingDelimiter(line[column]); - current.DelimiterDepth = current.OpeningDelimiter == current.ClosingDelimiter ? 0 : 1; - current.AwaitingNextOpeningDelimiter = false; - column++; - return; - } - - if (line[column] == '\\' && column + 1 < line.Length) - { - buffer[column + 1] = ' '; - column += 2; - return; - } - - if (current.OpeningDelimiter != current.ClosingDelimiter - && line[column] == current.OpeningDelimiter) - { - current.DelimiterDepth++; - column++; - return; - } - - if (line[column] != current.ClosingDelimiter) - { - column++; - return; - } - - if (current.OpeningDelimiter != current.ClosingDelimiter - && --current.DelimiterDepth > 0) - { - column++; - return; - } - - current.RemainingSegments--; - if (current.RemainingSegments == 0) - { - state = null; - } - else if (current.OpeningDelimiter != current.ClosingDelimiter) - { - current.AwaitingNextOpeningDelimiter = true; - } - else - { - current.DelimiterDepth = 0; - } - - column++; - } - - private static char GetPairedClosingDelimiter(char openingDelimiter) => - openingDelimiter switch - { - '(' => ')', - '[' => ']', - '{' => '}', - '<' => '>', - _ => openingDelimiter, - }; - - private static bool TryBeginCrystalPercentLiteral( - string line, - int column, - out char openingDelimiter, - out char closingDelimiter, - out int contentColumn) - { - openingDelimiter = '\0'; - closingDelimiter = '\0'; - contentColumn = column; - if (line[column] != '%' || column + 1 >= line.Length) - return false; - - var delimiterColumn = column + 1; - var hasTypePrefix = line[delimiterColumn] is 'q' or 'Q' or 'w' or 'W' or 'i' or 'I' or 'x' or 'r'; - if (hasTypePrefix) - delimiterColumn++; - if (delimiterColumn >= line.Length - || char.IsLetterOrDigit(line[delimiterColumn]) - || char.IsWhiteSpace(line[delimiterColumn]) - || (!hasTypePrefix && line[delimiterColumn] is not ('(' or '[' or '{' or '<'))) - { - return false; - } - - openingDelimiter = line[delimiterColumn]; - closingDelimiter = openingDelimiter switch - { - '(' => ')', - '[' => ']', - '{' => '}', - '<' => '>', - _ => openingDelimiter, - }; - contentColumn = delimiterColumn + 1; - return true; - } - - private static string PreparePrologCallScanLine( - string language, - string line, - bool isClauseContinuation) - { - if (language is not ("prolog" or "ambiguous_pl")) - return line; - - var masked = line.ToCharArray(); - var clauseStartColumn = isClauseContinuation - ? FindPrologClauseTerminator(line, 0) + 1 - : 0; - if (isClauseContinuation && clauseStartColumn == 0) - return line; - var changed = false; - while (TryFindNextPrologClauseStart(line, clauseStartColumn, out var headStartColumn) - && TryFindPrologHeadBoundary( - line, - headStartColumn, - out var bodyStartColumn, - out var clauseEndColumn)) - { - if (bodyStartColumn >= 0) - { - FillWithSpaces(masked, headStartColumn, bodyStartColumn); - changed = true; - if (clauseEndColumn < 0) - break; - } - else - { - FillWithSpaces(masked, headStartColumn, clauseEndColumn + 1); - changed = true; - } - - clauseStartColumn = clauseEndColumn + 1; - } - - return changed ? new string(masked) : line; - } - - private static bool TryFindNextPrologClauseStart( - string line, - int searchColumn, - out int clauseStartColumn) - { - for (var column = Math.Max(0, searchColumn); column < line.Length; column++) - { - if (char.IsWhiteSpace(line[column])) - continue; - - clauseStartColumn = column; - return char.IsLower(line[column]); - } - - clauseStartColumn = -1; - return false; - } - - private static bool TryFindPrologHeadBoundary( - string line, - int headStartColumn, - out int bodyStartColumn, - out int clauseEndColumn) - { - bodyStartColumn = -1; - clauseEndColumn = -1; - var parenthesisDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - for (var column = headStartColumn; column < line.Length; column++) - { - var ch = line[column]; - if (ch is '\'' or '"') - { - column = SkipQuotedToken(line, column, ch) - 1; - continue; - } - switch (ch) - { - case '(': - parenthesisDepth++; - continue; - case ')' when parenthesisDepth > 0: - parenthesisDepth--; - continue; - case '[': - bracketDepth++; - continue; - case ']' when bracketDepth > 0: - bracketDepth--; - continue; - case '{': - braceDepth++; - continue; - case '}' when braceDepth > 0: - braceDepth--; - continue; - } - if (parenthesisDepth != 0 || bracketDepth != 0 || braceDepth != 0) - continue; - - var separatorLength = line.AsSpan(column).StartsWith("-->", StringComparison.Ordinal) - ? 3 - : line.AsSpan(column).StartsWith(":-", StringComparison.Ordinal) - ? 2 - : 0; - if (separatorLength > 0) - { - bodyStartColumn = column + separatorLength; - clauseEndColumn = FindPrologClauseTerminator(line, bodyStartColumn); - return true; - } - if (IsPrologClauseTerminator(line, column)) - { - clauseEndColumn = column; - return true; - } - } - - return false; - } - - internal static bool IsPrologClauseTerminator(string line, int column) - { - if (column < 0 || column >= line.Length || line[column] != '.') - return false; - - return PrologClauseTerminatorMaps - .GetValue(line, static currentLine => new PrologClauseTerminatorMap(currentLine)) - .IsTerminator(column); - } - - private sealed class PrologClauseTerminatorMap - { - private readonly bool[] _terminatorColumns; - - public PrologClauseTerminatorMap(string line) - { - _terminatorColumns = new bool[line.Length]; - var parenthesisDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - for (var column = 0; column < line.Length; column++) - { - var ch = line[column]; - if (ch is '\'' or '"') - { - column = SkipQuotedToken(line, column, ch) - 1; - continue; - } - switch (ch) - { - case '(': - parenthesisDepth++; - continue; - case ')' when parenthesisDepth > 0: - parenthesisDepth--; - continue; - case '[': - bracketDepth++; - continue; - case ']' when bracketDepth > 0: - bracketDepth--; - continue; - case '{': - braceDepth++; - continue; - case '}' when braceDepth > 0: - braceDepth--; - continue; - } - - if (ch != '.' - || parenthesisDepth != 0 - || bracketDepth != 0 - || braceDepth != 0) - { - continue; - } - - var previous = column > 0 ? line[column - 1] : '\0'; - var next = column + 1 < line.Length ? line[column + 1] : '\0'; - if (previous != '.' - && next != '.' - && !(char.IsDigit(previous) && char.IsDigit(next)) - && (next == '\0' || char.IsWhiteSpace(next))) - { - _terminatorColumns[column] = true; - } - } - } - - public bool IsTerminator(int column) => _terminatorColumns[column]; - } - - private static int FindPrologClauseTerminator(string line, int startColumn) - { - for (var column = Math.Max(0, startColumn); column < line.Length; column++) - { - if (IsPrologClauseTerminator(line, column)) - return column; - } - - return -1; - } - - private static void EmitImportReference( - string language, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForCall) - { - var match = language switch - { - "crystal" => CrystalRequireRegex.Match(originalLine), - "groovy" => GroovyImportRegex.Match(originalLine), - "tcl" => TclPackageRegex.Match(originalLine), - "prolog" or "ambiguous_pl" => PrologImportRegex.Match(originalLine), - _ => Match.Empty, - }; - if (!match.Success) - return; - - var nameGroup = match.Groups["name"]; - var name = NormalizeImportTarget(language, nameGroup.Value); - if (name.Length == 0) - return; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name, - nameGroup.Index, - "type_reference", - context, - lineNumber, - resolveContainerForCall(nameGroup.Index), - language); - } - - private static string NormalizeImportTarget(string language, string name) - { - var normalized = name.Replace('\\', '/').TrimEnd('/'); - if (language == "groovy") - return normalized[(normalized.LastIndexOf('.') + 1)..]; - if (language is "crystal" or "prolog" or "ambiguous_pl") - { - normalized = normalized[(normalized.LastIndexOf('/') + 1)..]; - var extensionIndex = normalized.LastIndexOf('.'); - if (extensionIndex > 0) - normalized = normalized[..extensionIndex]; - } - return normalized; - } - - private static void AddTclContainers( - IReadOnlyList lines, - IReadOnlyList symbols, - IReadOnlyDictionary braceEnds, - List scopes, - HashSet scriptBodyOpenings) - { - foreach (var symbol in symbols) - { - if (symbol.Kind != "function" || symbol.StartLine < 1 || symbol.StartLine > lines.Count) - continue; - - var startLineIndex = symbol.StartLine - 1; - var declarationMatch = FindTclProcDeclaration(lines[startLineIndex], symbol); - if (declarationMatch == null - || !TryFindTclBodyEnd( - lines, - braceEnds, - startLineIndex, - declarationMatch.Index + declarationMatch.Length, - out var bodyStartLineIndex, - out var bodyStartColumn, - out var bodyEnd)) - { - continue; - } - - scopes.Add(new TclContainerScope( - symbol, - bodyStartLineIndex + 1, - lines[bodyStartLineIndex][bodyStartColumn] is '{' or '"' - ? bodyStartColumn - : bodyStartColumn - 1, - bodyEnd.Line + 1, - lines[bodyStartLineIndex][bodyStartColumn] is '{' or '"' - ? bodyEnd.Column - : bodyEnd.Column + 1)); - if (lines[bodyStartLineIndex][bodyStartColumn] == '{') - scriptBodyOpenings.Add(GetTclPositionKey(bodyStartLineIndex, bodyStartColumn)); - } - - scopes.Sort(static (left, right) => - { - var startComparison = left.BodyStartLine.CompareTo(right.BodyStartLine); - return startComparison != 0 - ? startComparison - : left.BodyStartColumn.CompareTo(right.BodyStartColumn); - }); - } - - private static Match? FindTclProcDeclaration(string line, SymbolRecord symbol) - { - Match? fallback = null; - foreach (Match match in BoundedRegex.EnumerateMatches(TclProcRegex, line)) - { - var nameGroup = match.Groups["name"]; - if (!string.Equals(nameGroup.Value, symbol.Name, StringComparison.Ordinal)) - continue; - if (symbol.StartColumn == nameGroup.Index) - return match; - fallback ??= match; - } - - return fallback; - } - - private static bool TryFindTclBodyEnd( - IReadOnlyList lines, - IReadOnlyDictionary braceEnds, - int startLineIndex, - int searchColumn, - out int bodyStartLineIndex, - out int bodyStartColumn, - out TclBraceEnd bodyEnd) - { - bodyStartLineIndex = startLineIndex; - bodyStartColumn = -1; - bodyEnd = default; - if (!TryFindNextNonWhitespace(lines[startLineIndex], searchColumn, out var argsColumn) - || !TryFindTclWordEnd( - lines, - braceEnds, - startLineIndex, - argsColumn, - out var argsEndLine, - out var argsEndColumn) - || !TryFindNextNonWhitespace( - lines, - argsEndLine, - argsEndColumn + 1, - out bodyStartLineIndex, - out bodyStartColumn) - || !TryFindTclWordEnd( - lines, - braceEnds, - bodyStartLineIndex, - bodyStartColumn, - out var bodyEndLine, - out var bodyEndColumn)) - { - return false; - } - - bodyEnd = new TclBraceEnd(bodyEndLine, bodyEndColumn); - return true; - } - - private static bool TryFindTclWordEnd( - IReadOnlyList lines, - IReadOnlyDictionary braceEnds, - int startLine, - int startColumn, - out int endLine, - out int endColumn) - { - var line = lines[startLine]; - if (line[startColumn] == '{') - { - if (braceEnds.TryGetValue(GetTclPositionKey(startLine, startColumn), out var braceEnd)) - { - endLine = braceEnd.Line; - endColumn = braceEnd.Column; - return true; - } - - endLine = -1; - endColumn = -1; - return false; - } - - if (line[startColumn] == '"') - { - for (var lineIndex = startLine; lineIndex < lines.Count; lineIndex++) - { - line = lines[lineIndex]; - var firstColumn = lineIndex == startLine ? startColumn + 1 : 0; - for (var column = firstColumn; column < line.Length; column++) - { - if (line[column] == '\\') - { - column++; - continue; - } - if (line[column] == '"') - { - endLine = lineIndex; - endColumn = column; - return true; - } - } - } - - endLine = -1; - endColumn = -1; - return false; - } - - var wordEnd = startColumn; - while (wordEnd + 1 < line.Length && !char.IsWhiteSpace(line[wordEnd + 1])) - wordEnd++; - endLine = startLine; - endColumn = wordEnd; - return true; - } - - private static bool TryFindNextNonWhitespace( - string line, - int startColumn, - out int foundColumn) - { - for (var column = startColumn; column < line.Length; column++) - { - if (!char.IsWhiteSpace(line[column])) - { - foundColumn = column; - return true; - } - } - - foundColumn = -1; - return false; - } - - private static bool TryFindNextNonWhitespace( - IReadOnlyList lines, - int startLine, - int startColumn, - out int foundLine, - out int foundColumn) - { - for (var lineIndex = startLine; lineIndex < lines.Count; lineIndex++) - { - var column = lineIndex == startLine ? startColumn : 0; - if (TryFindNextNonWhitespace(lines[lineIndex], column, out foundColumn)) - { - if (foundColumn == lines[lineIndex].Length - 1 - && lines[lineIndex][foundColumn] == '\\') - { - continue; - } - - foundLine = lineIndex; - return true; - } - } - - foundLine = -1; - foundColumn = -1; - return false; - } - - private static Dictionary BuildTclBraceEndPositions(IReadOnlyList lines) - { - var result = new Dictionary(); - var openings = new Stack<(int Line, int Column)>(); - var commandStart = true; - var wordStart = true; - for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) - { - var line = lines[lineIndex]; - for (var column = 0; column < line.Length; column++) - { - var ch = line[column]; - if (openings.Count > 0) - { - if (ch == '\\') - { - column++; - continue; - } - if (ch == '{') - { - openings.Push((lineIndex, column)); - } - else if (ch == '}') - { - var opening = openings.Pop(); - result[GetTclPositionKey(opening.Line, opening.Column)] = - new TclBraceEnd(lineIndex, column); - } - continue; - } - - if (ch == '\\') - { - column++; - commandStart = false; - wordStart = false; - continue; - } - - if (ch == '"') - { - column = SkipQuotedToken(line, column, ch) - 1; - commandStart = false; - wordStart = false; - continue; - } - - if (ch == '#' && commandStart) - break; - if (ch == ';' || ch == '[') - { - commandStart = true; - wordStart = true; - continue; - } - if (char.IsWhiteSpace(ch)) - { - wordStart = true; - continue; - } - if (ch == '{' && wordStart) - { - openings.Push((lineIndex, column)); - commandStart = false; - wordStart = false; - continue; - } - - commandStart = false; - wordStart = false; - } - - if (openings.Count == 0) - { - commandStart = true; - wordStart = true; - } - } - - return result; - } - - private static string[] BuildTclCallLines( - IReadOnlyList lines, - IReadOnlyDictionary braceEnds, - IReadOnlySet scriptBodyOpenings, - IDictionary? commentColumns = null) - { - var result = new string[lines.Count]; - var frames = new Stack(); - frames.Push(new TclLexicalFrame(TclLexicalFrameKind.Script)); - var commentContinued = false; - - for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) - { - var line = lines[lineIndex]; - if (commentContinued) - { - result[lineIndex] = new string(' ', line.Length); - commentColumns?.TryAdd(lineIndex, 0); - commentContinued = HasTclEscapedNewline(line); - continue; - } - - var buffer = line.ToCharArray(); - var lineContinued = false; - var suppressLeadingContinuedWord = frames.Peek().Kind != TclLexicalFrameKind.Script - || !frames.Peek().CommandStart; - for (var column = 0; column < line.Length;) - { - var frame = frames.Peek(); - var ch = line[column]; - if (frame.Kind == TclLexicalFrameKind.SwitchTable) - { - buffer[column] = ' '; - if (ch == frame.Terminator) - { - frames.Pop(); - column++; - } - else if (ch == '\\' && column + 1 < line.Length) - { - buffer[column + 1] = ' '; - frame.WordStart = false; - column += 2; - } - else if (char.IsWhiteSpace(ch)) - { - frame.WordStart = true; - column++; - } - else if (ch == '{' && frame.WordStart) - { - var wordIndex = frame.WordIndex++; - if (wordIndex % 2 == 1) - { - buffer[column] = ';'; - frames.Push(new TclLexicalFrame(TclLexicalFrameKind.Script, '}')); - suppressLeadingContinuedWord = false; - } - else - { - frames.Push(new TclLexicalFrame(TclLexicalFrameKind.BracedWord, '}')); - } - - frame.WordStart = false; - column++; - } - else if (ch == '"' && frame.WordStart) - { - var wordIndex = frame.WordIndex++; - if (wordIndex % 2 == 1) - { - buffer[column] = ';'; - frames.Push(new TclLexicalFrame(TclLexicalFrameKind.Script, '"')); - suppressLeadingContinuedWord = false; - } - else - { - var endColumn = SkipQuotedToken(line, column, '"'); - FillWithSpaces(buffer, column, endColumn); - column = endColumn - 1; - } - frame.WordStart = false; - column++; - } - else - { - if (frame.WordStart) - { - var wordIndex = frame.WordIndex++; - if (wordIndex % 2 == 1) - { - var endColumn = column; - while (endColumn < line.Length - && !char.IsWhiteSpace(line[endColumn]) - && line[endColumn] != frame.Terminator) - { - buffer[endColumn] = line[endColumn]; - endColumn++; - } - MarkTclBareScriptCommandBoundary(buffer, column); - column = endColumn - 1; - } - } - frame.WordStart = false; - column++; - } - - continue; - } - - if (frame.Kind == TclLexicalFrameKind.BracedWord) - { - buffer[column] = ' '; - if (ch == '\\' && column + 1 < line.Length) - { - buffer[column + 1] = ' '; - column += 2; - } - else if (ch == '{') - { - frames.Push(new TclLexicalFrame(TclLexicalFrameKind.BracedWord, '}')); - column++; - } - else if (ch == frame.Terminator) - { - frames.Pop(); - column++; - } - else - { - column++; - } - continue; - } - - if (frame.Kind == TclLexicalFrameKind.ExpressionWord) - { - buffer[column] = ' '; - if (ch == '\\' && column + 1 < line.Length) - { - buffer[column + 1] = ' '; - column += 2; - } - else if (ch == '{') - { - frames.Push(new TclLexicalFrame(TclLexicalFrameKind.ExpressionWord, '}')); - column++; - } - else if (ch == frame.Terminator) - { - frames.Pop(); - column++; - } - else if (ch == '[') - { - buffer[column] = '['; - frames.Push(new TclLexicalFrame(TclLexicalFrameKind.Script, ']')); - suppressLeadingContinuedWord = false; - column++; - } - else - { - column++; - } - continue; - } - - if (frame.Kind == TclLexicalFrameKind.Quote) - { - buffer[column] = ' '; - if (ch == '\\' && column + 1 < line.Length) - { - buffer[column + 1] = ' '; - column += 2; - } - else if (ch == frame.Terminator) - { - frames.Pop(); - column++; - } - else if (ch == '[') - { - buffer[column] = '['; - frames.Push(new TclLexicalFrame(TclLexicalFrameKind.Script, ']')); - suppressLeadingContinuedWord = false; - column++; - } - else - { - column++; - } - continue; - } - - if (frame.Terminator != '\0' && ch == frame.Terminator) - { - PersistTclConcatenatedScriptState(frame); - frames.Pop(); - buffer[column] = frame.Terminator == '}' ? ' ' : ch; - column++; - continue; - } - if (ch == '\\') - { - buffer[column] = ' '; - if (column + 1 >= line.Length) - { - lineContinued = true; - frame.WordStart = true; - column++; - continue; - } - - if (frame.WordStart) - frame.WordIndex++; - buffer[column + 1] = ' '; - column += 2; - frame.CommandStart = false; - frame.WordStart = false; - continue; - } - if (ch == '#' && frame.CommandStart) - { - FillWithSpaces(buffer, column); - commentColumns?.TryAdd(lineIndex, column); - commentContinued = HasTclEscapedNewline(line); - break; - } - if (ch == '"') - { - var isScriptArgument = false; - var isConcatenatedScriptArgument = false; - if (frame.WordStart) - { - var wordIndex = frame.WordIndex++; - var token = GetTclQuotedWordToken(line, column); - isConcatenatedScriptArgument = IsTclConcatenatedScriptArgument( - frame, - wordIndex, - token); - isScriptArgument = isConcatenatedScriptArgument - || IsTclScriptArgument( - frame, - wordIndex, - lines, - braceEnd: null); - UpdateTclFirstArgument(frame, wordIndex, token); - UpdateTclDictArgumentState(frame, wordIndex, token); - UpdateTclSwitchArgumentState(frame, wordIndex, string.Empty); - UpdateTclTryArgumentState(frame, wordIndex, string.Empty, isScriptArgument); - } - var quotedFrame = isScriptArgument - ? CreateTclScriptFrame(frame, '"', isConcatenatedScriptArgument) - : new TclLexicalFrame(TclLexicalFrameKind.Quote, '"'); - buffer[column] = isScriptArgument - && (!isConcatenatedScriptArgument || quotedFrame.CommandStart) - ? ';' - : ' '; - frames.Push(quotedFrame); - frame.CommandStart = false; - frame.WordStart = false; - if (isScriptArgument) - suppressLeadingContinuedWord = false; - column++; - continue; - } - if (ch == '[') - { - if (frame.WordStart) - { - var wordIndex = frame.WordIndex++; - UpdateTclDictArgumentState(frame, wordIndex, token: null); - UpdateTclSwitchArgumentState(frame, wordIndex, string.Empty); - UpdateTclTryArgumentState( - frame, - wordIndex, - string.Empty, - isScriptArgument: false); - } - frames.Push(new TclLexicalFrame(TclLexicalFrameKind.Script, ']')); - frame.CommandStart = false; - frame.WordStart = false; - suppressLeadingContinuedWord = false; - column++; - continue; - } - if (ch == '{' && frame.WordStart) - { - var wordIndex = frame.WordIndex++; - var positionKey = GetTclPositionKey(lineIndex, column); - TclBraceEnd? braceEnd = braceEnds.TryGetValue(positionKey, out var foundBraceEnd) - ? foundBraceEnd - : null; - var isSwitchTable = IsTclSwitchTableArgument( - frame, - wordIndex, - lines, - braceEnd); - var token = GetTclBracedWordToken( - lines, - lineIndex, - column, - braceEnd); - var isConcatenatedScriptArgument = !isSwitchTable - && IsTclConcatenatedScriptArgument( - frame, - wordIndex, - token); - var isExpressionArgument = !isSwitchTable - && IsTclExpressionArgument(frame, wordIndex); - var isScriptArgument = !isSwitchTable - && (isConcatenatedScriptArgument - || scriptBodyOpenings.Contains(positionKey) - || IsTclScriptArgument( - frame, - wordIndex, - lines, - braceEnd)); - UpdateTclFirstArgument(frame, wordIndex, token); - UpdateTclDictArgumentState(frame, wordIndex, token); - UpdateTclSwitchArgumentState(frame, wordIndex, string.Empty); - UpdateTclTryArgumentState( - frame, - wordIndex, - string.Empty, - isScriptArgument); - if (isSwitchTable) - { - buffer[column] = ' '; - frames.Push(new TclLexicalFrame(TclLexicalFrameKind.SwitchTable, '}')); - } - else if (isScriptArgument) - { - var scriptFrame = CreateTclScriptFrame( - frame, - '}', - isConcatenatedScriptArgument); - buffer[column] = !isConcatenatedScriptArgument || scriptFrame.CommandStart - ? ';' - : ' '; - frames.Push(scriptFrame); - suppressLeadingContinuedWord = false; - } - else if (isExpressionArgument) - { - buffer[column] = ' '; - frames.Push(new TclLexicalFrame(TclLexicalFrameKind.ExpressionWord, '}')); - } - else - { - buffer[column] = ' '; - frames.Push(new TclLexicalFrame(TclLexicalFrameKind.BracedWord, '}')); - } - frame.CommandStart = false; - frame.WordStart = false; - column++; - continue; - } - if (ch == ';') - { - frame.ResetCommand(); - suppressLeadingContinuedWord = false; - column++; - continue; - } - if (char.IsWhiteSpace(ch)) - { - frame.WordStart = true; - column++; - continue; - } - - if (frame.WordStart) - { - var wordIndex = frame.WordIndex++; - var token = ReadTclBareWord(line, column); - var isConcatenatedScriptArgument = token.Length > 0 - && IsTclConcatenatedScriptArgument(frame, wordIndex, token); - var isScriptCommand = token.Length > 0 - && (isConcatenatedScriptArgument - ? ProcessTclConcatenatedBareWord(frame, token) - : IsTclBareScriptCommandArgument( - frame, - wordIndex, - token, - lines, - new TclBraceEnd(lineIndex, column + token.Length - 1))); - if (wordIndex == 0) - frame.CommandName = token; - else - { - UpdateTclFirstArgument(frame, wordIndex, token); - UpdateTclDictArgumentState(frame, wordIndex, token); - UpdateTclSwitchArgumentState(frame, wordIndex, token); - UpdateTclTryArgumentState( - frame, - wordIndex, - token, - isScriptCommand); - } - if (token.Length > 0) - { - frame.LastBareWord = token; - frame.LastBareWordIndex = wordIndex; - } - - if (suppressLeadingContinuedWord) - { - FillWithSpaces(buffer, column, column + token.Length); - suppressLeadingContinuedWord = false; - } - else if (isScriptCommand) - { - MarkTclBareScriptCommandBoundary(buffer, column); - } - } - - frame.CommandStart = false; - frame.WordStart = false; - column++; - } - - result[lineIndex] = new string(buffer); - if (!lineContinued && frames.Peek().Kind == TclLexicalFrameKind.Script) - frames.Peek().ResetCommand(); - } - - return result; - } - - private static bool HasTclEscapedNewline(string line) - { - var backslashCount = 0; - for (var column = line.Length - 1; column >= 0 && line[column] == '\\'; column--) - backslashCount++; - return backslashCount % 2 == 1; - } - - private static int FindTclCommentStart(string line) - { - var commandStart = true; - for (var column = 0; column < line.Length; column++) - { - var ch = line[column]; - if (ch is '\'' or '"') - { - column = SkipQuotedToken(line, column, ch) - 1; - commandStart = false; - continue; - } - if (ch == '\\') - { - column++; - commandStart = false; - continue; - } - if (ch == '#' && commandStart) - return column; - if (ch is ';' or '[') - { - commandStart = true; - continue; - } - if (char.IsWhiteSpace(ch)) - continue; - commandStart = false; - } - - return -1; - } - - private static string ReadTclBareWord(string line, int startColumn) - { - var endColumn = startColumn; - while (endColumn < line.Length - && (char.IsLetterOrDigit(line[endColumn]) - || line[endColumn] is '_' or ':' or '.' or '-' or '#')) - { - endColumn++; - } - - return endColumn == startColumn - ? string.Empty - : line[startColumn..endColumn]; - } - - private static bool IsTclScriptArgument( - TclLexicalFrame frame, - int wordIndex, - IReadOnlyList lines, - TclBraceEnd? braceEnd) - { - var isLastCommandWord = braceEnd is { } end - && IsTclLastCommandWord(lines, end); - return frame.CommandName switch - { - "if" => wordIndex == 2 - || (frame.LastBareWord == "then" - && wordIndex == frame.LastBareWordIndex + 1) - || (frame.LastBareWord == "elseif" - && wordIndex == frame.LastBareWordIndex + 2) - || (frame.LastBareWord == "else" - && wordIndex == frame.LastBareWordIndex + 1), - "foreach" or "lmap" => wordIndex >= 3 - && wordIndex % 2 == 1 - && isLastCommandWord, - "while" => wordIndex == 2, - "catch" => wordIndex == 1, - "for" => wordIndex is 1 or 3 or 4, - "proc" => wordIndex == 3, - "try" => wordIndex == frame.TryScriptWordIndex, - "dict" => wordIndex == frame.DictScriptWordIndex, - "switch" => frame.SwitchStringWordIndex >= 0 - && wordIndex - frame.SwitchStringWordIndex >= 2 - && (wordIndex - frame.SwitchStringWordIndex) % 2 == 0, - _ => false, - }; - } - - private static bool IsTclExpressionArgument(TclLexicalFrame frame, int wordIndex) => - frame.CommandName switch - { - "if" => wordIndex == 1 - || (frame.LastBareWord == "elseif" - && wordIndex == frame.LastBareWordIndex + 1), - "while" => wordIndex == 1, - "for" => wordIndex == 2, - "expr" => wordIndex >= 1, - _ => false, - }; - - private static bool IsTclBareScriptCommandArgument( - TclLexicalFrame frame, - int wordIndex, - string token, - IReadOnlyList lines, - TclBraceEnd wordEnd) - { - if (frame.CommandName == "if" && token == "then") - return false; - - return IsTclScriptArgument( - frame, - wordIndex, - lines, - wordEnd); - } - - private static bool IsTclConcatenatedScriptArgument( - TclLexicalFrame frame, - int wordIndex, - string? token) - { - if (frame.CommandName == "eval") - return wordIndex >= 1; - if (frame.CommandName == "after") - { - return wordIndex >= 2 - && frame.FirstArgument is not null - && frame.FirstArgument is not ("cancel" or "info"); - } - if (frame.CommandName == "namespace") - return wordIndex >= 3 && frame.FirstArgument == "eval"; - if (frame.CommandName != "uplevel") - return false; - if (frame.UplevelScriptWordIndex >= 0) - return wordIndex >= frame.UplevelScriptWordIndex; - if (wordIndex < 1) - return false; - - if (wordIndex == 1 && IsTclUplevelLevelToken(token)) - { - frame.UplevelScriptWordIndex = 2; - return false; - } - - frame.UplevelScriptWordIndex = wordIndex; - return true; - } - - private static bool IsTclUplevelLevelToken(string? token) - { - if (string.IsNullOrWhiteSpace(token)) - return false; - - var span = token.AsSpan().Trim(); - if (span.Length > 1 && span[0] == '#') - span = span[1..]; - if (span.IsEmpty) - return false; - - var start = span[0] is '+' or '-' ? 1 : 0; - if (start == span.Length) - return false; - for (var index = start; index < span.Length; index++) - { - if (!char.IsDigit(span[index])) - return false; - } - - return true; - } - - private static TclLexicalFrame CreateTclScriptFrame( - TclLexicalFrame owner, - char terminator, - bool concatenateArguments) - { - var frame = new TclLexicalFrame( - TclLexicalFrameKind.Script, - terminator, - concatenateArguments ? owner : null); - if (!concatenateArguments) - return frame; - - if (owner.ConcatenatedScriptState != null) - frame.CopyCommandStateFrom(owner.ConcatenatedScriptState); - // Tcl inserts a separating space while concatenating eval/uplevel arguments. - // eval/uplevel の引数連結では引数間に空白が入るため、次は word boundary。 - frame.WordStart = true; - return frame; - } - - private static void PersistTclConcatenatedScriptState(TclLexicalFrame frame) - { - if (frame.ConcatenationOwner is not { } owner) - return; - - owner.ConcatenatedScriptState ??= new TclLexicalFrame(TclLexicalFrameKind.Script); - owner.ConcatenatedScriptState.CopyCommandStateFrom(frame); - owner.ConcatenatedScriptState.WordStart = true; - } - - private static bool ProcessTclConcatenatedBareWord( - TclLexicalFrame owner, - string token) - { - owner.ConcatenatedScriptState ??= new TclLexicalFrame(TclLexicalFrameKind.Script); - var state = owner.ConcatenatedScriptState; - var isCommand = state.CommandStart; - var wordIndex = state.WordIndex++; - if (wordIndex == 0) - { - state.CommandName = token; - } - else - { - UpdateTclFirstArgument(state, wordIndex, token); - UpdateTclDictArgumentState(state, wordIndex, token); - UpdateTclSwitchArgumentState(state, wordIndex, token); - UpdateTclTryArgumentState( - state, - wordIndex, - token, - isScriptArgument: false); - } - - state.LastBareWord = token; - state.LastBareWordIndex = wordIndex; - state.CommandStart = false; - state.WordStart = false; - return isCommand; - } - - private static string? GetTclBracedWordToken( - IReadOnlyList lines, - int startLine, - int startColumn, - TclBraceEnd? braceEnd) - { - if (braceEnd is not { } end || end.Line != startLine) - return null; - var length = end.Column - startColumn - 1; - return length < 0 ? null : lines[startLine].Substring(startColumn + 1, length); - } - - private static string? GetTclQuotedWordToken(string line, int startColumn) - { - var endColumn = SkipQuotedToken(line, startColumn, '"'); - return endColumn <= startColumn + 1 - || endColumn > line.Length - || line[endColumn - 1] != '"' - ? null - : line.Substring(startColumn + 1, endColumn - startColumn - 2); - } - - private static string NormalizeTclQualifiedName(string name) - { - while (name.StartsWith("::", StringComparison.Ordinal)) - name = name[2..]; - return name; - } - - private static void UpdateTclFirstArgument( - TclLexicalFrame frame, - int wordIndex, - string? token) - { - if (wordIndex == 1 && token != null) - frame.FirstArgument = token; - } - - private static void UpdateTclDictArgumentState( - TclLexicalFrame frame, - int wordIndex, - string? token) - { - if (frame.CommandName == "dict" - && wordIndex == 1 - && token == "for") - { - frame.DictScriptWordIndex = wordIndex + 3; - } - } - - private static void MarkTclBareScriptCommandBoundary(char[] buffer, int commandColumn) - { - var boundaryColumn = commandColumn - 1; - if (boundaryColumn >= 0 && char.IsWhiteSpace(buffer[boundaryColumn])) - buffer[boundaryColumn] = ';'; - } - - private static bool IsTclSwitchTableArgument( - TclLexicalFrame frame, - int wordIndex, - IReadOnlyList lines, - TclBraceEnd? braceEnd) - { - return frame.CommandName == "switch" - && frame.SwitchStringWordIndex >= 0 - && wordIndex == frame.SwitchStringWordIndex + 1 - && braceEnd is { } end - && IsTclLastCommandWord(lines, end); - } - - private static void UpdateTclSwitchArgumentState( - TclLexicalFrame frame, - int wordIndex, - string token) - { - if (frame.CommandName != "switch" - || wordIndex == 0 - || frame.SwitchStringWordIndex >= 0) - { - return; - } - - if (frame.SwitchOptionValuePending) - { - frame.SwitchOptionValuePending = false; - return; - } - - if (!frame.SwitchOptionsEnded && token.StartsWith("-", StringComparison.Ordinal)) - { - if (token == "--") - frame.SwitchOptionsEnded = true; - else if (token is "-matchvar" or "-indexvar") - frame.SwitchOptionValuePending = true; - return; - } - - frame.SwitchStringWordIndex = wordIndex; - } - - private static void UpdateTclTryArgumentState( - TclLexicalFrame frame, - int wordIndex, - string token, - bool isScriptArgument) - { - if (frame.CommandName != "try") - return; - - if (isScriptArgument) - { - frame.TryClauseWordIndex = wordIndex + 1; - frame.TryScriptWordIndex = -1; - return; - } - - if (wordIndex != frame.TryClauseWordIndex) - return; - - frame.TryScriptWordIndex = token switch - { - "on" or "trap" => wordIndex + 3, - "finally" => wordIndex + 1, - _ => -1, - }; - } - - private static bool IsTclLastCommandWord( - IReadOnlyList lines, - TclBraceEnd braceEnd) - { - var line = lines[braceEnd.Line]; - for (var column = braceEnd.Column + 1; column < line.Length; column++) - { - if (char.IsWhiteSpace(line[column])) - continue; - return line[column] is ';' or ']' or '}'; - } - - return true; - } - - private static long GetTclPositionKey(int line, int column) => - ((long)line << 32) | (uint)column; - - private static IReadOnlyDictionary> BuildPrologGoalCalls( - IReadOnlyList lines, - IReadOnlyDictionary containersByLine, - IReadOnlySet callableNames) - { - var result = new Dictionary>(); - var frames = new Stack(); - var expectGoal = true; - SymbolRecord? activeContainer = null; - var scanningMultilineHead = false; - var multilineHeadParenthesisDepth = 0; - var multilineHeadParenthesesClosed = false; - var scanningDirective = false; - - for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) - { - var lineNumber = lineIndex + 1; - if (!containersByLine.TryGetValue(lineNumber, out var container)) - { - activeContainer = null; - scanningMultilineHead = false; - multilineHeadParenthesisDepth = 0; - multilineHeadParenthesesClosed = false; - if (!scanningDirective - && !StartsWithPrologGoalDirective(lines[lineIndex])) - { - frames.Clear(); - expectGoal = true; - continue; - } - - if (!scanningDirective) - { - frames.Clear(); - expectGoal = true; - scanningDirective = true; - } - - var directiveCalls = new List(); - ScanPrologGoalLine( - lines, - lineIndex, - lines[lineIndex], - callableNames, - frames, - ref expectGoal, - directiveCalls); - if (directiveCalls.Count > 0) - { - result[lineNumber] = directiveCalls - .Select(static call => call with { IsTopLevelDirective = true }) - .ToList(); - } - if (ContainsPrologClauseTerminator(lines[lineIndex])) - { - frames.Clear(); - expectGoal = true; - scanningDirective = false; - } - continue; - } - - scanningDirective = false; - if (activeContainer == null - || activeContainer.StartLine != container.StartLine - || !string.Equals(activeContainer.Name, container.Name, StringComparison.Ordinal)) - { - frames.Clear(); - activeContainer = container; - expectGoal = true; - multilineHeadParenthesisDepth = 0; - multilineHeadParenthesesClosed = false; - scanningMultilineHead = TryInitializePrologMultilineHeadScan( - lines, - container, - lineIndex, - ref multilineHeadParenthesisDepth, - ref multilineHeadParenthesesClosed); - } - - string callScanLine; - if (scanningMultilineHead) - { - var multilineHeadLine = lineNumber == container.StartLine - ? MaskLineBeforeColumn(lines[lineIndex], container.StartColumn ?? 0) - : lines[lineIndex]; - callScanLine = PreparePrologMultilineHeadScanLine( - multilineHeadLine, - ref multilineHeadParenthesisDepth, - ref multilineHeadParenthesesClosed, - out var headEnded); - scanningMultilineHead = !headEnded; - } - else - { - callScanLine = PreparePrologCallScanLine( - "prolog", - lines[lineIndex], - container.StartLine < lineNumber); - } - var lineCalls = new List(); - ScanPrologGoalLine( - lines, - lineIndex, - callScanLine, - callableNames, - frames, - ref expectGoal, - lineCalls); - if (lineCalls.Count > 0) - { - result[lineNumber] = lineCalls - .Select(call => IsTopLevelPrologDirectiveGoal(lines[lineIndex], call.Column) - ? call with { IsTopLevelDirective = true } - : call) - .ToList(); - } - - if (ContainsPrologClauseTerminator(callScanLine)) - { - frames.Clear(); - activeContainer = null; - expectGoal = true; - } - } - - return result; - } - - private static bool StartsWithPrologGoalDirective(string line) - { - var column = 0; - while (column < line.Length && char.IsWhiteSpace(line[column])) - column++; - return line.AsSpan(column).StartsWith(":-", StringComparison.Ordinal); - } - - private static IReadOnlySet BuildPrologDirectiveLines( - IReadOnlyList lines) - { - var directiveLines = new HashSet(); - var scanningDirective = false; - for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++) - { - if (!scanningDirective && !StartsWithPrologGoalDirective(lines[lineIndex])) - continue; - - scanningDirective = true; - directiveLines.Add(lineIndex + 1); - if (ContainsPrologClauseTerminator(lines[lineIndex])) - scanningDirective = false; - } - - return directiveLines; - } - - private static bool IsTopLevelPrologDirectiveGoal(string line, int goalColumn) - { - var segmentStartColumn = 0; - for (var column = 0; column < goalColumn; column++) - { - if (IsPrologClauseTerminator(line, column)) - segmentStartColumn = column + 1; - } - - segmentStartColumn = SkipWhitespace(line, segmentStartColumn); - return segmentStartColumn + 2 <= goalColumn - && line.AsSpan(segmentStartColumn).StartsWith(":-", StringComparison.Ordinal); - } - - private static bool TryInitializePrologMultilineHeadScan( - IReadOnlyList lines, - SymbolRecord container, - int currentLineIndex, - ref int parenthesisDepth, - ref bool parenthesesClosed) - { - var startLineIndex = container.StartLine - 1; - if (startLineIndex < 0 || startLineIndex >= lines.Count || startLineIndex > currentLineIndex) - return false; - - var startColumn = Math.Clamp( - container.StartColumn ?? 0, - 0, - lines[startLineIndex].Length); - var headLine = lines[startLineIndex][startColumn..]; - var multilineHeadMatch = PrologMultilineHeadRegex.Match(headLine); - if (PrologHeadRegex.IsMatch(headLine) || !multilineHeadMatch.Success) - return false; - parenthesesClosed = !multilineHeadMatch.Groups["open"].Success; - - for (var lineIndex = startLineIndex; lineIndex < currentLineIndex; lineIndex++) - { - var line = lineIndex == startLineIndex - ? MaskLineBeforeColumn(lines[lineIndex], startColumn) - : lines[lineIndex]; - _ = PreparePrologMultilineHeadScanLine( - line, - ref parenthesisDepth, - ref parenthesesClosed, - out var headEnded); - if (headEnded) - return false; - } - - return true; - } - - private static string MaskLineBeforeColumn(string line, int startColumn) - { - startColumn = Math.Clamp(startColumn, 0, line.Length); - if (startColumn == 0) - return line; - - var masked = line.ToCharArray(); - FillWithSpaces(masked, 0, startColumn); - return new string(masked); - } - - private static string PreparePrologMultilineHeadScanLine( - string line, - ref int parenthesisDepth, - ref bool parenthesesClosed, - out bool headEnded) - { - headEnded = false; - for (var column = 0; column < line.Length; column++) - { - var ch = line[column]; - if (ch is '\'' or '"') - { - column = SkipQuotedToken(line, column, ch) - 1; - continue; - } - - if (!parenthesesClosed) - { - if (ch == '(') - { - parenthesisDepth++; - } - else if (ch == ')' && parenthesisDepth > 0) - { - parenthesisDepth--; - parenthesesClosed = parenthesisDepth == 0; - } - continue; - } - - if (line.AsSpan(column).StartsWith("-->", StringComparison.Ordinal)) - { - var masked = line.ToCharArray(); - FillWithSpaces(masked, 0, column + 3); - headEnded = true; - return new string(masked); - } - if (line.AsSpan(column).StartsWith(":-", StringComparison.Ordinal)) - { - var masked = line.ToCharArray(); - FillWithSpaces(masked, 0, column + 2); - headEnded = true; - return new string(masked); - } - if (IsPrologClauseTerminator(line, column)) - { - headEnded = true; - return new string(' ', line.Length); - } - } - - return new string(' ', line.Length); - } - - private static void ScanPrologGoalLine( - IReadOnlyList lines, - int lineIndex, - string line, - IReadOnlySet callableNames, - Stack frames, - ref bool expectGoal, - List calls) - { - for (var column = 0; column < line.Length;) - { - var ch = line[column]; - if (char.IsWhiteSpace(ch)) - { - column++; - continue; - } - - if (ch is '\'' or '"') - { - column = SkipQuotedToken(line, column, ch); - if (expectGoal) - expectGoal = false; - continue; - } - if (IsPrologClauseTerminator(line, column)) - { - frames.Clear(); - expectGoal = true; - column++; - continue; - } - - if (expectGoal) - { - if (line.AsSpan(column).StartsWith("-->", StringComparison.Ordinal)) - { - column += 3; - continue; - } - if (line.AsSpan(column).StartsWith(":-", StringComparison.Ordinal) - || line.AsSpan(column).StartsWith(@"\+", StringComparison.Ordinal)) - { - column += 2; - continue; - } - if (ch is ',' or ';') - { - column++; - continue; - } - if (line.AsSpan(column).StartsWith("->", StringComparison.Ordinal)) - { - column += 2; - continue; - } - if (ch == '(') - { - frames.Push(new PrologLexicalFrame(PrologLexicalFrameKind.GoalGroup)); - column++; - continue; - } - if (ch == '{') - { - frames.Push(new PrologLexicalFrame( - PrologLexicalFrameKind.GoalGroup, - terminator: '}')); - column++; - continue; - } - if (ch == '[') - { - frames.Push(new PrologLexicalFrame( - PrologLexicalFrameKind.TermGroup, - terminator: ']')); - expectGoal = false; - column++; - continue; - } - if (ch == '!') - { - expectGoal = false; - column++; - continue; - } - if (char.IsLower(ch)) - { - var nameStart = column; - column++; - while (column < line.Length - && (char.IsLetterOrDigit(line[column]) || line[column] == '_')) - { - column++; - } - - var name = line[nameStart..column]; - var nextColumn = column; - while (nextColumn < line.Length && char.IsWhiteSpace(line[nextColumn])) - nextColumn++; - if (nextColumn < line.Length - && line[nextColumn] == ':' - && (nextColumn + 1 >= line.Length || line[nextColumn + 1] != '-')) - { - column = nextColumn + 1; - expectGoal = true; - continue; - } - if (callableNames.Contains(name) - && !IsPrologTermBeforeInfixOperator( - lines, - lineIndex, - column, - nextColumn)) - { - calls.Add(new PrologGoalCall(name, nameStart)); - } - - if (nextColumn < line.Length && line[nextColumn] == '(') - { - if (PrologMetaGoalArguments.TryGetValue(name, out var goalArgumentIndices)) - { - var metaFrame = new PrologLexicalFrame( - PrologLexicalFrameKind.MetaArguments, - goalArgumentIndices); - frames.Push(metaFrame); - expectGoal = metaFrame.CurrentArgumentIsGoal; - } - else - { - frames.Push(new PrologLexicalFrame( - PrologLexicalFrameKind.PredicateArguments)); - expectGoal = false; - } - - column = nextColumn + 1; - } - else - { - expectGoal = false; - } - - continue; - } - - expectGoal = false; - column++; - continue; - } - - if (ch == '(') - { - frames.Push(new PrologLexicalFrame(PrologLexicalFrameKind.PredicateArguments)); - column++; - continue; - } - if (ch is '[' or '{') - { - frames.Push(new PrologLexicalFrame( - PrologLexicalFrameKind.TermGroup, - terminator: ch == '[' ? ']' : '}')); - column++; - continue; - } - if (ch is ')' or ']' or '}') - { - if (frames.TryPeek(out var closingFrame) - && closingFrame.Terminator == ch) - { - frames.Pop(); - } - expectGoal = false; - column++; - continue; - } - if (ch == ',') - { - if (frames.TryPeek(out var frame) - && frame.Kind == PrologLexicalFrameKind.MetaArguments) - { - frame.ArgumentIndex++; - expectGoal = frame.CurrentArgumentIsGoal; - } - else if (CanStartNextPrologGoal(frames)) - { - expectGoal = true; - } - - column++; - continue; - } - if (ch == ';' - || line.AsSpan(column).StartsWith("->", StringComparison.Ordinal)) - { - if (CanStartNextPrologGoal(frames)) - expectGoal = true; - column += ch == ';' ? 1 : 2; - continue; - } - - column++; - } - } - - private static bool IsPrologTermBeforeInfixOperator( - IReadOnlyList lines, - int lineIndex, - int nameEndColumn, - int nextColumn) - { - const int lookaheadLineLimit = 256; - var line = lines[lineIndex]; - var afterTermLine = lineIndex; - var afterTermColumn = nextColumn; - if (nextColumn < line.Length && line[nextColumn] == '(') - { - var depth = 0; - var termClosed = false; - var endLineExclusive = Math.Min(lines.Count, lineIndex + lookaheadLineLimit); - for (var scanLineIndex = lineIndex; - scanLineIndex < endLineExclusive && !termClosed; - scanLineIndex++) - { - var scanLine = lines[scanLineIndex]; - var startColumn = scanLineIndex == lineIndex ? nextColumn : 0; - for (var column = startColumn; column < scanLine.Length; column++) - { - var ch = scanLine[column]; - if (ch is '\'' or '"') - { - column = SkipQuotedToken(scanLine, column, ch) - 1; - continue; - } - - if (ch == '(') - { - depth++; - } - else if (ch == ')' && --depth == 0) - { - afterTermLine = scanLineIndex; - afterTermColumn = column + 1; - termClosed = true; - break; - } - } - } - - // An unterminated compound term is not authoritative evidence of a call. - // 未終端の compound term は call と判断できる根拠にならない。 - if (!termClosed) - return true; - } - else - { - afterTermColumn = nameEndColumn; - } - - if (!TryFindNextPrologToken( - lines, - afterTermLine, - afterTermColumn, - lookaheadLineLimit, - out var operatorLine, - out var operatorColumn)) - { - return false; - } - - var operatorSourceLine = lines[operatorLine]; - var remaining = operatorSourceLine.AsSpan(operatorColumn); - if (remaining.StartsWith("->", StringComparison.Ordinal) - || remaining.StartsWith("*->", StringComparison.Ordinal)) - { - return false; - } - - if (operatorSourceLine[operatorColumn] is '=' or '\\' or '<' or '>' or '@' or '#' - or ':' or '+' or '-' or '*' or '/' or '^') - { - return true; - } - - foreach (var operatorName in PrologInfixOperatorNames) - { - if (!remaining.StartsWith(operatorName, StringComparison.Ordinal)) - continue; - var operatorEnd = operatorColumn + operatorName.Length; - if (operatorEnd >= operatorSourceLine.Length - || !char.IsLetterOrDigit(operatorSourceLine[operatorEnd]) - && operatorSourceLine[operatorEnd] != '_') - { - return true; - } - } - - return false; - } - - private static bool TryFindNextPrologToken( - IReadOnlyList lines, - int startLine, - int startColumn, - int lookaheadLineLimit, - out int tokenLine, - out int tokenColumn) - { - var endLineExclusive = Math.Min(lines.Count, startLine + lookaheadLineLimit); - for (var lineIndex = startLine; lineIndex < endLineExclusive; lineIndex++) - { - var line = lines[lineIndex]; - var column = lineIndex == startLine ? startColumn : 0; - while (column < line.Length && char.IsWhiteSpace(line[column])) - column++; - if (column < line.Length) - { - tokenLine = lineIndex; - tokenColumn = column; - return true; - } - } - - tokenLine = -1; - tokenColumn = -1; - return false; - } - - private static readonly string[] PrologInfixOperatorNames = - ["is", "mod", "rem", "xor", "div", "rdiv"]; - - private static bool CanStartNextPrologGoal( - IEnumerable frames) - { - foreach (var frame in frames) - { - if (frame.Kind == PrologLexicalFrameKind.PredicateArguments) - return false; - if (frame.Kind == PrologLexicalFrameKind.TermGroup) - return false; - if (frame.Kind == PrologLexicalFrameKind.MetaArguments) - return frame.CurrentArgumentIsGoal; - } - - return true; - } - - private static bool ContainsPrologClauseTerminator(string line) - { - for (var column = 0; column < line.Length; column++) - { - if (IsPrologClauseTerminator(line, column)) - return true; - } - - return false; - } - - private static void AddPrologContainers( - IReadOnlyList lines, - IReadOnlyList symbols, - Dictionary containersByLine, - Dictionary> declarationsByLine) - { - foreach (var symbol in symbols) - { - if (symbol.Kind != "function" || symbol.StartLine < 1 || symbol.StartLine > lines.Count) - continue; - - var startLineIndex = symbol.StartLine - 1; - var startColumn = Math.Clamp( - symbol.StartColumn ?? 0, - 0, - lines[startLineIndex].Length); - var headLine = lines[startLineIndex][startColumn..]; - var headMatch = PrologHeadRegex.Match(headLine); - if (!headMatch.Success) - headMatch = PrologMultilineHeadRegex.Match(headLine); - if (!headMatch.Success - || !string.Equals(headMatch.Groups["name"].Value, symbol.Name, StringComparison.Ordinal)) - { - continue; - } - - if (!declarationsByLine.TryGetValue(symbol.StartLine, out var declarations)) - { - declarations = []; - declarationsByLine[symbol.StartLine] = declarations; - } - declarations.Add(symbol); - - var endLineIndex = FindPrologClauseEnd(lines, startLineIndex, startColumn); - for (var lineIndex = startLineIndex; lineIndex <= endLineIndex; lineIndex++) - containersByLine.TryAdd(lineIndex + 1, symbol); - } - - foreach (var declarations in declarationsByLine.Values) - { - declarations.Sort(static (left, right) => - (left.StartColumn ?? 0).CompareTo(right.StartColumn ?? 0)); - } - } - - private static int FindPrologClauseEnd( - IReadOnlyList lines, - int startLineIndex, - int startColumn) - { - for (var lineIndex = startLineIndex; lineIndex < lines.Count; lineIndex++) - { - var line = lines[lineIndex]; - var firstColumn = lineIndex == startLineIndex ? startColumn : 0; - for (var column = firstColumn; column < line.Length; column++) - { - if (IsPrologClauseTerminator(line, column)) - return lineIndex; - } - } - - return startLineIndex; - } } diff --git a/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Clojure.cs b/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Clojure.cs new file mode 100644 index 000000000..ed83497c3 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Clojure.cs @@ -0,0 +1,239 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static void EmitClojureReferences( + long fileId, + string line, + string context, + int lineNumber, + SymbolRecord? typeDefinition, + SymbolRecord? container, + List references, + ReferenceDedupeSet seen, + FunctionalReferenceState state) + { + if (line.Contains(":require", StringComparison.Ordinal)) + state.ClojureRequireMode = true; + + if (state.ClojureRequireMode) + { + EmitClojureRequireEntries(); + if (line.Contains(')')) + { + state.ClojureRequireMode = false; + state.ClojureRequireBracketDepth = 0; + } + } + + var relationMatch = ClojureTypeRelationRegex.Match(line); + if (relationMatch.Success) + { + state.ClojureTypeBodyMode = true; + state.ClojureTypeBodyBaseDepth = state.ClojureParenDepth; + state.ClojureActiveTypeDefinition = typeDefinition; + var typeContainer = typeDefinition ?? container; + var types = relationMatch.Groups["types"]; + foreach (Match typeMatch in Regex.Matches( + types.Value, + @"(?[A-Z][\w.*+!?<>=-]*)", + RegexOptions.CultureInvariant, + ExtractionRegexTimeout)) + { + AddReference( + references, + seen, + fileId, + typeMatch.Groups["name"].Value, + types.Index + typeMatch.Groups["name"].Index, + "type_reference", + context, + lineNumber, + typeContainer, + "clojure"); + } + } + + var isProtocolHeader = Regex.IsMatch( + line, + @"^\s*\(\s*defprotocol\b", + RegexOptions.CultureInvariant, + ExtractionRegexTimeout); + if (isProtocolHeader) + { + state.ClojureProtocolMode = true; + state.ClojureProtocolBaseDepth = state.ClojureParenDepth; + } + + if (state.ClojureProtocolMode + || Regex.IsMatch( + line, + @"^\s*\(\s*(?:ns|defrecord|deftype|extend-type)\b", + RegexOptions.CultureInvariant, + ExtractionRegexTimeout)) + { + return; + } + + var callLine = MaskClojureSuppressedForms(line, state); + var methodHeader = state.ClojureTypeBodyMode + && state.ClojureParenDepth == state.ClojureTypeBodyBaseDepth + 1 + ? ClojureCallHeadRegex.Match(callLine) + : Match.Empty; + foreach (Match match in ClojureCallHeadRegex.Matches(callLine)) + { + var fullName = match.Groups["name"].Value; + var separator = fullName.LastIndexOf('/'); + var name = separator >= 0 ? fullName[(separator + 1)..] : fullName; + if (ClojureIgnoredCallHeads.Contains(name)) + continue; + if (methodHeader.Success + && match.Groups["name"].Index == methodHeader.Groups["name"].Index) + { + continue; + } + + AddReference( + references, + seen, + fileId, + name, + match.Groups["name"].Index + Math.Max(0, separator + 1), + "call", + context, + lineNumber, + state.ClojureActiveTypeDefinition ?? container, + "clojure"); + } + + void EmitClojureRequireEntries() + { + for (var index = 0; index < line.Length; index++) + { + if (line[index] == '[') + { + if (state.ClojureRequireBracketDepth == 0) + { + var match = ClojureRequireEntryRegex.Match(line, index); + if (match.Success && match.Index == index) + { + AddFunctionalReference( + references, + seen, + fileId, + match.Groups["name"], + "import", + context, + lineNumber, + container, + "clojure"); + if (match.Groups["alias"].Success) + { + AddFunctionalReference( + references, + seen, + fileId, + match.Groups["name"], + "alias", + context, + lineNumber, + container, + "clojure"); + } + } + } + + state.ClojureRequireBracketDepth++; + } + else if (line[index] == ']' && state.ClojureRequireBracketDepth > 0) + { + state.ClojureRequireBracketDepth--; + } + } + } + } + + private static string MaskClojureSuppressedForms(string line, FunctionalReferenceState state) + { + var masked = line.ToCharArray(); + for (var index = 0; index < masked.Length; index++) + { + if (state.ClojureSuppressedFormDepth > 0) + { + masked[index] = ' '; + UpdateDepth(line[index]); + continue; + } + + var isNamedSuppressedForm = + (line.IndexOf("(quote", index, StringComparison.Ordinal) == index + && IsClojureFormBoundary(line, index + 6)) + || (line.IndexOf("(comment", index, StringComparison.Ordinal) == index + && IsClojureFormBoundary(line, index + 8)); + if (isNamedSuppressedForm) + { + state.ClojureSuppressedFormDepth = 1; + masked[index] = ' '; + continue; + } + + var prefixLength = line[index] == '\'' + ? 1 + : line[index] == '#' && index + 1 < line.Length && line[index + 1] == '_' + ? 2 + : 0; + if (prefixLength == 0 || !IsClojureReaderPrefixPosition(line, index)) + continue; + + var formStart = index + prefixLength; + while (formStart < line.Length && char.IsWhiteSpace(line[formStart])) + formStart++; + Array.Fill(masked, ' ', index, formStart - index); + if (formStart >= line.Length) + continue; + + if (line[formStart] is '(' or '[' or '{') + { + state.ClojureSuppressedFormDepth = 1; + masked[formStart] = ' '; + index = formStart; + continue; + } + + var tokenEnd = formStart; + while (tokenEnd < line.Length + && !char.IsWhiteSpace(line[tokenEnd]) + && line[tokenEnd] is not ('(' or ')' or '[' or ']' or '{' or '}' or ',' or ';')) + { + masked[tokenEnd++] = ' '; + } + index = Math.Max(index, tokenEnd - 1); + } + + return new string(masked); + + void UpdateDepth(char character) + { + if (character is '(' or '[' or '{') + state.ClojureSuppressedFormDepth++; + else if (character is ')' or ']' or '}') + state.ClojureSuppressedFormDepth--; + } + } + + private static bool IsClojureReaderPrefixPosition(string line, int index) + => index == 0 + || char.IsWhiteSpace(line[index - 1]) + || line[index - 1] is '(' or '[' or '{' or ','; + + private static bool IsClojureFormBoundary(string line, int index) + => index >= line.Length + || char.IsWhiteSpace(line[index]) + || line[index] is '(' or '[' or '{' or ')' or ']' or '}'; + +} diff --git a/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Erlang.cs b/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Erlang.cs new file mode 100644 index 000000000..36f1b1219 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Erlang.cs @@ -0,0 +1,101 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static void EmitErlangReferences( + long fileId, + string line, + string context, + int lineNumber, + SymbolRecord? container, + List references, + ReferenceDedupeSet seen, + FunctionalReferenceState state) + { + AddFunctionalMatchReference(ErlangImportRegex.Match(line), "import"); + AddFunctionalMatchReference(ErlangBehaviourRegex.Match(line), "type_reference"); + if (ErlangSpecificationAttributeRegex.IsMatch(line)) + state.ErlangSpecificationMode = true; + if (state.ErlangSpecificationMode) + { + if (line.TrimEnd().EndsWith(".", StringComparison.Ordinal)) + state.ErlangSpecificationMode = false; + return; + } + + var quotedAtomSpans = GetErlangQuotedAtomSpans(line); + var remoteCallSpans = new List<(int Start, int End)>(); + foreach (Match match in ErlangRemoteCallRegex.Matches(line)) + { + if (IsInsideQuotedAtom(match.Index)) + continue; + remoteCallSpans.Add((match.Index, match.Index + match.Length)); + AddFunctionalReference(references, seen, fileId, match.Groups["module"], "reference", context, lineNumber, container, "erlang"); + AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "erlang"); + } + + var definitionMatch = ErlangFunctionDefinitionRegex.Match(line); + foreach (Match match in ErlangLocalCallRegex.Matches(line)) + { + if (remoteCallSpans.Any(span => match.Index >= span.Start && match.Index < span.End)) + continue; + if (IsInsideQuotedAtom(match.Groups["name"].Index)) + continue; + + var name = match.Groups["name"].Value; + if (ErlangIgnoredCalls.Contains(name)) + continue; + + if (definitionMatch.Success + && match.Groups["name"].Index == definitionMatch.Groups["name"].Index) + { + continue; + } + + AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "erlang"); + } + + void AddFunctionalMatchReference(Match match, string kind) + { + if (match.Success) + AddFunctionalReference(references, seen, fileId, match.Groups["name"], kind, context, lineNumber, container, "erlang"); + } + + bool IsInsideQuotedAtom(int index) + => quotedAtomSpans.Any(span => index > span.Start && index < span.End); + } + + private static List<(int Start, int End)> GetErlangQuotedAtomSpans(string line) + { + var spans = new List<(int Start, int End)>(); + for (var index = 0; index < line.Length; index++) + { + if (line[index] != '\'') + continue; + + var start = index; + for (index++; index < line.Length; index++) + { + if (line[index] == '\\' && index + 1 < line.Length) + { + index++; + continue; + } + + if (line[index] != '\'') + continue; + + spans.Add((start, index + 1)); + break; + } + } + + return spans; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Ocaml.cs b/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Ocaml.cs new file mode 100644 index 000000000..a011afc47 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Ocaml.cs @@ -0,0 +1,128 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static void EmitOcamlReferences( + long fileId, + string line, + string context, + int lineNumber, + SymbolRecord? definition, + SymbolRecord? typeDefinition, + SymbolRecord? container, + List references, + ReferenceDedupeSet seen, + FunctionalReferenceState state) + { + if (state.OcamlTypeDeclarationMode + && Regex.IsMatch( + line, + @"^\s*(?:let|module|class|exception|external|open|include)\b", + RegexOptions.CultureInvariant, + ExtractionRegexTimeout)) + { + state.OcamlTypeDeclarationMode = false; + state.OcamlActiveTypeDefinition = null; + } + + var startsTypeDeclaration = Regex.IsMatch( + line, + @"^\s*type\b", + RegexOptions.CultureInvariant, + ExtractionRegexTimeout); + if (startsTypeDeclaration) + { + state.OcamlTypeDeclarationMode = true; + state.OcamlActiveTypeDefinition = typeDefinition; + } + + var typeReferenceSpans = new List<(int Start, int End)>(); + AddMatch(OcamlImportRegex.Match(line), "import"); + AddMatch(OcamlModuleAliasRegex.Match(line), "alias"); + var typeAliasTarget = OcamlTypeAliasTargetRegex.Match(line); + if (typeAliasTarget.Success) + { + AddOcamlTypeReference(typeAliasTarget.Groups["name"]); + } + foreach (Match match in OcamlTypeReferenceRegex.Matches(line)) + AddOcamlTypeReference(match.Groups["name"]); + if (state.OcamlTypeDeclarationMode) + return; + + if (Regex.IsMatch( + line, + @"^\s*(?:module|type|class|open|include|val|external)\b", + RegexOptions.CultureInvariant, + ExtractionRegexTimeout)) + { + return; + } + + var qualifiedCallSpans = new List<(int Start, int End)>(typeReferenceSpans); + foreach (Match match in OcamlQualifiedCallRegex.Matches(line)) + { + if (qualifiedCallSpans.Any(span => RangesOverlap(span.Start, span.End, match.Index, match.Index + match.Length))) + continue; + qualifiedCallSpans.Add((match.Index, match.Index + match.Length)); + AddFunctionalReference(references, seen, fileId, match.Groups["module"], "reference", context, lineNumber, container, "ocaml"); + AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "ocaml"); + } + + var skippedDefinition = false; + foreach (Match match in OcamlBareCallRegex.Matches(line)) + { + if (qualifiedCallSpans.Any(span => match.Index >= span.Start && match.Index < span.End)) + continue; + + var name = match.Groups["name"].Value; + if (OcamlIgnoredCalls.Contains(name)) + continue; + + if (!skippedDefinition + && definition != null + && string.Equals(definition.Name, name, StringComparison.Ordinal)) + { + skippedDefinition = true; + continue; + } + + AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "ocaml"); + } + + void AddMatch(Match match, string kind) + { + if (match.Success) + AddFunctionalReference(references, seen, fileId, match.Groups["name"], kind, context, lineNumber, container, "ocaml"); + } + + void AddOcamlTypeReference(Group group) + { + if (!group.Success) + return; + + typeReferenceSpans.Add((group.Index, group.Index + group.Length)); + if (!OcamlIgnoredTypeReferences.Contains(group.Value)) + { + AddFunctionalReference( + references, + seen, + fileId, + group, + "type_reference", + context, + lineNumber, + typeDefinition ?? state.OcamlActiveTypeDefinition ?? container, + "ocaml"); + } + } + + static bool RangesOverlap(int leftStart, int leftEnd, int rightStart, int rightEnd) + => leftStart < rightEnd && rightStart < leftEnd; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Raku.cs b/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Raku.cs new file mode 100644 index 000000000..a6c808fa5 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.Raku.cs @@ -0,0 +1,100 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static void EmitRakuReferences( + long fileId, + string line, + string context, + int lineNumber, + SymbolRecord? definition, + SymbolRecord? typeDefinition, + SymbolRecord? container, + List references, + ReferenceDedupeSet seen) + { + var importMatch = RakuImportRegex.Match(line); + if (importMatch.Success) + { + AddFunctionalReference(references, seen, fileId, importMatch.Groups["name"], "import", context, lineNumber, container, "raku"); + if (importMatch.Groups["angleAlias"].Success || importMatch.Groups["alias"].Success) + AddFunctionalReference(references, seen, fileId, importMatch.Groups["name"], "alias", context, lineNumber, container, "raku"); + } + + if (typeDefinition != null) + { + foreach (Match match in RakuTypeRelationRegex.Matches(line)) + AddFunctionalReference(references, seen, fileId, match.Groups["name"], "type_reference", context, lineNumber, typeDefinition, "raku"); + } + foreach (Match match in RakuReturnTypeRegex.Matches(line)) + AddFunctionalReference(references, seen, fileId, match.Groups["name"], "type_reference", context, lineNumber, container, "raku"); + if (typeDefinition != null) + return; + + var qualifiedCallSpans = new List<(int Start, int End)>(); + foreach (Match match in RakuQualifiedCallRegex.Matches(line)) + { + qualifiedCallSpans.Add((match.Index, match.Index + match.Length)); + AddFunctionalReference(references, seen, fileId, match.Groups["module"], "reference", context, lineNumber, container, "raku"); + AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "raku"); + } + foreach (Match match in RakuMethodCallRegex.Matches(line)) + { + qualifiedCallSpans.Add((match.Index, match.Index + match.Length)); + AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "raku"); + } + + var skippedDefinition = false; + foreach (Match match in RakuBareCallRegex.Matches(line)) + { + if (qualifiedCallSpans.Any(span => match.Index >= span.Start && match.Index < span.End)) + continue; + + var name = match.Groups["name"].Value; + if (RakuIgnoredCalls.Contains(name)) + continue; + + if (!skippedDefinition + && definition != null + && string.Equals(definition.Name, name, StringComparison.Ordinal)) + { + skippedDefinition = true; + continue; + } + + AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "raku"); + } + } + + private static void AddFunctionalReference( + List references, + ReferenceDedupeSet seen, + long fileId, + Group group, + string referenceKind, + string context, + int lineNumber, + SymbolRecord? container, + string language) + { + if (!group.Success || ReferenceLimitReached(references)) + return; + + AddReference( + references, + seen, + fileId, + group.Value, + group.Index, + referenceKind, + context, + lineNumber, + container, + language); + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.cs index 806374b65..4f7de8e5d 100644 --- a/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/FunctionalLanguageReferenceExtractor.cs @@ -770,532 +770,4 @@ private static bool TryReadRakuQuotePrefix( return true; } - private static void EmitClojureReferences( - long fileId, - string line, - string context, - int lineNumber, - SymbolRecord? typeDefinition, - SymbolRecord? container, - List references, - ReferenceDedupeSet seen, - FunctionalReferenceState state) - { - if (line.Contains(":require", StringComparison.Ordinal)) - state.ClojureRequireMode = true; - - if (state.ClojureRequireMode) - { - EmitClojureRequireEntries(); - if (line.Contains(')')) - { - state.ClojureRequireMode = false; - state.ClojureRequireBracketDepth = 0; - } - } - - var relationMatch = ClojureTypeRelationRegex.Match(line); - if (relationMatch.Success) - { - state.ClojureTypeBodyMode = true; - state.ClojureTypeBodyBaseDepth = state.ClojureParenDepth; - state.ClojureActiveTypeDefinition = typeDefinition; - var typeContainer = typeDefinition ?? container; - var types = relationMatch.Groups["types"]; - foreach (Match typeMatch in Regex.Matches( - types.Value, - @"(?[A-Z][\w.*+!?<>=-]*)", - RegexOptions.CultureInvariant, - ExtractionRegexTimeout)) - { - AddReference( - references, - seen, - fileId, - typeMatch.Groups["name"].Value, - types.Index + typeMatch.Groups["name"].Index, - "type_reference", - context, - lineNumber, - typeContainer, - "clojure"); - } - } - - var isProtocolHeader = Regex.IsMatch( - line, - @"^\s*\(\s*defprotocol\b", - RegexOptions.CultureInvariant, - ExtractionRegexTimeout); - if (isProtocolHeader) - { - state.ClojureProtocolMode = true; - state.ClojureProtocolBaseDepth = state.ClojureParenDepth; - } - - if (state.ClojureProtocolMode - || Regex.IsMatch( - line, - @"^\s*\(\s*(?:ns|defrecord|deftype|extend-type)\b", - RegexOptions.CultureInvariant, - ExtractionRegexTimeout)) - { - return; - } - - var callLine = MaskClojureSuppressedForms(line, state); - var methodHeader = state.ClojureTypeBodyMode - && state.ClojureParenDepth == state.ClojureTypeBodyBaseDepth + 1 - ? ClojureCallHeadRegex.Match(callLine) - : Match.Empty; - foreach (Match match in ClojureCallHeadRegex.Matches(callLine)) - { - var fullName = match.Groups["name"].Value; - var separator = fullName.LastIndexOf('/'); - var name = separator >= 0 ? fullName[(separator + 1)..] : fullName; - if (ClojureIgnoredCallHeads.Contains(name)) - continue; - if (methodHeader.Success - && match.Groups["name"].Index == methodHeader.Groups["name"].Index) - { - continue; - } - - AddReference( - references, - seen, - fileId, - name, - match.Groups["name"].Index + Math.Max(0, separator + 1), - "call", - context, - lineNumber, - state.ClojureActiveTypeDefinition ?? container, - "clojure"); - } - - void EmitClojureRequireEntries() - { - for (var index = 0; index < line.Length; index++) - { - if (line[index] == '[') - { - if (state.ClojureRequireBracketDepth == 0) - { - var match = ClojureRequireEntryRegex.Match(line, index); - if (match.Success && match.Index == index) - { - AddFunctionalReference( - references, - seen, - fileId, - match.Groups["name"], - "import", - context, - lineNumber, - container, - "clojure"); - if (match.Groups["alias"].Success) - { - AddFunctionalReference( - references, - seen, - fileId, - match.Groups["name"], - "alias", - context, - lineNumber, - container, - "clojure"); - } - } - } - - state.ClojureRequireBracketDepth++; - } - else if (line[index] == ']' && state.ClojureRequireBracketDepth > 0) - { - state.ClojureRequireBracketDepth--; - } - } - } - } - - private static string MaskClojureSuppressedForms(string line, FunctionalReferenceState state) - { - var masked = line.ToCharArray(); - for (var index = 0; index < masked.Length; index++) - { - if (state.ClojureSuppressedFormDepth > 0) - { - masked[index] = ' '; - UpdateDepth(line[index]); - continue; - } - - var isNamedSuppressedForm = - (line.IndexOf("(quote", index, StringComparison.Ordinal) == index - && IsClojureFormBoundary(line, index + 6)) - || (line.IndexOf("(comment", index, StringComparison.Ordinal) == index - && IsClojureFormBoundary(line, index + 8)); - if (isNamedSuppressedForm) - { - state.ClojureSuppressedFormDepth = 1; - masked[index] = ' '; - continue; - } - - var prefixLength = line[index] == '\'' - ? 1 - : line[index] == '#' && index + 1 < line.Length && line[index + 1] == '_' - ? 2 - : 0; - if (prefixLength == 0 || !IsClojureReaderPrefixPosition(line, index)) - continue; - - var formStart = index + prefixLength; - while (formStart < line.Length && char.IsWhiteSpace(line[formStart])) - formStart++; - Array.Fill(masked, ' ', index, formStart - index); - if (formStart >= line.Length) - continue; - - if (line[formStart] is '(' or '[' or '{') - { - state.ClojureSuppressedFormDepth = 1; - masked[formStart] = ' '; - index = formStart; - continue; - } - - var tokenEnd = formStart; - while (tokenEnd < line.Length - && !char.IsWhiteSpace(line[tokenEnd]) - && line[tokenEnd] is not ('(' or ')' or '[' or ']' or '{' or '}' or ',' or ';')) - { - masked[tokenEnd++] = ' '; - } - index = Math.Max(index, tokenEnd - 1); - } - - return new string(masked); - - void UpdateDepth(char character) - { - if (character is '(' or '[' or '{') - state.ClojureSuppressedFormDepth++; - else if (character is ')' or ']' or '}') - state.ClojureSuppressedFormDepth--; - } - } - - private static bool IsClojureReaderPrefixPosition(string line, int index) - => index == 0 - || char.IsWhiteSpace(line[index - 1]) - || line[index - 1] is '(' or '[' or '{' or ','; - - private static bool IsClojureFormBoundary(string line, int index) - => index >= line.Length - || char.IsWhiteSpace(line[index]) - || line[index] is '(' or '[' or '{' or ')' or ']' or '}'; - - private static void EmitErlangReferences( - long fileId, - string line, - string context, - int lineNumber, - SymbolRecord? container, - List references, - ReferenceDedupeSet seen, - FunctionalReferenceState state) - { - AddFunctionalMatchReference(ErlangImportRegex.Match(line), "import"); - AddFunctionalMatchReference(ErlangBehaviourRegex.Match(line), "type_reference"); - if (ErlangSpecificationAttributeRegex.IsMatch(line)) - state.ErlangSpecificationMode = true; - if (state.ErlangSpecificationMode) - { - if (line.TrimEnd().EndsWith(".", StringComparison.Ordinal)) - state.ErlangSpecificationMode = false; - return; - } - - var quotedAtomSpans = GetErlangQuotedAtomSpans(line); - var remoteCallSpans = new List<(int Start, int End)>(); - foreach (Match match in ErlangRemoteCallRegex.Matches(line)) - { - if (IsInsideQuotedAtom(match.Index)) - continue; - remoteCallSpans.Add((match.Index, match.Index + match.Length)); - AddFunctionalReference(references, seen, fileId, match.Groups["module"], "reference", context, lineNumber, container, "erlang"); - AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "erlang"); - } - - var definitionMatch = ErlangFunctionDefinitionRegex.Match(line); - foreach (Match match in ErlangLocalCallRegex.Matches(line)) - { - if (remoteCallSpans.Any(span => match.Index >= span.Start && match.Index < span.End)) - continue; - if (IsInsideQuotedAtom(match.Groups["name"].Index)) - continue; - - var name = match.Groups["name"].Value; - if (ErlangIgnoredCalls.Contains(name)) - continue; - - if (definitionMatch.Success - && match.Groups["name"].Index == definitionMatch.Groups["name"].Index) - { - continue; - } - - AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "erlang"); - } - - void AddFunctionalMatchReference(Match match, string kind) - { - if (match.Success) - AddFunctionalReference(references, seen, fileId, match.Groups["name"], kind, context, lineNumber, container, "erlang"); - } - - bool IsInsideQuotedAtom(int index) - => quotedAtomSpans.Any(span => index > span.Start && index < span.End); - } - - private static List<(int Start, int End)> GetErlangQuotedAtomSpans(string line) - { - var spans = new List<(int Start, int End)>(); - for (var index = 0; index < line.Length; index++) - { - if (line[index] != '\'') - continue; - - var start = index; - for (index++; index < line.Length; index++) - { - if (line[index] == '\\' && index + 1 < line.Length) - { - index++; - continue; - } - - if (line[index] != '\'') - continue; - - spans.Add((start, index + 1)); - break; - } - } - - return spans; - } - - private static void EmitOcamlReferences( - long fileId, - string line, - string context, - int lineNumber, - SymbolRecord? definition, - SymbolRecord? typeDefinition, - SymbolRecord? container, - List references, - ReferenceDedupeSet seen, - FunctionalReferenceState state) - { - if (state.OcamlTypeDeclarationMode - && Regex.IsMatch( - line, - @"^\s*(?:let|module|class|exception|external|open|include)\b", - RegexOptions.CultureInvariant, - ExtractionRegexTimeout)) - { - state.OcamlTypeDeclarationMode = false; - state.OcamlActiveTypeDefinition = null; - } - - var startsTypeDeclaration = Regex.IsMatch( - line, - @"^\s*type\b", - RegexOptions.CultureInvariant, - ExtractionRegexTimeout); - if (startsTypeDeclaration) - { - state.OcamlTypeDeclarationMode = true; - state.OcamlActiveTypeDefinition = typeDefinition; - } - - var typeReferenceSpans = new List<(int Start, int End)>(); - AddMatch(OcamlImportRegex.Match(line), "import"); - AddMatch(OcamlModuleAliasRegex.Match(line), "alias"); - var typeAliasTarget = OcamlTypeAliasTargetRegex.Match(line); - if (typeAliasTarget.Success) - { - AddOcamlTypeReference(typeAliasTarget.Groups["name"]); - } - foreach (Match match in OcamlTypeReferenceRegex.Matches(line)) - AddOcamlTypeReference(match.Groups["name"]); - if (state.OcamlTypeDeclarationMode) - return; - - if (Regex.IsMatch( - line, - @"^\s*(?:module|type|class|open|include|val|external)\b", - RegexOptions.CultureInvariant, - ExtractionRegexTimeout)) - { - return; - } - - var qualifiedCallSpans = new List<(int Start, int End)>(typeReferenceSpans); - foreach (Match match in OcamlQualifiedCallRegex.Matches(line)) - { - if (qualifiedCallSpans.Any(span => RangesOverlap(span.Start, span.End, match.Index, match.Index + match.Length))) - continue; - qualifiedCallSpans.Add((match.Index, match.Index + match.Length)); - AddFunctionalReference(references, seen, fileId, match.Groups["module"], "reference", context, lineNumber, container, "ocaml"); - AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "ocaml"); - } - - var skippedDefinition = false; - foreach (Match match in OcamlBareCallRegex.Matches(line)) - { - if (qualifiedCallSpans.Any(span => match.Index >= span.Start && match.Index < span.End)) - continue; - - var name = match.Groups["name"].Value; - if (OcamlIgnoredCalls.Contains(name)) - continue; - - if (!skippedDefinition - && definition != null - && string.Equals(definition.Name, name, StringComparison.Ordinal)) - { - skippedDefinition = true; - continue; - } - - AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "ocaml"); - } - - void AddMatch(Match match, string kind) - { - if (match.Success) - AddFunctionalReference(references, seen, fileId, match.Groups["name"], kind, context, lineNumber, container, "ocaml"); - } - - void AddOcamlTypeReference(Group group) - { - if (!group.Success) - return; - - typeReferenceSpans.Add((group.Index, group.Index + group.Length)); - if (!OcamlIgnoredTypeReferences.Contains(group.Value)) - { - AddFunctionalReference( - references, - seen, - fileId, - group, - "type_reference", - context, - lineNumber, - typeDefinition ?? state.OcamlActiveTypeDefinition ?? container, - "ocaml"); - } - } - - static bool RangesOverlap(int leftStart, int leftEnd, int rightStart, int rightEnd) - => leftStart < rightEnd && rightStart < leftEnd; - } - - private static void EmitRakuReferences( - long fileId, - string line, - string context, - int lineNumber, - SymbolRecord? definition, - SymbolRecord? typeDefinition, - SymbolRecord? container, - List references, - ReferenceDedupeSet seen) - { - var importMatch = RakuImportRegex.Match(line); - if (importMatch.Success) - { - AddFunctionalReference(references, seen, fileId, importMatch.Groups["name"], "import", context, lineNumber, container, "raku"); - if (importMatch.Groups["angleAlias"].Success || importMatch.Groups["alias"].Success) - AddFunctionalReference(references, seen, fileId, importMatch.Groups["name"], "alias", context, lineNumber, container, "raku"); - } - - if (typeDefinition != null) - { - foreach (Match match in RakuTypeRelationRegex.Matches(line)) - AddFunctionalReference(references, seen, fileId, match.Groups["name"], "type_reference", context, lineNumber, typeDefinition, "raku"); - } - foreach (Match match in RakuReturnTypeRegex.Matches(line)) - AddFunctionalReference(references, seen, fileId, match.Groups["name"], "type_reference", context, lineNumber, container, "raku"); - if (typeDefinition != null) - return; - - var qualifiedCallSpans = new List<(int Start, int End)>(); - foreach (Match match in RakuQualifiedCallRegex.Matches(line)) - { - qualifiedCallSpans.Add((match.Index, match.Index + match.Length)); - AddFunctionalReference(references, seen, fileId, match.Groups["module"], "reference", context, lineNumber, container, "raku"); - AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "raku"); - } - foreach (Match match in RakuMethodCallRegex.Matches(line)) - { - qualifiedCallSpans.Add((match.Index, match.Index + match.Length)); - AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "raku"); - } - - var skippedDefinition = false; - foreach (Match match in RakuBareCallRegex.Matches(line)) - { - if (qualifiedCallSpans.Any(span => match.Index >= span.Start && match.Index < span.End)) - continue; - - var name = match.Groups["name"].Value; - if (RakuIgnoredCalls.Contains(name)) - continue; - - if (!skippedDefinition - && definition != null - && string.Equals(definition.Name, name, StringComparison.Ordinal)) - { - skippedDefinition = true; - continue; - } - - AddFunctionalReference(references, seen, fileId, match.Groups["name"], "call", context, lineNumber, container, "raku"); - } - } - - private static void AddFunctionalReference( - List references, - ReferenceDedupeSet seen, - long fileId, - Group group, - string referenceKind, - string context, - int lineNumber, - SymbolRecord? container, - string language) - { - if (!group.Success || ReferenceLimitReached(references)) - return; - - AddReference( - references, - seen, - fileId, - group.Value, - group.Index, - referenceKind, - context, - lineNumber, - container, - language); - } } From 8df13a209827e63a61f4c701694fa7fa5bc70c85 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:20:59 +0900 Subject: [PATCH 043/101] Split typed language reference analysis --- .../RustReferenceExtractor.GenericBounds.cs | 595 +++++ .../RustReferenceExtractor.SignatureTypes.cs | 918 ++++++++ .../RustReferenceExtractor.ValueTypes.cs | 590 +++++ .../Languages/RustReferenceExtractor.cs | 2075 +---------------- ...criptReferenceExtractor.ConstAssertions.cs | 640 +++++ ...ptReferenceExtractor.ImportExportSyntax.cs | 280 +++ ...riptReferenceExtractor.NamespaceAliases.cs | 301 +++ ...riptReferenceExtractor.TypeDeclarations.cs | 470 ++++ .../Languages/TypeScriptReferenceExtractor.cs | 1657 +------------ 9 files changed, 3796 insertions(+), 3730 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.GenericBounds.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.SignatureTypes.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.ValueTypes.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.ConstAssertions.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.ImportExportSyntax.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.NamespaceAliases.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.TypeDeclarations.cs diff --git a/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.GenericBounds.cs b/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.GenericBounds.cs new file mode 100644 index 000000000..46ea96fb0 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.GenericBounds.cs @@ -0,0 +1,595 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class RustReferenceExtractor +{ + private static void EmitGenericBoundReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var hasGenericMarker = preparedLine.IndexOf('<') >= 0; + var hasWhereMarker = preparedLine.IndexOf("where", StringComparison.Ordinal) >= 0; + if (!hasGenericMarker && !hasWhereMarker) + return; + + var genericOpenIndex = hasGenericMarker + ? TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '<') + : -1; + if (genericOpenIndex >= 0) + { + var constGenericNames = EmitConstGenericParameterReferences( + preparedLine, + genericOpenIndex, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + EmitConstGenericUsageReferences( + preparedLine, + genericOpenIndex, + constGenericNames, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + TypedLanguageReferenceExtractor.EmitGenericColonBoundReferences( + preparedLine, + genericOpenIndex, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + EmitGenericDefaultTypeReferences( + preparedLine, + genericOpenIndex, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + EmitGenericFunctionTraitReturnTypeReferences( + preparedLine, + genericOpenIndex, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + + if (!hasWhereMarker) + return; + + TypedLanguageReferenceExtractor.EmitWhereClauseTypeReferences( + preparedLine, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + EmitWhereClauseConstGenericReferences( + preparedLine, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + EmitWhereClauseFunctionTraitReturnTypeReferences( + preparedLine, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + + private static HashSet EmitConstGenericParameterReferences( + string preparedLine, + int genericOpenIndex, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var constGenericNames = new HashSet(StringComparer.Ordinal); + var genericCloseIndex = FindRustGenericClose(preparedLine, genericOpenIndex); + if (genericCloseIndex <= genericOpenIndex) + return constGenericNames; + + var clause = preparedLine.Substring(genericOpenIndex + 1, genericCloseIndex - genericOpenIndex - 1); + EmitConstGenericSegments( + clause, + genericOpenIndex + 1, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + constGenericNames); + return constGenericNames; + } + + private static void EmitConstGenericUsageReferences( + string preparedLine, + int genericOpenIndex, + HashSet constGenericNames, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (constGenericNames.Count == 0) + return; + + var genericCloseIndex = FindRustGenericClose(preparedLine, genericOpenIndex); + if (genericCloseIndex <= genericOpenIndex) + return; + + for (var index = genericCloseIndex + 1; index < preparedLine.Length; index++) + { + if (!IsRustIdentifierStart(preparedLine[index])) + continue; + + var end = index + 1; + while (end < preparedLine.Length && IsRustIdentifierPart(preparedLine[end])) + end++; + + var name = preparedLine.Substring(index, end - index); + if (constGenericNames.Contains(name)) + { + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name, + index, + "const_generic_reference", + context, + lineNumber, + resolveContainerForColumn(index)); + } + + index = end - 1; + } + } + + private static void EmitWhereClauseConstGenericReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + foreach (var whereIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "where")) + { + var clauseStart = whereIndex + "where".Length; + var clauseEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, clauseStart, stopAtComma: false, stopAtArrow: false); + if (clauseEnd <= clauseStart) + clauseEnd = preparedLine.Length; + + EmitConstGenericSegments( + preparedLine.Substring(clauseStart, clauseEnd - clauseStart), + clauseStart, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + } + + private static void EmitConstGenericSegments( + string clause, + int clauseStart, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + HashSet? constGenericNames = null) + { + if (clause.IndexOf("const", StringComparison.Ordinal) < 0 + || clause.IndexOf(':') < 0) + { + return; + } + + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(clause)) + { + var fragment = clause.Substring(segmentStart, segmentLength); + var match = ConstGenericParameterRegex.Match(fragment); + if (!match.Success) + continue; + + var nameGroup = match.Groups["name"]; + var name = NormalizeIdentifier(nameGroup.Value); + constGenericNames?.Add(name); + var absoluteNameStart = clauseStart + segmentStart + nameGroup.Index; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name, + absoluteNameStart, + "const_generic_reference", + context, + lineNumber, + resolveContainerForColumn(absoluteNameStart)); + + var typeGroup = match.Groups["type"]; + var typeMatch = ConstGenericTypeHeadRegex.Match(typeGroup.Value); + if (!typeMatch.Success) + continue; + + var typeNameGroup = typeMatch.Groups["name"]; + var absoluteTypeStart = clauseStart + segmentStart + typeGroup.Index + typeNameGroup.Index; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + NormalizeIdentifier(typeNameGroup.Value), + absoluteTypeStart, + "annotation", + context, + lineNumber, + resolveContainerForColumn(absoluteTypeStart)); + } + } + + private static void EmitGenericFunctionTraitReturnTypeReferences( + string preparedLine, + int genericOpenIndex, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf("->", StringComparison.Ordinal) < 0) + return; + + var genericCloseIndex = FindRustGenericClose(preparedLine, genericOpenIndex); + if (genericCloseIndex <= genericOpenIndex) + return; + + var clause = preparedLine.Substring(genericOpenIndex + 1, genericCloseIndex - genericOpenIndex - 1); + EmitFunctionTraitReturnTypesFromBoundClause( + clause, + genericOpenIndex + 1, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + + private static void EmitWhereClauseFunctionTraitReturnTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf("->", StringComparison.Ordinal) < 0) + return; + + foreach (var whereIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "where")) + { + var clauseStart = whereIndex + "where".Length; + var clauseEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, clauseStart, stopAtComma: false, stopAtArrow: false); + if (clauseEnd <= clauseStart) + clauseEnd = preparedLine.Length; + + EmitFunctionTraitReturnTypesFromBoundClause( + preparedLine.Substring(clauseStart, clauseEnd - clauseStart), + clauseStart, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + } + + private static void EmitFunctionTraitReturnTypesFromBoundClause( + string clause, + int clauseStart, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (clause.IndexOf("->", StringComparison.Ordinal) < 0 + || clause.IndexOf(':') < 0) + { + return; + } + + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(clause)) + { + var fragment = clause.Substring(segmentStart, segmentLength); + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(fragment, ':'); + if (colonIndex < 0) + continue; + + var arrowIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(fragment, "->", colonIndex + 1); + if (arrowIndex < 0) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, arrowIndex + 2); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart, stopAtArrow: false); + if (typeEnd <= typeStart) + continue; + + var absoluteStart = clauseStart + segmentStart + typeStart; + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + fragment.Substring(typeStart, typeEnd - typeStart), + absoluteStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(absoluteStart)); + } + } + + private static void EmitFunctionTraitReturnTypeFromExpression( + string expression, + int expressionStart, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (expression.IndexOf("->", StringComparison.Ordinal) < 0) + return; + + var arrowIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(expression, "->"); + if (arrowIndex < 0) + return; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(expression, arrowIndex + 2); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(expression, typeStart, stopAtArrow: false); + if (typeEnd <= typeStart) + return; + + var absoluteStart = expressionStart + typeStart; + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + expression.Substring(typeStart, typeEnd - typeStart), + absoluteStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(absoluteStart)); + } + + private static void EmitGenericDefaultTypeReferences( + string preparedLine, + int genericOpenIndex, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf('=') < 0) + return; + + var genericCloseIndex = FindRustGenericClose(preparedLine, genericOpenIndex); + if (genericCloseIndex <= genericOpenIndex) + return; + + var clause = preparedLine.Substring(genericOpenIndex + 1, genericCloseIndex - genericOpenIndex - 1); + if (clause.IndexOf('=') < 0) + return; + + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(clause)) + { + var fragment = clause.Substring(segmentStart, segmentLength); + var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(fragment, '='); + if (assignmentIndex < 0) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, assignmentIndex + 1); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart); + if (typeEnd <= typeStart) + continue; + + var absoluteStart = genericOpenIndex + 1 + segmentStart + typeStart; + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + fragment.Substring(typeStart, typeEnd - typeStart), + absoluteStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(absoluteStart)); + } + } + + private static int FindRustGenericClose(string text, int openIndex) + { + var depth = 0; + for (var index = openIndex; index < text.Length; index++) + { + if (text[index] == '-' && index + 1 < text.Length && text[index + 1] == '>') + { + index++; + continue; + } + + if (text[index] == '<') + { + depth++; + continue; + } + + if (text[index] != '>') + continue; + + depth--; + if (depth == 0) + return index; + } + + return -1; + } + + public static string NormalizeIdentifier(string identifier) + { + if (identifier.Length == 0) + return identifier; + + if (!identifier.Contains("r#", StringComparison.Ordinal)) + return identifier; + + if (!identifier.Contains("::", StringComparison.Ordinal)) + return identifier.StartsWith("r#", StringComparison.Ordinal) + ? identifier[2..] + : identifier; + + var builder = new StringBuilder(identifier.Length); + var segmentStart = 0; + while (segmentStart <= identifier.Length) + { + var separator = identifier.IndexOf("::", segmentStart, StringComparison.Ordinal); + var segmentEnd = separator >= 0 ? separator : identifier.Length; + AppendNormalizedRustIdentifierSegment(builder, identifier, segmentStart, segmentEnd - segmentStart); + if (separator < 0) + break; + + builder.Append("::"); + segmentStart = separator + 2; + } + + return builder.ToString(); + } + + private static void AppendNormalizedRustIdentifierSegment(StringBuilder builder, string identifier, int start, int length) + { + if (length >= 2 + && identifier[start] == 'r' + && identifier[start + 1] == '#') + { + start += 2; + length -= 2; + } + + builder.Append(identifier, start, length); + } + + public static bool IsFunctionDeclarationCallSite(string line, int callIndex) + { + if (callIndex <= 0) + return false; + + var prefix = line.AsSpan(0, callIndex).TrimEnd(); + return prefix.EndsWith("fn", StringComparison.Ordinal); + } + + public static bool IsDeriveAttributeCallSite(string line, string name, int callIndex) + { + if (!string.Equals(name, "derive", StringComparison.Ordinal) || callIndex <= 0) + return false; + + var index = callIndex - 1; + while (index >= 0 && char.IsWhiteSpace(line[index])) + index--; + + if (index < 0 || line[index] != '[') + return false; + + index--; + while (index >= 0 && char.IsWhiteSpace(line[index])) + index--; + + if (index >= 0 && line[index] == '!') + { + index--; + while (index >= 0 && char.IsWhiteSpace(line[index])) + index--; + } + + return index >= 0 && line[index] == '#'; + } + + public static bool IsLikelyInstantiationCallName(string originalName, string normalizedName, string line, int callIndex) + { + var normalizedLeaf = LastPathSegment(normalizedName); + var originalLeaf = LastPathSegment(originalName); + if (!IsLikelyRustTypePathLeaf(originalLeaf) && !IsLikelyRustTypePathLeaf(normalizedLeaf)) + return false; + + var afterName = callIndex + originalName.Length; + while (afterName < line.Length && char.IsWhiteSpace(line[afterName])) + afterName++; + + if (afterName >= line.Length) + return false; + + if (line[afterName] == '!') + return false; + + return line[afterName] is '(' or '<' + || (afterName + 1 < line.Length && line[afterName] == ':' && line[afterName + 1] == ':'); + } + + private static string LastPathSegment(string name) + { + var leafStart = name.LastIndexOf("::", StringComparison.Ordinal); + return leafStart >= 0 ? name[(leafStart + 2)..] : name; + } + + public static bool IsRawIdentifierPrefix(string line, int callIndex) => + callIndex >= 2 + && line[callIndex - 2] == 'r' + && line[callIndex - 1] == '#'; +} diff --git a/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.SignatureTypes.cs b/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.SignatureTypes.cs new file mode 100644 index 000000000..9f58c16d1 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.SignatureTypes.cs @@ -0,0 +1,918 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class RustReferenceExtractor +{ + public static void EmitTypePositionReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + SymbolRecord? container, + SymbolRecord? enumContainer) + { + EmitLifetimeReferences(context, references, seen, fileId, context, lineNumber, container); + EmitHigherRankedTraitBoundReferences(context, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitUseReferences(preparedLine, references, seen, fileId, context, lineNumber, container); + EmitExternCrateReferences(preparedLine, references, seen, fileId, context, lineNumber, container); + EmitModuleDeclarationReferences(preparedLine, references, seen, fileId, context, lineNumber, container); + EmitFunctionSignatureTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitClosureSignatureTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitLetTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitConstStaticTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitTypeAliasTargetReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitTraitAliasTargetReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitAssociatedTypeBoundReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitTupleStructFieldTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, container); + EmitStructFieldTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, container); + EmitEnumVariantTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, enumContainer); + EmitAsCastTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitQualifiedAssociatedCallReceiverTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitAssociatedCallReceiverTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitAssociatedValueReceiverTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitStructLiteralInstantiationReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, enumContainer); + EmitImplAndTraitTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitMutableReferenceTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitGenericBoundReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static void EmitMutableReferenceTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf('&') < 0 + || preparedLine.IndexOf("mut", StringComparison.Ordinal) < 0) + { + return; + } + + foreach (Match match in MutableReferenceTypeRegex.Matches(preparedLine)) + { + if (!IsMutableReferenceTypeContext(preparedLine, match.Index)) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, match.Index + match.Length); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); + if (typeEnd <= typeStart) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + } + + private static bool IsMutableReferenceTypeContext(string preparedLine, int ampersandIndex) + { + var cursor = ampersandIndex - 1; + while (cursor >= 0 && char.IsWhiteSpace(preparedLine[cursor])) + cursor--; + + if (cursor < 0) + return false; + if (preparedLine[cursor] == ':') + return true; + + return preparedLine[cursor] == '>' + && cursor > 0 + && preparedLine[cursor - 1] == '-'; + } + + private static void EmitLifetimeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf('\'') < 0) + { + return; + } + + for (var index = 0; index + 1 < preparedLine.Length; index++) + { + if (preparedLine[index] != '\'' || !IsRustLifetimeStart(preparedLine[index + 1])) + continue; + + var end = index + 2; + while (end < preparedLine.Length && IsRustLifetimePart(preparedLine[end])) + end++; + + if (end < preparedLine.Length && preparedLine[end] == '\'') + { + index = end; + continue; + } + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + preparedLine.Substring(index, end - index), + index, + "lifetime_reference", + context, + lineNumber, + container); + index = end - 1; + } + } + + private static void EmitHigherRankedTraitBoundReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (line.IndexOf("for", StringComparison.Ordinal) < 0 + || line.IndexOf('<') < 0) + { + return; + } + + foreach (var forIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(line, "for")) + { + var openAngle = SkipWhitespace(line, forIndex + "for".Length); + if (openAngle >= line.Length || line[openAngle] != '<') + continue; + + var closeAngle = FindRustGenericClose(line, openAngle); + if (closeAngle <= openAngle) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(line, closeAngle + 1); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(line, typeStart); + if (typeEnd <= typeStart) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + line.Substring(typeStart, typeEnd - typeStart), + typeStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + } + + private static void EmitUseReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("use", StringComparison.Ordinal) < 0) + { + return; + } + + var match = UseStatementRegex.Match(preparedLine); + if (!match.Success) + return; + + var bodyGroup = match.Groups["body"]; + EmitUseBodyReferences(bodyGroup.Value, bodyGroup.Index, references, seen, fileId, context, lineNumber, container, prefix: null); + } + + private static void EmitUseBodyReferences( + string body, + int bodyStart, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + string? prefix) + { + var text = body.Trim(); + if (text.Length == 0) + return; + + var textStart = bodyStart + body.IndexOf(text, StringComparison.Ordinal); + var openBrace = text.IndexOf('{'); + if (openBrace >= 0) + { + var closeBrace = text.LastIndexOf('}'); + if (closeBrace > openBrace) + { + var groupedPrefix = CombineUsePath(prefix, text[..openBrace].Trim()); + var inner = text.AsSpan(openBrace + 1, closeBrace - openBrace - 1); + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(inner)) + { + EmitUseBodyReferences( + inner.Slice(segmentStart, segmentLength).ToString(), + textStart + openBrace + 1 + segmentStart, + references, + seen, + fileId, + context, + lineNumber, + container, + groupedPrefix); + } + + return; + } + } + + var aliasIndex = FindTopLevelUseAliasIndex(text); + var target = aliasIndex >= 0 ? text[..aliasIndex].Trim() : text; + if (target.Length == 0 + || target is "crate" or "super" + || target == "*" && string.IsNullOrWhiteSpace(prefix)) + { + return; + } + + if (target == "self" && !string.IsNullOrWhiteSpace(prefix)) + target = prefix; + else if (target == "self") + return; + else if (!string.IsNullOrWhiteSpace(prefix)) + target = CombineUsePath(prefix, target); + + var leafStart = target.LastIndexOf("::", StringComparison.Ordinal); + var leaf = leafStart >= 0 ? target[(leafStart + 2)..].Trim() : target.Trim(); + if (leaf == "*") + { + if (leafStart < 0) + return; + + var globParent = target[..leafStart].Trim(); + var globParentLeafStart = globParent.LastIndexOf("::", StringComparison.Ordinal); + leaf = globParentLeafStart >= 0 ? globParent[(globParentLeafStart + 2)..].Trim() : globParent; + } + + if (leaf.Length == 0 || leaf is "crate" or "self" or "super" or "*") + return; + + var leafIndex = text.IndexOf(leaf, StringComparison.Ordinal); + ReferenceExtractor.AddReference( + references, + seen, + fileId, + NormalizeIdentifier(leaf), + textStart + Math.Max(0, leafIndex), + "reference", + context, + lineNumber, + container); + } + + private static int FindTopLevelUseAliasIndex(string text) + { + foreach (var asIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(text, "as")) + return asIndex; + + return -1; + } + + private static string CombineUsePath(string? prefix, string name) + { + var cleanedPrefix = TrimRustUsePathSegment(prefix); + var cleanedName = TrimRustUsePathSegment(name); + if (cleanedPrefix.Length == 0) + return cleanedName; + if (cleanedName.Length == 0) + return cleanedPrefix; + return $"{cleanedPrefix}::{cleanedName}"; + } + + private static string TrimRustUsePathSegment(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var span = value.AsSpan().Trim(); + while (span.Length > 0 && span[^1] == ':') + span = span[..^1]; + + return span.ToString(); + } + + private static void EmitExternCrateReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("extern", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf("crate", StringComparison.Ordinal) < 0) + { + return; + } + + var match = ExternCrateRegex.Match(preparedLine); + if (!match.Success) + return; + + var nameGroup = match.Groups["name"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + NormalizeIdentifier(nameGroup.Value), + nameGroup.Index, + "reference", + context, + lineNumber, + container); + } + + private static void EmitModuleDeclarationReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("mod", StringComparison.Ordinal) < 0) + { + return; + } + + var match = ModuleDeclarationRegex.Match(preparedLine); + if (!match.Success) + return; + + var nameGroup = match.Groups["name"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + NormalizeIdentifier(nameGroup.Value), + nameGroup.Index, + "reference", + context, + lineNumber, + container); + } + + private static void EmitFunctionSignatureTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf("fn", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('(') < 0) + { + return; + } + + var fnIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "fn"); + if (fnIndex < 0) + return; + + var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '(', fnIndex + 2); + if (openParen <= fnIndex) + return; + + var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); + if (closeParen < 0) + return; + + TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( + preparedLine, + openParen + 1, + closeParen, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + + if (preparedLine.IndexOf("->", StringComparison.Ordinal) < 0) + return; + + var arrowIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(preparedLine, "->", closeParen + 1); + if (arrowIndex < 0) + return; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, arrowIndex + 2); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); + if (typeEnd <= typeStart) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + + private static void EmitClosureSignatureTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf('|') < 0 + || (preparedLine.IndexOf(':') < 0 && preparedLine.IndexOf("->", StringComparison.Ordinal) < 0)) + { + return; + } + + var searchIndex = 0; + while (searchIndex < preparedLine.Length) + { + var openPipe = preparedLine.IndexOf('|', searchIndex); + if (openPipe < 0) + return; + + var closePipe = preparedLine.IndexOf('|', openPipe + 1); + if (closePipe < 0) + return; + + searchIndex = closePipe + 1; + var parameterList = preparedLine.Substring(openPipe + 1, closePipe - openPipe - 1); + var hasParameterTypes = TypedLanguageReferenceExtractor.FindTopLevelChar(parameterList, ':') >= 0; + var arrowIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(preparedLine, "->", closePipe + 1); + var hasImmediateReturnType = arrowIndex >= 0 && HasOnlyWhitespace(preparedLine, closePipe + 1, arrowIndex); + if (!hasParameterTypes && !hasImmediateReturnType) + continue; + + if (hasParameterTypes) + { + TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( + preparedLine, + openPipe + 1, + closePipe, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + + if (!hasImmediateReturnType) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, arrowIndex + 2); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); + if (typeEnd <= typeStart) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + } + + private static bool HasOnlyWhitespace(string text, int startIndex, int endIndex) + { + for (var index = Math.Max(0, startIndex); index < endIndex && index < text.Length; index++) + { + if (!char.IsWhiteSpace(text[index])) + return false; + } + + return true; + } + + private static void EmitLetTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf("let", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf(':') < 0) + { + return; + } + + foreach (var letIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "let")) + { + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', letIndex + "let".Length); + if (colonIndex < 0) + continue; + + var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', letIndex + "let".Length); + if (assignmentIndex >= 0 && assignmentIndex < colonIndex) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); + if (typeEnd <= typeStart) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + } + + private static void EmitConstStaticTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf(':') < 0 + || (preparedLine.IndexOf("const", StringComparison.Ordinal) < 0 + && preparedLine.IndexOf("static", StringComparison.Ordinal) < 0)) + { + return; + } + + foreach (var keyword in ConstStaticKeywords) + { + foreach (var keywordIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, keyword)) + { + var declarationStart = keywordIndex + keyword.Length; + if (keyword == "static") + declarationStart = SkipOptionalRustMut(preparedLine, declarationStart); + + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', declarationStart); + if (colonIndex < 0) + continue; + + var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', declarationStart); + if (assignmentIndex >= 0 && assignmentIndex < colonIndex) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); + if (typeEnd <= typeStart) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + } + } + + private static int SkipOptionalRustMut(string line, int startIndex) + { + var index = startIndex; + while (index < line.Length && char.IsWhiteSpace(line[index])) + index++; + + if (index + "mut".Length > line.Length + || string.CompareOrdinal(line, index, "mut", 0, "mut".Length) != 0) + { + return startIndex; + } + + var afterMut = index + "mut".Length; + if (afterMut < line.Length && IsRustIdentifierPart(line[afterMut])) + return startIndex; + + return afterMut; + } + + private static void EmitTypeAliasTargetReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf("type", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('=') < 0) + { + return; + } + + foreach (var typeIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "type")) + { + var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', typeIndex + "type".Length); + if (assignmentIndex < 0) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, assignmentIndex + 1); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); + if (typeEnd <= typeStart) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + } + + private static void EmitTraitAliasTargetReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf("trait", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('=') < 0) + { + return; + } + + foreach (var traitIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "trait")) + { + var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', traitIndex + "trait".Length); + if (assignmentIndex < 0) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, assignmentIndex + 1); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); + if (typeEnd <= typeStart) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + } + + private static void EmitAssociatedTypeBoundReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf("type", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf(':') < 0) + { + return; + } + + foreach (var typeIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "type")) + { + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', typeIndex + "type".Length); + if (colonIndex < 0) + continue; + + var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', typeIndex + "type".Length); + if (assignmentIndex >= 0 && assignmentIndex < colonIndex) + continue; + + var boundsStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); + var boundsEnd = assignmentIndex > colonIndex + ? assignmentIndex + : TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, boundsStart); + if (boundsEnd <= boundsStart) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(boundsStart, boundsEnd - boundsStart), + boundsStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(boundsStart)); + } + } + + private static void EmitTupleStructFieldTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + SymbolRecord? container) + { + if (preparedLine.IndexOf("struct", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('(') < 0) + { + return; + } + + var structIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "struct"); + if (structIndex < 0) + return; + + var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '(', structIndex + "struct".Length); + if (openParen < 0) + return; + + var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); + if (closeParen <= openParen) + return; + + var fieldList = preparedLine.AsSpan(openParen + 1, closeParen - openParen - 1); + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(fieldList)) + { + var fragment = fieldList.Slice(segmentStart, segmentLength).ToString(); + var typeStart = SkipRustTupleFieldPrefix(fragment); + if (typeStart >= fragment.Length) + continue; + + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart); + if (typeEnd <= typeStart) + continue; + + var absoluteStart = openParen + 1 + segmentStart + typeStart; + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + fragment.Substring(typeStart, typeEnd - typeStart), + absoluteStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + container ?? resolveContainerForColumn(absoluteStart)); + } + } + + private static void EmitStructFieldTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (container?.Kind is not "class" and not "struct") + return; + + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':'); + if (colonIndex < 0) + return; + + var trimmed = preparedLine.TrimStart(); + if (trimmed.StartsWith("fn ", StringComparison.Ordinal) + || trimmed.StartsWith("let ", StringComparison.Ordinal) + || trimmed.StartsWith("type ", StringComparison.Ordinal) + || trimmed.StartsWith("impl ", StringComparison.Ordinal)) + { + return; + } + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); + if (typeEnd <= typeStart) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + container); + } + + private static int SkipRustTupleFieldPrefix(string fragment) + { + var index = 0; + while (index < fragment.Length && char.IsWhiteSpace(fragment[index])) + index++; + + if (index + 3 > fragment.Length + || string.CompareOrdinal(fragment, index, "pub", 0, "pub".Length) != 0) + { + return index; + } + + var afterPub = index + "pub".Length; + if (afterPub < fragment.Length && IsRustIdentifierPart(fragment[afterPub])) + return index; + + index = afterPub; + while (index < fragment.Length && char.IsWhiteSpace(fragment[index])) + index++; + + if (index < fragment.Length && fragment[index] == '(') + { + var closeParen = ReferenceExtractor.FindMatchingChar(fragment, index, '(', ')'); + if (closeParen < 0) + return fragment.Length; + + index = closeParen + 1; + while (index < fragment.Length && char.IsWhiteSpace(fragment[index])) + index++; + } + + return index; + } + + private static bool IsRustIdentifierPart(char ch) => + ch == '_' || ch == '$' || char.IsLetterOrDigit(ch); + + private static bool IsRustIdentifierStart(char ch) => + ch == '_' || char.IsLetter(ch); + + private static bool IsRustLifetimeStart(char ch) => + ch == '_' || char.IsLetter(ch); + + private static bool IsRustLifetimePart(char ch) => + ch == '_' || char.IsLetterOrDigit(ch); + + private static int SkipWhitespace(string text, int index) + { + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + + return index; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.ValueTypes.cs b/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.ValueTypes.cs new file mode 100644 index 000000000..cf647a964 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.ValueTypes.cs @@ -0,0 +1,590 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class RustReferenceExtractor +{ + private static void EmitEnumVariantTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? enumContainer) + { + if (enumContainer?.Kind != "enum") + return; + if (preparedLine.IndexOf('(') < 0 + && preparedLine.IndexOf('{') < 0) + { + return; + } + + var variantStart = FirstNonWhitespaceIndex(preparedLine); + if (variantStart >= preparedLine.Length + || preparedLine[variantStart] is '}' or '#' + || !IsLikelyRustEnumVariantStart(preparedLine, variantStart)) + { + return; + } + + var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '(', variantStart); + var openBrace = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '{', variantStart); + if (openParen >= 0 && (openBrace < 0 || openParen < openBrace)) + { + EmitEnumTupleVariantTypeReferences(preparedLine, openParen, references, seen, fileId, context, lineNumber, enumContainer); + } + + if (openBrace >= 0) + EmitEnumStructVariantTypeReferences(preparedLine, openBrace, references, seen, fileId, context, lineNumber, enumContainer); + } + + private static void EmitEnumTupleVariantTypeReferences( + string preparedLine, + int openParen, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord enumContainer) + { + var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); + if (closeParen <= openParen) + return; + + var fieldList = preparedLine.AsSpan(openParen + 1, closeParen - openParen - 1); + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(fieldList)) + { + var fragment = fieldList.Slice(segmentStart, segmentLength).ToString(); + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, 0); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart); + if (typeEnd <= typeStart) + continue; + + var absoluteStart = openParen + 1 + segmentStart + typeStart; + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + fragment.Substring(typeStart, typeEnd - typeStart), + absoluteStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + enumContainer); + } + } + + private static void EmitEnumStructVariantTypeReferences( + string preparedLine, + int openBrace, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord enumContainer) + { + var closeBrace = ReferenceExtractor.FindMatchingChar(preparedLine, openBrace, '{', '}'); + if (closeBrace <= openBrace) + return; + + var fieldList = preparedLine.Substring(openBrace + 1, closeBrace - openBrace - 1); + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(fieldList)) + { + var fragment = fieldList.Substring(segmentStart, segmentLength); + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(fragment, ':'); + if (colonIndex < 0) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, colonIndex + 1); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart); + if (typeEnd <= typeStart) + continue; + + var absoluteStart = openBrace + 1 + segmentStart + typeStart; + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + fragment.Substring(typeStart, typeEnd - typeStart), + absoluteStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + enumContainer); + } + } + + private static int FirstNonWhitespaceIndex(string text) + { + var index = 0; + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + + return index; + } + + private static bool IsLikelyRustEnumVariantStart(string line, int startIndex) + { + if (startIndex < line.Length && char.IsUpper(line[startIndex])) + return true; + + return startIndex + 2 < line.Length + && line[startIndex] == 'r' + && line[startIndex + 1] == '#' + && IsRustIdentifierPart(line[startIndex + 2]); + } + + private static void EmitAsCastTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf("as", StringComparison.Ordinal) < 0) + return; + + var trimmed = preparedLine.TrimStart(); + if (trimmed.StartsWith("use ", StringComparison.Ordinal) + || trimmed.StartsWith("pub use ", StringComparison.Ordinal) + || trimmed.StartsWith("extern crate ", StringComparison.Ordinal) + || trimmed.StartsWith("pub extern crate ", StringComparison.Ordinal)) + { + return; + } + + foreach (var asIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "as")) + { + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, asIndex + "as".Length); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); + if (typeEnd <= typeStart) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + } + + private static void EmitAssociatedCallReceiverTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf("::", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('(') < 0) + { + return; + } + + foreach (Match match in AssociatedCallReceiverRegex.Matches(preparedLine)) + { + var receiverGroup = match.Groups["receiver"]; + var receiver = receiverGroup.Value; + var leafStart = receiver.LastIndexOf("::", StringComparison.Ordinal); + var leaf = leafStart >= 0 ? receiver[(leafStart + 2)..] : receiver; + var leafOffset = leafStart >= 0 ? leafStart + 2 : 0; + var normalizedLeaf = NormalizeIdentifier(leaf); + if (normalizedLeaf == "Self" || !IsLikelyRustTypePathLeaf(leaf)) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + normalizedLeaf, + receiverGroup.Index + leafOffset, + "type_reference", + context, + lineNumber, + resolveContainerForColumn(receiverGroup.Index)); + + var argsGroup = match.Groups["args"]; + if (!argsGroup.Success) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + argsGroup.Value, + argsGroup.Index, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(argsGroup.Index)); + } + } + + private static void EmitAssociatedValueReceiverTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf("::", StringComparison.Ordinal) < 0) + { + return; + } + + foreach (Match match in AssociatedValueReceiverRegex.Matches(preparedLine)) + { + var receiverGroup = match.Groups["receiver"]; + var receiver = receiverGroup.Value; + var leafStart = receiver.LastIndexOf("::", StringComparison.Ordinal); + var leaf = leafStart >= 0 ? receiver[(leafStart + 2)..] : receiver; + var leafOffset = leafStart >= 0 ? leafStart + 2 : 0; + var normalizedLeaf = NormalizeIdentifier(leaf); + if (normalizedLeaf == "Self" || !IsLikelyRustTypePathLeaf(leaf)) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + normalizedLeaf, + receiverGroup.Index + leafOffset, + "type_reference", + context, + lineNumber, + resolveContainerForColumn(receiverGroup.Index)); + + var argsGroup = match.Groups["args"]; + if (!argsGroup.Success) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + argsGroup.Value, + argsGroup.Index, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(argsGroup.Index)); + } + } + + private static void EmitQualifiedAssociatedCallReceiverTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf('<') < 0 + || preparedLine.IndexOf("::", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('(') < 0) + { + return; + } + + var searchIndex = 0; + while (searchIndex < preparedLine.Length) + { + var openAngle = preparedLine.IndexOf('<', searchIndex); + if (openAngle < 0) + return; + + searchIndex = openAngle + 1; + var closeAngle = ReferenceExtractor.FindMatchingChar(preparedLine, openAngle, '<', '>'); + if (closeAngle <= openAngle) + continue; + + var afterClose = SkipWhitespace(preparedLine, closeAngle + 1); + if (afterClose + 2 > preparedLine.Length + || preparedLine[afterClose] != ':' + || preparedLine[afterClose + 1] != ':') + { + continue; + } + + var methodStart = SkipWhitespace(preparedLine, afterClose + 2); + if (methodStart >= preparedLine.Length || !IsRustIdentifierStart(preparedLine[methodStart])) + continue; + + var methodEnd = methodStart + 1; + while (methodEnd < preparedLine.Length && IsRustIdentifierPart(preparedLine[methodEnd])) + methodEnd++; + + var callOpen = SkipWhitespace(preparedLine, methodEnd); + if (callOpen >= preparedLine.Length || preparedLine[callOpen] != '(') + continue; + + var qualified = preparedLine.Substring(openAngle + 1, closeAngle - openAngle - 1); + foreach (var asIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(qualified, "as")) + { + EmitQualifiedAssociatedCallTypePart( + qualified, + openAngle + 1, + 0, + asIndex, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + EmitQualifiedAssociatedCallTypePart( + qualified, + openAngle + 1, + asIndex + "as".Length, + qualified.Length, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + break; + } + } + } + + private static void EmitQualifiedAssociatedCallTypePart( + string qualified, + int qualifiedStart, + int partStart, + int partEnd, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(qualified, partStart); + while (partEnd > typeStart && char.IsWhiteSpace(qualified[partEnd - 1])) + partEnd--; + if (partEnd <= typeStart) + return; + + var absoluteStart = qualifiedStart + typeStart; + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + qualified.Substring(typeStart, partEnd - typeStart), + absoluteStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(absoluteStart)); + } + + private static bool IsLikelyRustTypePathLeaf(string leaf) + { + if (leaf.StartsWith("r#", StringComparison.Ordinal)) + return leaf.Length > 2 && IsRustIdentifierPart(leaf[2]); + + return leaf.Length > 0 && char.IsUpper(leaf[0]); + } + + private static void EmitStructLiteralInstantiationReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + SymbolRecord? enumContainer) + { + if (enumContainer != null + || preparedLine.IndexOf('{') < 0 + || IsRustTypeDeclarationLine(preparedLine)) + { + return; + } + + foreach (Match match in StructLiteralRegex.Matches(preparedLine)) + { + var nameGroup = match.Groups["name"]; + var name = nameGroup.Value; + var leafStart = name.LastIndexOf("::", StringComparison.Ordinal); + var leaf = leafStart >= 0 ? name[(leafStart + 2)..] : name; + var leafOffset = leafStart >= 0 ? leafStart + 2 : 0; + var normalizedLeaf = NormalizeIdentifier(leaf); + if (normalizedLeaf == "Self" || !IsLikelyRustTypePathLeaf(leaf)) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + normalizedLeaf, + nameGroup.Index + leafOffset, + "instantiate", + context, + lineNumber, + resolveContainerForColumn(nameGroup.Index)); + + var argsGroup = match.Groups["args"]; + if (!argsGroup.Success) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + argsGroup.Value, + argsGroup.Index, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(argsGroup.Index)); + } + } + + private static bool IsRustTypeDeclarationLine(string line) + { + var trimmed = line.TrimStart(); + if (trimmed.StartsWith("pub", StringComparison.Ordinal)) + { + var afterPub = "pub".Length; + if (afterPub < trimmed.Length && trimmed[afterPub] == '(') + { + var closeParen = ReferenceExtractor.FindMatchingChar(trimmed, afterPub, '(', ')'); + if (closeParen > afterPub) + trimmed = trimmed[(closeParen + 1)..].TrimStart(); + } + else if (afterPub < trimmed.Length && char.IsWhiteSpace(trimmed[afterPub])) + { + trimmed = trimmed[afterPub..].TrimStart(); + } + } + + return trimmed.StartsWith("struct ", StringComparison.Ordinal) + || trimmed.StartsWith("enum ", StringComparison.Ordinal) + || trimmed.StartsWith("union ", StringComparison.Ordinal); + } + + private static void EmitImplAndTraitTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var hasImplMarker = preparedLine.IndexOf("impl", StringComparison.Ordinal) >= 0; + var hasTraitMarker = preparedLine.IndexOf("trait", StringComparison.Ordinal) >= 0; + if (!hasImplMarker && !hasTraitMarker) + return; + + if (hasImplMarker) + { + var implIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "impl"); + if (implIndex >= 0) + { + var typeListStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, implIndex + "impl".Length); + var forIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "for"); + var typeListEnd = forIndex >= 0 + ? forIndex + : TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeListStart); + + if (typeListEnd > typeListStart) + { + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeListStart, typeListEnd - typeListStart), + typeListStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeListStart)); + } + + if (forIndex >= 0) + { + var targetStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, forIndex + "for".Length); + var targetEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, targetStart); + if (targetEnd > targetStart) + { + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(targetStart, targetEnd - targetStart), + targetStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(targetStart)); + } + } + } + } + + if (!hasTraitMarker || preparedLine.IndexOf(':') < 0) + return; + + var traitIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "trait"); + if (traitIndex < 0) + return; + + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', traitIndex + "trait".Length); + if (colonIndex < 0) + return; + + var boundsStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); + var boundsEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, boundsStart); + if (boundsEnd <= boundsStart) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(boundsStart, boundsEnd - boundsStart), + boundsStart, + "rust", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(boundsStart)); + + var fullBoundsEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, boundsStart, stopAtArrow: false); + if (fullBoundsEnd > boundsStart) + { + EmitFunctionTraitReturnTypeFromExpression( + preparedLine.Substring(boundsStart, fullBoundsEnd - boundsStart), + boundsStart, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.cs index 61b0be10a..2deb32bdf 100644 --- a/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/RustReferenceExtractor.cs @@ -5,7 +5,7 @@ namespace CodeIndex.Indexer; -internal static class RustReferenceExtractor +internal static partial class RustReferenceExtractor { private const string RustIdentifierPattern = @"(?:r#)?[_\p{L}][\w$]*"; private static readonly string[] ConstStaticKeywords = ["const", "static"]; @@ -571,2077 +571,4 @@ private static (int LineNumber, int Column) GetLineColumn( return (lineNumber, column); } - public static void EmitTypePositionReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - SymbolRecord? container, - SymbolRecord? enumContainer) - { - EmitLifetimeReferences(context, references, seen, fileId, context, lineNumber, container); - EmitHigherRankedTraitBoundReferences(context, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitUseReferences(preparedLine, references, seen, fileId, context, lineNumber, container); - EmitExternCrateReferences(preparedLine, references, seen, fileId, context, lineNumber, container); - EmitModuleDeclarationReferences(preparedLine, references, seen, fileId, context, lineNumber, container); - EmitFunctionSignatureTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitClosureSignatureTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitLetTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitConstStaticTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitTypeAliasTargetReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitTraitAliasTargetReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitAssociatedTypeBoundReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitTupleStructFieldTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, container); - EmitStructFieldTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, container); - EmitEnumVariantTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, enumContainer); - EmitAsCastTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitQualifiedAssociatedCallReceiverTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitAssociatedCallReceiverTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitAssociatedValueReceiverTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitStructLiteralInstantiationReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, enumContainer); - EmitImplAndTraitTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitMutableReferenceTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitGenericBoundReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static void EmitMutableReferenceTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf('&') < 0 - || preparedLine.IndexOf("mut", StringComparison.Ordinal) < 0) - { - return; - } - - foreach (Match match in MutableReferenceTypeRegex.Matches(preparedLine)) - { - if (!IsMutableReferenceTypeContext(preparedLine, match.Index)) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, match.Index + match.Length); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); - if (typeEnd <= typeStart) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - } - - private static bool IsMutableReferenceTypeContext(string preparedLine, int ampersandIndex) - { - var cursor = ampersandIndex - 1; - while (cursor >= 0 && char.IsWhiteSpace(preparedLine[cursor])) - cursor--; - - if (cursor < 0) - return false; - if (preparedLine[cursor] == ':') - return true; - - return preparedLine[cursor] == '>' - && cursor > 0 - && preparedLine[cursor - 1] == '-'; - } - - private static void EmitLifetimeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf('\'') < 0) - { - return; - } - - for (var index = 0; index + 1 < preparedLine.Length; index++) - { - if (preparedLine[index] != '\'' || !IsRustLifetimeStart(preparedLine[index + 1])) - continue; - - var end = index + 2; - while (end < preparedLine.Length && IsRustLifetimePart(preparedLine[end])) - end++; - - if (end < preparedLine.Length && preparedLine[end] == '\'') - { - index = end; - continue; - } - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - preparedLine.Substring(index, end - index), - index, - "lifetime_reference", - context, - lineNumber, - container); - index = end - 1; - } - } - - private static void EmitHigherRankedTraitBoundReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (line.IndexOf("for", StringComparison.Ordinal) < 0 - || line.IndexOf('<') < 0) - { - return; - } - - foreach (var forIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(line, "for")) - { - var openAngle = SkipWhitespace(line, forIndex + "for".Length); - if (openAngle >= line.Length || line[openAngle] != '<') - continue; - - var closeAngle = FindRustGenericClose(line, openAngle); - if (closeAngle <= openAngle) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(line, closeAngle + 1); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(line, typeStart); - if (typeEnd <= typeStart) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - line.Substring(typeStart, typeEnd - typeStart), - typeStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - } - - private static void EmitUseReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("use", StringComparison.Ordinal) < 0) - { - return; - } - - var match = UseStatementRegex.Match(preparedLine); - if (!match.Success) - return; - - var bodyGroup = match.Groups["body"]; - EmitUseBodyReferences(bodyGroup.Value, bodyGroup.Index, references, seen, fileId, context, lineNumber, container, prefix: null); - } - - private static void EmitUseBodyReferences( - string body, - int bodyStart, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - string? prefix) - { - var text = body.Trim(); - if (text.Length == 0) - return; - - var textStart = bodyStart + body.IndexOf(text, StringComparison.Ordinal); - var openBrace = text.IndexOf('{'); - if (openBrace >= 0) - { - var closeBrace = text.LastIndexOf('}'); - if (closeBrace > openBrace) - { - var groupedPrefix = CombineUsePath(prefix, text[..openBrace].Trim()); - var inner = text.AsSpan(openBrace + 1, closeBrace - openBrace - 1); - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(inner)) - { - EmitUseBodyReferences( - inner.Slice(segmentStart, segmentLength).ToString(), - textStart + openBrace + 1 + segmentStart, - references, - seen, - fileId, - context, - lineNumber, - container, - groupedPrefix); - } - - return; - } - } - - var aliasIndex = FindTopLevelUseAliasIndex(text); - var target = aliasIndex >= 0 ? text[..aliasIndex].Trim() : text; - if (target.Length == 0 - || target is "crate" or "super" - || target == "*" && string.IsNullOrWhiteSpace(prefix)) - { - return; - } - - if (target == "self" && !string.IsNullOrWhiteSpace(prefix)) - target = prefix; - else if (target == "self") - return; - else if (!string.IsNullOrWhiteSpace(prefix)) - target = CombineUsePath(prefix, target); - - var leafStart = target.LastIndexOf("::", StringComparison.Ordinal); - var leaf = leafStart >= 0 ? target[(leafStart + 2)..].Trim() : target.Trim(); - if (leaf == "*") - { - if (leafStart < 0) - return; - - var globParent = target[..leafStart].Trim(); - var globParentLeafStart = globParent.LastIndexOf("::", StringComparison.Ordinal); - leaf = globParentLeafStart >= 0 ? globParent[(globParentLeafStart + 2)..].Trim() : globParent; - } - - if (leaf.Length == 0 || leaf is "crate" or "self" or "super" or "*") - return; - - var leafIndex = text.IndexOf(leaf, StringComparison.Ordinal); - ReferenceExtractor.AddReference( - references, - seen, - fileId, - NormalizeIdentifier(leaf), - textStart + Math.Max(0, leafIndex), - "reference", - context, - lineNumber, - container); - } - - private static int FindTopLevelUseAliasIndex(string text) - { - foreach (var asIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(text, "as")) - return asIndex; - - return -1; - } - - private static string CombineUsePath(string? prefix, string name) - { - var cleanedPrefix = TrimRustUsePathSegment(prefix); - var cleanedName = TrimRustUsePathSegment(name); - if (cleanedPrefix.Length == 0) - return cleanedName; - if (cleanedName.Length == 0) - return cleanedPrefix; - return $"{cleanedPrefix}::{cleanedName}"; - } - - private static string TrimRustUsePathSegment(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return string.Empty; - - var span = value.AsSpan().Trim(); - while (span.Length > 0 && span[^1] == ':') - span = span[..^1]; - - return span.ToString(); - } - - private static void EmitExternCrateReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("extern", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf("crate", StringComparison.Ordinal) < 0) - { - return; - } - - var match = ExternCrateRegex.Match(preparedLine); - if (!match.Success) - return; - - var nameGroup = match.Groups["name"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - NormalizeIdentifier(nameGroup.Value), - nameGroup.Index, - "reference", - context, - lineNumber, - container); - } - - private static void EmitModuleDeclarationReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("mod", StringComparison.Ordinal) < 0) - { - return; - } - - var match = ModuleDeclarationRegex.Match(preparedLine); - if (!match.Success) - return; - - var nameGroup = match.Groups["name"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - NormalizeIdentifier(nameGroup.Value), - nameGroup.Index, - "reference", - context, - lineNumber, - container); - } - - private static void EmitFunctionSignatureTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf("fn", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('(') < 0) - { - return; - } - - var fnIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "fn"); - if (fnIndex < 0) - return; - - var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '(', fnIndex + 2); - if (openParen <= fnIndex) - return; - - var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); - if (closeParen < 0) - return; - - TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( - preparedLine, - openParen + 1, - closeParen, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - - if (preparedLine.IndexOf("->", StringComparison.Ordinal) < 0) - return; - - var arrowIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(preparedLine, "->", closeParen + 1); - if (arrowIndex < 0) - return; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, arrowIndex + 2); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); - if (typeEnd <= typeStart) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - - private static void EmitClosureSignatureTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf('|') < 0 - || (preparedLine.IndexOf(':') < 0 && preparedLine.IndexOf("->", StringComparison.Ordinal) < 0)) - { - return; - } - - var searchIndex = 0; - while (searchIndex < preparedLine.Length) - { - var openPipe = preparedLine.IndexOf('|', searchIndex); - if (openPipe < 0) - return; - - var closePipe = preparedLine.IndexOf('|', openPipe + 1); - if (closePipe < 0) - return; - - searchIndex = closePipe + 1; - var parameterList = preparedLine.Substring(openPipe + 1, closePipe - openPipe - 1); - var hasParameterTypes = TypedLanguageReferenceExtractor.FindTopLevelChar(parameterList, ':') >= 0; - var arrowIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(preparedLine, "->", closePipe + 1); - var hasImmediateReturnType = arrowIndex >= 0 && HasOnlyWhitespace(preparedLine, closePipe + 1, arrowIndex); - if (!hasParameterTypes && !hasImmediateReturnType) - continue; - - if (hasParameterTypes) - { - TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( - preparedLine, - openPipe + 1, - closePipe, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - - if (!hasImmediateReturnType) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, arrowIndex + 2); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); - if (typeEnd <= typeStart) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - } - - private static bool HasOnlyWhitespace(string text, int startIndex, int endIndex) - { - for (var index = Math.Max(0, startIndex); index < endIndex && index < text.Length; index++) - { - if (!char.IsWhiteSpace(text[index])) - return false; - } - - return true; - } - - private static void EmitLetTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf("let", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf(':') < 0) - { - return; - } - - foreach (var letIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "let")) - { - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', letIndex + "let".Length); - if (colonIndex < 0) - continue; - - var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', letIndex + "let".Length); - if (assignmentIndex >= 0 && assignmentIndex < colonIndex) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); - if (typeEnd <= typeStart) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - } - - private static void EmitConstStaticTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf(':') < 0 - || (preparedLine.IndexOf("const", StringComparison.Ordinal) < 0 - && preparedLine.IndexOf("static", StringComparison.Ordinal) < 0)) - { - return; - } - - foreach (var keyword in ConstStaticKeywords) - { - foreach (var keywordIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, keyword)) - { - var declarationStart = keywordIndex + keyword.Length; - if (keyword == "static") - declarationStart = SkipOptionalRustMut(preparedLine, declarationStart); - - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', declarationStart); - if (colonIndex < 0) - continue; - - var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', declarationStart); - if (assignmentIndex >= 0 && assignmentIndex < colonIndex) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); - if (typeEnd <= typeStart) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - } - } - - private static int SkipOptionalRustMut(string line, int startIndex) - { - var index = startIndex; - while (index < line.Length && char.IsWhiteSpace(line[index])) - index++; - - if (index + "mut".Length > line.Length - || string.CompareOrdinal(line, index, "mut", 0, "mut".Length) != 0) - { - return startIndex; - } - - var afterMut = index + "mut".Length; - if (afterMut < line.Length && IsRustIdentifierPart(line[afterMut])) - return startIndex; - - return afterMut; - } - - private static void EmitTypeAliasTargetReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf("type", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('=') < 0) - { - return; - } - - foreach (var typeIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "type")) - { - var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', typeIndex + "type".Length); - if (assignmentIndex < 0) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, assignmentIndex + 1); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); - if (typeEnd <= typeStart) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - } - - private static void EmitTraitAliasTargetReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf("trait", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('=') < 0) - { - return; - } - - foreach (var traitIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "trait")) - { - var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', traitIndex + "trait".Length); - if (assignmentIndex < 0) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, assignmentIndex + 1); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); - if (typeEnd <= typeStart) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - } - - private static void EmitAssociatedTypeBoundReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf("type", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf(':') < 0) - { - return; - } - - foreach (var typeIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "type")) - { - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', typeIndex + "type".Length); - if (colonIndex < 0) - continue; - - var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', typeIndex + "type".Length); - if (assignmentIndex >= 0 && assignmentIndex < colonIndex) - continue; - - var boundsStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); - var boundsEnd = assignmentIndex > colonIndex - ? assignmentIndex - : TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, boundsStart); - if (boundsEnd <= boundsStart) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(boundsStart, boundsEnd - boundsStart), - boundsStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(boundsStart)); - } - } - - private static void EmitTupleStructFieldTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - SymbolRecord? container) - { - if (preparedLine.IndexOf("struct", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('(') < 0) - { - return; - } - - var structIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "struct"); - if (structIndex < 0) - return; - - var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '(', structIndex + "struct".Length); - if (openParen < 0) - return; - - var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); - if (closeParen <= openParen) - return; - - var fieldList = preparedLine.AsSpan(openParen + 1, closeParen - openParen - 1); - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(fieldList)) - { - var fragment = fieldList.Slice(segmentStart, segmentLength).ToString(); - var typeStart = SkipRustTupleFieldPrefix(fragment); - if (typeStart >= fragment.Length) - continue; - - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart); - if (typeEnd <= typeStart) - continue; - - var absoluteStart = openParen + 1 + segmentStart + typeStart; - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - fragment.Substring(typeStart, typeEnd - typeStart), - absoluteStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - container ?? resolveContainerForColumn(absoluteStart)); - } - } - - private static void EmitStructFieldTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (container?.Kind is not "class" and not "struct") - return; - - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':'); - if (colonIndex < 0) - return; - - var trimmed = preparedLine.TrimStart(); - if (trimmed.StartsWith("fn ", StringComparison.Ordinal) - || trimmed.StartsWith("let ", StringComparison.Ordinal) - || trimmed.StartsWith("type ", StringComparison.Ordinal) - || trimmed.StartsWith("impl ", StringComparison.Ordinal)) - { - return; - } - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); - if (typeEnd <= typeStart) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - container); - } - - private static int SkipRustTupleFieldPrefix(string fragment) - { - var index = 0; - while (index < fragment.Length && char.IsWhiteSpace(fragment[index])) - index++; - - if (index + 3 > fragment.Length - || string.CompareOrdinal(fragment, index, "pub", 0, "pub".Length) != 0) - { - return index; - } - - var afterPub = index + "pub".Length; - if (afterPub < fragment.Length && IsRustIdentifierPart(fragment[afterPub])) - return index; - - index = afterPub; - while (index < fragment.Length && char.IsWhiteSpace(fragment[index])) - index++; - - if (index < fragment.Length && fragment[index] == '(') - { - var closeParen = ReferenceExtractor.FindMatchingChar(fragment, index, '(', ')'); - if (closeParen < 0) - return fragment.Length; - - index = closeParen + 1; - while (index < fragment.Length && char.IsWhiteSpace(fragment[index])) - index++; - } - - return index; - } - - private static bool IsRustIdentifierPart(char ch) => - ch == '_' || ch == '$' || char.IsLetterOrDigit(ch); - - private static bool IsRustIdentifierStart(char ch) => - ch == '_' || char.IsLetter(ch); - - private static bool IsRustLifetimeStart(char ch) => - ch == '_' || char.IsLetter(ch); - - private static bool IsRustLifetimePart(char ch) => - ch == '_' || char.IsLetterOrDigit(ch); - - private static int SkipWhitespace(string text, int index) - { - while (index < text.Length && char.IsWhiteSpace(text[index])) - index++; - - return index; - } - - private static void EmitEnumVariantTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? enumContainer) - { - if (enumContainer?.Kind != "enum") - return; - if (preparedLine.IndexOf('(') < 0 - && preparedLine.IndexOf('{') < 0) - { - return; - } - - var variantStart = FirstNonWhitespaceIndex(preparedLine); - if (variantStart >= preparedLine.Length - || preparedLine[variantStart] is '}' or '#' - || !IsLikelyRustEnumVariantStart(preparedLine, variantStart)) - { - return; - } - - var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '(', variantStart); - var openBrace = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '{', variantStart); - if (openParen >= 0 && (openBrace < 0 || openParen < openBrace)) - { - EmitEnumTupleVariantTypeReferences(preparedLine, openParen, references, seen, fileId, context, lineNumber, enumContainer); - } - - if (openBrace >= 0) - EmitEnumStructVariantTypeReferences(preparedLine, openBrace, references, seen, fileId, context, lineNumber, enumContainer); - } - - private static void EmitEnumTupleVariantTypeReferences( - string preparedLine, - int openParen, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord enumContainer) - { - var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); - if (closeParen <= openParen) - return; - - var fieldList = preparedLine.AsSpan(openParen + 1, closeParen - openParen - 1); - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(fieldList)) - { - var fragment = fieldList.Slice(segmentStart, segmentLength).ToString(); - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, 0); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart); - if (typeEnd <= typeStart) - continue; - - var absoluteStart = openParen + 1 + segmentStart + typeStart; - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - fragment.Substring(typeStart, typeEnd - typeStart), - absoluteStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - enumContainer); - } - } - - private static void EmitEnumStructVariantTypeReferences( - string preparedLine, - int openBrace, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord enumContainer) - { - var closeBrace = ReferenceExtractor.FindMatchingChar(preparedLine, openBrace, '{', '}'); - if (closeBrace <= openBrace) - return; - - var fieldList = preparedLine.Substring(openBrace + 1, closeBrace - openBrace - 1); - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(fieldList)) - { - var fragment = fieldList.Substring(segmentStart, segmentLength); - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(fragment, ':'); - if (colonIndex < 0) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, colonIndex + 1); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart); - if (typeEnd <= typeStart) - continue; - - var absoluteStart = openBrace + 1 + segmentStart + typeStart; - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - fragment.Substring(typeStart, typeEnd - typeStart), - absoluteStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - enumContainer); - } - } - - private static int FirstNonWhitespaceIndex(string text) - { - var index = 0; - while (index < text.Length && char.IsWhiteSpace(text[index])) - index++; - - return index; - } - - private static bool IsLikelyRustEnumVariantStart(string line, int startIndex) - { - if (startIndex < line.Length && char.IsUpper(line[startIndex])) - return true; - - return startIndex + 2 < line.Length - && line[startIndex] == 'r' - && line[startIndex + 1] == '#' - && IsRustIdentifierPart(line[startIndex + 2]); - } - - private static void EmitAsCastTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf("as", StringComparison.Ordinal) < 0) - return; - - var trimmed = preparedLine.TrimStart(); - if (trimmed.StartsWith("use ", StringComparison.Ordinal) - || trimmed.StartsWith("pub use ", StringComparison.Ordinal) - || trimmed.StartsWith("extern crate ", StringComparison.Ordinal) - || trimmed.StartsWith("pub extern crate ", StringComparison.Ordinal)) - { - return; - } - - foreach (var asIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "as")) - { - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, asIndex + "as".Length); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); - if (typeEnd <= typeStart) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - } - - private static void EmitAssociatedCallReceiverTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf("::", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('(') < 0) - { - return; - } - - foreach (Match match in AssociatedCallReceiverRegex.Matches(preparedLine)) - { - var receiverGroup = match.Groups["receiver"]; - var receiver = receiverGroup.Value; - var leafStart = receiver.LastIndexOf("::", StringComparison.Ordinal); - var leaf = leafStart >= 0 ? receiver[(leafStart + 2)..] : receiver; - var leafOffset = leafStart >= 0 ? leafStart + 2 : 0; - var normalizedLeaf = NormalizeIdentifier(leaf); - if (normalizedLeaf == "Self" || !IsLikelyRustTypePathLeaf(leaf)) - continue; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - normalizedLeaf, - receiverGroup.Index + leafOffset, - "type_reference", - context, - lineNumber, - resolveContainerForColumn(receiverGroup.Index)); - - var argsGroup = match.Groups["args"]; - if (!argsGroup.Success) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - argsGroup.Value, - argsGroup.Index, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(argsGroup.Index)); - } - } - - private static void EmitAssociatedValueReceiverTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf("::", StringComparison.Ordinal) < 0) - { - return; - } - - foreach (Match match in AssociatedValueReceiverRegex.Matches(preparedLine)) - { - var receiverGroup = match.Groups["receiver"]; - var receiver = receiverGroup.Value; - var leafStart = receiver.LastIndexOf("::", StringComparison.Ordinal); - var leaf = leafStart >= 0 ? receiver[(leafStart + 2)..] : receiver; - var leafOffset = leafStart >= 0 ? leafStart + 2 : 0; - var normalizedLeaf = NormalizeIdentifier(leaf); - if (normalizedLeaf == "Self" || !IsLikelyRustTypePathLeaf(leaf)) - continue; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - normalizedLeaf, - receiverGroup.Index + leafOffset, - "type_reference", - context, - lineNumber, - resolveContainerForColumn(receiverGroup.Index)); - - var argsGroup = match.Groups["args"]; - if (!argsGroup.Success) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - argsGroup.Value, - argsGroup.Index, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(argsGroup.Index)); - } - } - - private static void EmitQualifiedAssociatedCallReceiverTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf('<') < 0 - || preparedLine.IndexOf("::", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('(') < 0) - { - return; - } - - var searchIndex = 0; - while (searchIndex < preparedLine.Length) - { - var openAngle = preparedLine.IndexOf('<', searchIndex); - if (openAngle < 0) - return; - - searchIndex = openAngle + 1; - var closeAngle = ReferenceExtractor.FindMatchingChar(preparedLine, openAngle, '<', '>'); - if (closeAngle <= openAngle) - continue; - - var afterClose = SkipWhitespace(preparedLine, closeAngle + 1); - if (afterClose + 2 > preparedLine.Length - || preparedLine[afterClose] != ':' - || preparedLine[afterClose + 1] != ':') - { - continue; - } - - var methodStart = SkipWhitespace(preparedLine, afterClose + 2); - if (methodStart >= preparedLine.Length || !IsRustIdentifierStart(preparedLine[methodStart])) - continue; - - var methodEnd = methodStart + 1; - while (methodEnd < preparedLine.Length && IsRustIdentifierPart(preparedLine[methodEnd])) - methodEnd++; - - var callOpen = SkipWhitespace(preparedLine, methodEnd); - if (callOpen >= preparedLine.Length || preparedLine[callOpen] != '(') - continue; - - var qualified = preparedLine.Substring(openAngle + 1, closeAngle - openAngle - 1); - foreach (var asIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(qualified, "as")) - { - EmitQualifiedAssociatedCallTypePart( - qualified, - openAngle + 1, - 0, - asIndex, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - EmitQualifiedAssociatedCallTypePart( - qualified, - openAngle + 1, - asIndex + "as".Length, - qualified.Length, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - break; - } - } - } - - private static void EmitQualifiedAssociatedCallTypePart( - string qualified, - int qualifiedStart, - int partStart, - int partEnd, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(qualified, partStart); - while (partEnd > typeStart && char.IsWhiteSpace(qualified[partEnd - 1])) - partEnd--; - if (partEnd <= typeStart) - return; - - var absoluteStart = qualifiedStart + typeStart; - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - qualified.Substring(typeStart, partEnd - typeStart), - absoluteStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(absoluteStart)); - } - - private static bool IsLikelyRustTypePathLeaf(string leaf) - { - if (leaf.StartsWith("r#", StringComparison.Ordinal)) - return leaf.Length > 2 && IsRustIdentifierPart(leaf[2]); - - return leaf.Length > 0 && char.IsUpper(leaf[0]); - } - - private static void EmitStructLiteralInstantiationReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - SymbolRecord? enumContainer) - { - if (enumContainer != null - || preparedLine.IndexOf('{') < 0 - || IsRustTypeDeclarationLine(preparedLine)) - { - return; - } - - foreach (Match match in StructLiteralRegex.Matches(preparedLine)) - { - var nameGroup = match.Groups["name"]; - var name = nameGroup.Value; - var leafStart = name.LastIndexOf("::", StringComparison.Ordinal); - var leaf = leafStart >= 0 ? name[(leafStart + 2)..] : name; - var leafOffset = leafStart >= 0 ? leafStart + 2 : 0; - var normalizedLeaf = NormalizeIdentifier(leaf); - if (normalizedLeaf == "Self" || !IsLikelyRustTypePathLeaf(leaf)) - continue; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - normalizedLeaf, - nameGroup.Index + leafOffset, - "instantiate", - context, - lineNumber, - resolveContainerForColumn(nameGroup.Index)); - - var argsGroup = match.Groups["args"]; - if (!argsGroup.Success) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - argsGroup.Value, - argsGroup.Index, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(argsGroup.Index)); - } - } - - private static bool IsRustTypeDeclarationLine(string line) - { - var trimmed = line.TrimStart(); - if (trimmed.StartsWith("pub", StringComparison.Ordinal)) - { - var afterPub = "pub".Length; - if (afterPub < trimmed.Length && trimmed[afterPub] == '(') - { - var closeParen = ReferenceExtractor.FindMatchingChar(trimmed, afterPub, '(', ')'); - if (closeParen > afterPub) - trimmed = trimmed[(closeParen + 1)..].TrimStart(); - } - else if (afterPub < trimmed.Length && char.IsWhiteSpace(trimmed[afterPub])) - { - trimmed = trimmed[afterPub..].TrimStart(); - } - } - - return trimmed.StartsWith("struct ", StringComparison.Ordinal) - || trimmed.StartsWith("enum ", StringComparison.Ordinal) - || trimmed.StartsWith("union ", StringComparison.Ordinal); - } - - private static void EmitImplAndTraitTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var hasImplMarker = preparedLine.IndexOf("impl", StringComparison.Ordinal) >= 0; - var hasTraitMarker = preparedLine.IndexOf("trait", StringComparison.Ordinal) >= 0; - if (!hasImplMarker && !hasTraitMarker) - return; - - if (hasImplMarker) - { - var implIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "impl"); - if (implIndex >= 0) - { - var typeListStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, implIndex + "impl".Length); - var forIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "for"); - var typeListEnd = forIndex >= 0 - ? forIndex - : TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeListStart); - - if (typeListEnd > typeListStart) - { - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeListStart, typeListEnd - typeListStart), - typeListStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeListStart)); - } - - if (forIndex >= 0) - { - var targetStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, forIndex + "for".Length); - var targetEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, targetStart); - if (targetEnd > targetStart) - { - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(targetStart, targetEnd - targetStart), - targetStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(targetStart)); - } - } - } - } - - if (!hasTraitMarker || preparedLine.IndexOf(':') < 0) - return; - - var traitIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "trait"); - if (traitIndex < 0) - return; - - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', traitIndex + "trait".Length); - if (colonIndex < 0) - return; - - var boundsStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); - var boundsEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, boundsStart); - if (boundsEnd <= boundsStart) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(boundsStart, boundsEnd - boundsStart), - boundsStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(boundsStart)); - - var fullBoundsEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, boundsStart, stopAtArrow: false); - if (fullBoundsEnd > boundsStart) - { - EmitFunctionTraitReturnTypeFromExpression( - preparedLine.Substring(boundsStart, fullBoundsEnd - boundsStart), - boundsStart, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - } - - private static void EmitGenericBoundReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var hasGenericMarker = preparedLine.IndexOf('<') >= 0; - var hasWhereMarker = preparedLine.IndexOf("where", StringComparison.Ordinal) >= 0; - if (!hasGenericMarker && !hasWhereMarker) - return; - - var genericOpenIndex = hasGenericMarker - ? TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '<') - : -1; - if (genericOpenIndex >= 0) - { - var constGenericNames = EmitConstGenericParameterReferences( - preparedLine, - genericOpenIndex, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - EmitConstGenericUsageReferences( - preparedLine, - genericOpenIndex, - constGenericNames, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - TypedLanguageReferenceExtractor.EmitGenericColonBoundReferences( - preparedLine, - genericOpenIndex, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - EmitGenericDefaultTypeReferences( - preparedLine, - genericOpenIndex, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - EmitGenericFunctionTraitReturnTypeReferences( - preparedLine, - genericOpenIndex, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - - if (!hasWhereMarker) - return; - - TypedLanguageReferenceExtractor.EmitWhereClauseTypeReferences( - preparedLine, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - EmitWhereClauseConstGenericReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - EmitWhereClauseFunctionTraitReturnTypeReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - - private static HashSet EmitConstGenericParameterReferences( - string preparedLine, - int genericOpenIndex, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var constGenericNames = new HashSet(StringComparer.Ordinal); - var genericCloseIndex = FindRustGenericClose(preparedLine, genericOpenIndex); - if (genericCloseIndex <= genericOpenIndex) - return constGenericNames; - - var clause = preparedLine.Substring(genericOpenIndex + 1, genericCloseIndex - genericOpenIndex - 1); - EmitConstGenericSegments( - clause, - genericOpenIndex + 1, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - constGenericNames); - return constGenericNames; - } - - private static void EmitConstGenericUsageReferences( - string preparedLine, - int genericOpenIndex, - HashSet constGenericNames, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (constGenericNames.Count == 0) - return; - - var genericCloseIndex = FindRustGenericClose(preparedLine, genericOpenIndex); - if (genericCloseIndex <= genericOpenIndex) - return; - - for (var index = genericCloseIndex + 1; index < preparedLine.Length; index++) - { - if (!IsRustIdentifierStart(preparedLine[index])) - continue; - - var end = index + 1; - while (end < preparedLine.Length && IsRustIdentifierPart(preparedLine[end])) - end++; - - var name = preparedLine.Substring(index, end - index); - if (constGenericNames.Contains(name)) - { - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name, - index, - "const_generic_reference", - context, - lineNumber, - resolveContainerForColumn(index)); - } - - index = end - 1; - } - } - - private static void EmitWhereClauseConstGenericReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - foreach (var whereIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "where")) - { - var clauseStart = whereIndex + "where".Length; - var clauseEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, clauseStart, stopAtComma: false, stopAtArrow: false); - if (clauseEnd <= clauseStart) - clauseEnd = preparedLine.Length; - - EmitConstGenericSegments( - preparedLine.Substring(clauseStart, clauseEnd - clauseStart), - clauseStart, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - } - - private static void EmitConstGenericSegments( - string clause, - int clauseStart, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - HashSet? constGenericNames = null) - { - if (clause.IndexOf("const", StringComparison.Ordinal) < 0 - || clause.IndexOf(':') < 0) - { - return; - } - - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(clause)) - { - var fragment = clause.Substring(segmentStart, segmentLength); - var match = ConstGenericParameterRegex.Match(fragment); - if (!match.Success) - continue; - - var nameGroup = match.Groups["name"]; - var name = NormalizeIdentifier(nameGroup.Value); - constGenericNames?.Add(name); - var absoluteNameStart = clauseStart + segmentStart + nameGroup.Index; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name, - absoluteNameStart, - "const_generic_reference", - context, - lineNumber, - resolveContainerForColumn(absoluteNameStart)); - - var typeGroup = match.Groups["type"]; - var typeMatch = ConstGenericTypeHeadRegex.Match(typeGroup.Value); - if (!typeMatch.Success) - continue; - - var typeNameGroup = typeMatch.Groups["name"]; - var absoluteTypeStart = clauseStart + segmentStart + typeGroup.Index + typeNameGroup.Index; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - NormalizeIdentifier(typeNameGroup.Value), - absoluteTypeStart, - "annotation", - context, - lineNumber, - resolveContainerForColumn(absoluteTypeStart)); - } - } - - private static void EmitGenericFunctionTraitReturnTypeReferences( - string preparedLine, - int genericOpenIndex, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf("->", StringComparison.Ordinal) < 0) - return; - - var genericCloseIndex = FindRustGenericClose(preparedLine, genericOpenIndex); - if (genericCloseIndex <= genericOpenIndex) - return; - - var clause = preparedLine.Substring(genericOpenIndex + 1, genericCloseIndex - genericOpenIndex - 1); - EmitFunctionTraitReturnTypesFromBoundClause( - clause, - genericOpenIndex + 1, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - - private static void EmitWhereClauseFunctionTraitReturnTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf("->", StringComparison.Ordinal) < 0) - return; - - foreach (var whereIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "where")) - { - var clauseStart = whereIndex + "where".Length; - var clauseEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, clauseStart, stopAtComma: false, stopAtArrow: false); - if (clauseEnd <= clauseStart) - clauseEnd = preparedLine.Length; - - EmitFunctionTraitReturnTypesFromBoundClause( - preparedLine.Substring(clauseStart, clauseEnd - clauseStart), - clauseStart, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - } - - private static void EmitFunctionTraitReturnTypesFromBoundClause( - string clause, - int clauseStart, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (clause.IndexOf("->", StringComparison.Ordinal) < 0 - || clause.IndexOf(':') < 0) - { - return; - } - - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(clause)) - { - var fragment = clause.Substring(segmentStart, segmentLength); - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(fragment, ':'); - if (colonIndex < 0) - continue; - - var arrowIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(fragment, "->", colonIndex + 1); - if (arrowIndex < 0) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, arrowIndex + 2); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart, stopAtArrow: false); - if (typeEnd <= typeStart) - continue; - - var absoluteStart = clauseStart + segmentStart + typeStart; - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - fragment.Substring(typeStart, typeEnd - typeStart), - absoluteStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(absoluteStart)); - } - } - - private static void EmitFunctionTraitReturnTypeFromExpression( - string expression, - int expressionStart, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (expression.IndexOf("->", StringComparison.Ordinal) < 0) - return; - - var arrowIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(expression, "->"); - if (arrowIndex < 0) - return; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(expression, arrowIndex + 2); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(expression, typeStart, stopAtArrow: false); - if (typeEnd <= typeStart) - return; - - var absoluteStart = expressionStart + typeStart; - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - expression.Substring(typeStart, typeEnd - typeStart), - absoluteStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(absoluteStart)); - } - - private static void EmitGenericDefaultTypeReferences( - string preparedLine, - int genericOpenIndex, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf('=') < 0) - return; - - var genericCloseIndex = FindRustGenericClose(preparedLine, genericOpenIndex); - if (genericCloseIndex <= genericOpenIndex) - return; - - var clause = preparedLine.Substring(genericOpenIndex + 1, genericCloseIndex - genericOpenIndex - 1); - if (clause.IndexOf('=') < 0) - return; - - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(clause)) - { - var fragment = clause.Substring(segmentStart, segmentLength); - var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(fragment, '='); - if (assignmentIndex < 0) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, assignmentIndex + 1); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart); - if (typeEnd <= typeStart) - continue; - - var absoluteStart = genericOpenIndex + 1 + segmentStart + typeStart; - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - fragment.Substring(typeStart, typeEnd - typeStart), - absoluteStart, - "rust", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(absoluteStart)); - } - } - - private static int FindRustGenericClose(string text, int openIndex) - { - var depth = 0; - for (var index = openIndex; index < text.Length; index++) - { - if (text[index] == '-' && index + 1 < text.Length && text[index + 1] == '>') - { - index++; - continue; - } - - if (text[index] == '<') - { - depth++; - continue; - } - - if (text[index] != '>') - continue; - - depth--; - if (depth == 0) - return index; - } - - return -1; - } - - public static string NormalizeIdentifier(string identifier) - { - if (identifier.Length == 0) - return identifier; - - if (!identifier.Contains("r#", StringComparison.Ordinal)) - return identifier; - - if (!identifier.Contains("::", StringComparison.Ordinal)) - return identifier.StartsWith("r#", StringComparison.Ordinal) - ? identifier[2..] - : identifier; - - var builder = new StringBuilder(identifier.Length); - var segmentStart = 0; - while (segmentStart <= identifier.Length) - { - var separator = identifier.IndexOf("::", segmentStart, StringComparison.Ordinal); - var segmentEnd = separator >= 0 ? separator : identifier.Length; - AppendNormalizedRustIdentifierSegment(builder, identifier, segmentStart, segmentEnd - segmentStart); - if (separator < 0) - break; - - builder.Append("::"); - segmentStart = separator + 2; - } - - return builder.ToString(); - } - - private static void AppendNormalizedRustIdentifierSegment(StringBuilder builder, string identifier, int start, int length) - { - if (length >= 2 - && identifier[start] == 'r' - && identifier[start + 1] == '#') - { - start += 2; - length -= 2; - } - - builder.Append(identifier, start, length); - } - - public static bool IsFunctionDeclarationCallSite(string line, int callIndex) - { - if (callIndex <= 0) - return false; - - var prefix = line.AsSpan(0, callIndex).TrimEnd(); - return prefix.EndsWith("fn", StringComparison.Ordinal); - } - - public static bool IsDeriveAttributeCallSite(string line, string name, int callIndex) - { - if (!string.Equals(name, "derive", StringComparison.Ordinal) || callIndex <= 0) - return false; - - var index = callIndex - 1; - while (index >= 0 && char.IsWhiteSpace(line[index])) - index--; - - if (index < 0 || line[index] != '[') - return false; - - index--; - while (index >= 0 && char.IsWhiteSpace(line[index])) - index--; - - if (index >= 0 && line[index] == '!') - { - index--; - while (index >= 0 && char.IsWhiteSpace(line[index])) - index--; - } - - return index >= 0 && line[index] == '#'; - } - - public static bool IsLikelyInstantiationCallName(string originalName, string normalizedName, string line, int callIndex) - { - var normalizedLeaf = LastPathSegment(normalizedName); - var originalLeaf = LastPathSegment(originalName); - if (!IsLikelyRustTypePathLeaf(originalLeaf) && !IsLikelyRustTypePathLeaf(normalizedLeaf)) - return false; - - var afterName = callIndex + originalName.Length; - while (afterName < line.Length && char.IsWhiteSpace(line[afterName])) - afterName++; - - if (afterName >= line.Length) - return false; - - if (line[afterName] == '!') - return false; - - return line[afterName] is '(' or '<' - || (afterName + 1 < line.Length && line[afterName] == ':' && line[afterName + 1] == ':'); - } - - private static string LastPathSegment(string name) - { - var leafStart = name.LastIndexOf("::", StringComparison.Ordinal); - return leafStart >= 0 ? name[(leafStart + 2)..] : name; - } - - public static bool IsRawIdentifierPrefix(string line, int callIndex) => - callIndex >= 2 - && line[callIndex - 2] == 'r' - && line[callIndex - 1] == '#'; } diff --git a/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.ConstAssertions.cs b/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.ConstAssertions.cs new file mode 100644 index 000000000..3e6251014 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.ConstAssertions.cs @@ -0,0 +1,640 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class TypeScriptReferenceExtractor +{ + private static void EmitAsTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + foreach (var asIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "as")) + { + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, asIndex + "as".Length); + if (typeStart >= preparedLine.Length || TryConsumeKeywordAt(preparedLine, "const", typeStart)) + continue; + + var typeEnd = TypedLanguageReferenceExtractor.FindKeywordFollowingTypeExpressionEnd(preparedLine, typeStart, "typescript"); + if (typeEnd <= typeStart) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "typescript", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + } + + private static void EmitConstAssertionReferences( + IReadOnlyList preparedLines, + IReadOnlyList rawLines, + int lineIndex, + string preparedLine, + string rawLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + foreach (var asIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "as")) + { + var constIndex = SkipWhitespace(preparedLine, asIndex + "as".Length); + if (!TryConsumeKeywordAt(preparedLine, "const", constIndex)) + continue; + + var rawAsIndex = rawLine.IndexOf(" as const", Math.Min(asIndex, rawLine.Length), StringComparison.Ordinal); + if (rawAsIndex < 0) + rawAsIndex = asIndex; + else + rawAsIndex++; + var rawConstIndex = SkipWhitespace(rawLine, rawAsIndex + "as".Length); + ReferenceExtractor.AddReference( + references, + seen, + fileId, + "const", + rawConstIndex, + "const_assertion", + context, + lineNumber, + resolveContainerForColumn(asIndex)); + + EmitConstAssertionLiteralTypeReferences( + preparedLines, + rawLines, + lineIndex, + asIndex, + rawAsIndex, + references, + seen, + fileId, + resolveContainerForColumn); + } + } + + private static void EmitConstAssertionLiteralTypeReferences( + IReadOnlyList preparedLines, + IReadOnlyList rawLines, + int assertionLineIndex, + int preparedAsIndex, + int asIndex, + List references, + ReferenceDedupeSet seen, + long fileId, + Func resolveContainerForColumn) + { + if (!TryFindConstAssertionLiteralOpen( + preparedLines, + assertionLineIndex, + preparedAsIndex, + out var literalOpenLineIndex, + out var literalOpenColumn)) + { + return; + } + + var insideBlockComment = false; + for (var currentLineIndex = literalOpenLineIndex; currentLineIndex <= assertionLineIndex; currentLineIndex++) + { + var rawLine = rawLines[currentLineIndex]; + var scanStart = currentLineIndex == literalOpenLineIndex ? literalOpenColumn + 1 : 0; + var scanEnd = currentLineIndex == assertionLineIndex ? Math.Min(asIndex, rawLine.Length) : rawLine.Length; + if (scanStart >= scanEnd) + continue; + + for (var index = scanStart; index < scanEnd; index++) + { + if (SkipConstAssertionComment(rawLine, scanEnd, ref index, ref insideBlockComment)) + continue; + + if (rawLine[index] is '"' or '\'' or '`') + { + var literalStart = index; + index = SkipQuotedLiteral(rawLine, index); + if (index <= literalStart + 1 + || !HasStandaloneConstAssertionLiteralBoundaries( + rawLines, + literalOpenLineIndex, + literalOpenColumn, + assertionLineIndex, + asIndex, + currentLineIndex, + literalStart, + index + 1)) + { + continue; + } + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + rawLine.Substring(literalStart, index - literalStart + 1), + literalStart, + "type_reference", + rawLine.Trim(), + currentLineIndex + 1, + ResolveConstAssertionLiteralContainer( + currentLineIndex, + assertionLineIndex, + literalStart, + resolveContainerForColumn)); + continue; + } + + if (IsNumberLiteralStart(rawLine, index)) + { + var literalStart = index; + index = SkipNumberLiteral(rawLine, index); + if (!HasStandaloneConstAssertionLiteralBoundaries( + rawLines, + literalOpenLineIndex, + literalOpenColumn, + assertionLineIndex, + asIndex, + currentLineIndex, + literalStart, + index)) + { + index--; + continue; + } + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + rawLine.Substring(literalStart, index - literalStart), + literalStart, + "type_reference", + rawLine.Trim(), + currentLineIndex + 1, + ResolveConstAssertionLiteralContainer( + currentLineIndex, + assertionLineIndex, + literalStart, + resolveContainerForColumn)); + index--; + continue; + } + + if (!TryReadLiteralKeyword(rawLine, index, scanEnd, out var keyword)) + continue; + if (!HasStandaloneConstAssertionLiteralBoundaries( + rawLines, + literalOpenLineIndex, + literalOpenColumn, + assertionLineIndex, + asIndex, + currentLineIndex, + index, + index + keyword.Length)) + { + continue; + } + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + keyword, + index, + "type_reference", + rawLine.Trim(), + currentLineIndex + 1, + ResolveConstAssertionLiteralContainer( + currentLineIndex, + assertionLineIndex, + index, + resolveContainerForColumn)); + index += keyword.Length - 1; + } + } + } + + private static bool SkipConstAssertionComment(string line, int scanEnd, ref int index, ref bool insideBlockComment) + { + if (insideBlockComment) + { + var end = line.IndexOf("*/", index, Math.Max(0, scanEnd - index), StringComparison.Ordinal); + if (end < 0) + { + index = scanEnd; + return true; + } + + index = end + 1; + insideBlockComment = false; + return true; + } + + if (index + 1 >= scanEnd || line[index] != '/') + return false; + + if (line[index + 1] == '/') + { + index = scanEnd; + return true; + } + + if (line[index + 1] != '*') + return false; + + insideBlockComment = true; + index++; + return true; + } + + private static bool HasStandaloneConstAssertionLiteralBoundaries( + IReadOnlyList preparedLines, + int literalOpenLineIndex, + int literalOpenColumn, + int assertionLineIndex, + int preparedAsIndex, + int literalLineIndex, + int literalStartColumn, + int literalEndColumn) + { + var previous = FindPreviousNonWhitespace( + preparedLines, + literalOpenLineIndex, + literalOpenColumn, + literalLineIndex, + literalStartColumn); + var next = FindNextNonWhitespace( + preparedLines, + literalLineIndex, + literalEndColumn, + assertionLineIndex, + preparedAsIndex); + + if (previous == ':' && HasQuestionBeforeLiteralValue(rawLines: preparedLines, literalLineIndex, literalStartColumn)) + return false; + + return previous is '[' or '{' or ':' or ',' + && next is ',' or ']' or '}'; + } + + private static bool HasQuestionBeforeLiteralValue( + IReadOnlyList rawLines, + int literalLineIndex, + int literalStartColumn) + { + var line = rawLines[literalLineIndex]; + for (var index = literalStartColumn - 1; index >= 0; index--) + { + if (line[index] == '?') + return true; + + if (line[index] is ',' or '{' or '[') + return false; + } + + return false; + } + + private static char? FindPreviousNonWhitespace( + IReadOnlyList lines, + int minLineIndex, + int minColumn, + int lineIndex, + int column) + { + for (var currentLineIndex = lineIndex; currentLineIndex >= minLineIndex; currentLineIndex--) + { + var line = lines[currentLineIndex]; + var index = currentLineIndex == lineIndex ? column - 1 : line.Length - 1; + var stop = currentLineIndex == minLineIndex ? minColumn : 0; + var lineCommentStart = IndexOfLineCommentOutsideString(line, stop, Math.Max(0, index - stop + 1)); + if (lineCommentStart >= 0) + index = lineCommentStart - 1; + for (; index >= stop; index--) + { + if (line[index] == '/' && index > stop && line[index - 1] == '*') + { + var commentStart = line.LastIndexOf("/*", index - 1, index - stop, StringComparison.Ordinal); + if (commentStart >= 0) + { + index = commentStart; + continue; + } + } + + if (!char.IsWhiteSpace(line[index])) + return line[index]; + } + } + + return null; + } + + private static int IndexOfLineCommentOutsideString(string line, int startIndex, int count) + { + var endIndex = Math.Min(line.Length, startIndex + count); + char? quote = null; + for (var index = startIndex; index + 1 < endIndex; index++) + { + if (quote is char activeQuote) + { + if (line[index] == '\\') + { + index++; + continue; + } + + if (line[index] == activeQuote) + quote = null; + continue; + } + + if (line[index] is '"' or '\'' or '`') + { + quote = line[index]; + continue; + } + + if (line[index] == '/' && line[index + 1] == '/') + return index; + } + + return -1; + } + + private static char? FindNextNonWhitespace( + IReadOnlyList lines, + int lineIndex, + int column, + int maxLineIndex, + int maxColumn) + { + for (var currentLineIndex = lineIndex; currentLineIndex <= maxLineIndex; currentLineIndex++) + { + var line = lines[currentLineIndex]; + var index = currentLineIndex == lineIndex ? column : 0; + var stop = currentLineIndex == maxLineIndex ? Math.Min(maxColumn, line.Length) : line.Length; + for (; index < stop; index++) + { + if (index + 1 < stop && line[index] == '/' && line[index + 1] == '*') + { + var commentEnd = line.IndexOf("*/", index + 2, stop - index - 2, StringComparison.Ordinal); + if (commentEnd < 0) + return null; + + index = commentEnd + 1; + continue; + } + + if (index + 1 < stop && line[index] == '/' && line[index + 1] == '/') + return null; + + if (!char.IsWhiteSpace(line[index])) + return line[index]; + } + } + + return null; + } + + private static SymbolRecord? ResolveConstAssertionLiteralContainer( + int literalLineIndex, + int assertionLineIndex, + int column, + Func resolveContainerForColumn) + { + return literalLineIndex == assertionLineIndex ? resolveContainerForColumn(column) : null; + } + + private static bool TryFindConstAssertionLiteralOpen( + IReadOnlyList preparedLines, + int assertionLineIndex, + int asIndex, + out int openLineIndex, + out int openColumn) + { + openLineIndex = -1; + openColumn = -1; + for (var lineIndex = assertionLineIndex; lineIndex >= 0; lineIndex--) + { + var line = preparedLines[lineIndex]; + var index = lineIndex == assertionLineIndex ? asIndex - 1 : line.Length - 1; + for (; index >= 0; index--) + { + if (char.IsWhiteSpace(line[index])) + continue; + + if (line[index] is ']' or '}') + { + var openChar = line[index] == ']' ? '[' : '{'; + return TryFindMatchingOpenChar( + preparedLines, + lineIndex, + index, + openChar, + line[index], + out openLineIndex, + out openColumn); + } + + return false; + } + } + + return false; + } + + private static bool TryFindMatchingOpenChar( + IReadOnlyList lines, + int closeLineIndex, + int closeColumn, + char openChar, + char closeChar, + out int openLineIndex, + out int openColumn) + { + openLineIndex = -1; + openColumn = -1; + var depth = 0; + for (var lineIndex = closeLineIndex; lineIndex >= 0; lineIndex--) + { + var line = lines[lineIndex]; + var index = lineIndex == closeLineIndex ? closeColumn : line.Length - 1; + for (; index >= 0; index--) + { + if (line[index] == closeChar) + { + depth++; + continue; + } + + if (line[index] != openChar) + continue; + + depth--; + if (depth == 0) + { + openLineIndex = lineIndex; + openColumn = index; + return true; + } + } + } + + return false; + } + + private static int SkipQuotedLiteral(string text, int quoteIndex) + { + var quote = text[quoteIndex]; + for (var index = quoteIndex + 1; index < text.Length; index++) + { + if (text[index] == '\\') + { + index++; + continue; + } + + if (text[index] == quote) + return index; + } + + return text.Length - 1; + } + + private static bool IsNumberLiteralStart(string text, int index) + { + if (index >= text.Length) + return false; + + var startsWithDigit = char.IsDigit(text[index]); + var startsWithNegativeSign = text[index] == '-' + && index + 1 < text.Length + && char.IsDigit(text[index + 1]); + if (!startsWithDigit && !startsWithNegativeSign) + return false; + + return index == 0 || !IsTypeScriptIdentifierPart(text[index - 1]); + } + + private static int SkipNumberLiteral(string text, int index) + { + if (index < text.Length && text[index] == '-') + index++; + + if (index + 1 < text.Length + && text[index] == '0' + && text[index + 1] is 'x' or 'X' or 'b' or 'B' or 'o' or 'O') + { + var radixPrefix = text[index + 1]; + index += 2; + while (index < text.Length && (IsRadixDigit(text[index], radixPrefix) || text[index] == '_')) + index++; + + if (index < text.Length && text[index] == 'n') + index++; + + return index; + } + + while (index < text.Length && (char.IsDigit(text[index]) || text[index] == '_')) + index++; + + if (index < text.Length && text[index] == '.') + { + index++; + while (index < text.Length && (char.IsDigit(text[index]) || text[index] == '_')) + index++; + } + + if (index < text.Length && text[index] is 'e' or 'E') + { + var exponentIndex = index + 1; + if (exponentIndex < text.Length && text[exponentIndex] is '+' or '-') + exponentIndex++; + + var digitStart = exponentIndex; + while (exponentIndex < text.Length && (char.IsDigit(text[exponentIndex]) || text[exponentIndex] == '_')) + exponentIndex++; + + if (exponentIndex > digitStart) + index = exponentIndex; + } + + if (index < text.Length && text[index] == 'n') + index++; + + return index; + } + + private static bool IsRadixDigit(char ch, char radixPrefix) + { + return radixPrefix switch + { + 'x' or 'X' => char.IsAsciiHexDigit(ch), + 'b' or 'B' => ch is '0' or '1', + 'o' or 'O' => ch is >= '0' and <= '7', + _ => false, + }; + } + + private static bool TryReadLiteralKeyword(string text, int index, int endExclusive, out string keyword) + { + foreach (var candidate in LiteralKeywords) + { + if (index + candidate.Length > endExclusive + || string.CompareOrdinal(text, index, candidate, 0, candidate.Length) != 0) + { + continue; + } + + var beforeOk = index == 0 || !IsTypeScriptIdentifierPart(text[index - 1]); + var after = index + candidate.Length; + var afterOk = after >= text.Length || !IsTypeScriptIdentifierPart(text[after]); + if (!beforeOk || !afterOk) + continue; + + keyword = candidate; + return true; + } + + keyword = string.Empty; + return false; + } + + private static bool TryConsumeKeywordAt(string text, string keyword, int index) + { + if (index < 0 || index + keyword.Length > text.Length) + return false; + + if (string.CompareOrdinal(text, index, keyword, 0, keyword.Length) != 0) + return false; + + var beforeOk = index == 0 || !IsTypeScriptIdentifierPart(text[index - 1]); + var after = index + keyword.Length; + var afterOk = after >= text.Length || !IsTypeScriptIdentifierPart(text[after]); + return beforeOk && afterOk; + } + + private static int SkipWhitespace(string text, int index) + { + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + + return index; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.ImportExportSyntax.cs b/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.ImportExportSyntax.cs new file mode 100644 index 000000000..5d9886f25 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.ImportExportSyntax.cs @@ -0,0 +1,280 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class TypeScriptReferenceExtractor +{ + private static bool IsImportExportAliasLine(IReadOnlyList preparedLines, int lineIndex, string preparedLine) + { + var trimmed = preparedLine.TrimStart(); + return IsImportDeclarationLine(trimmed) + || IsNamedExportLine(trimmed) + || IsExportStarAliasLine(trimmed) + || IsInsideMultilineImportExportAlias(preparedLines, lineIndex, preparedLine); + } + + private static bool IsImportDeclarationLine(string text) + { + const string importKeyword = "import"; + if (!text.StartsWith(importKeyword, StringComparison.Ordinal)) + return false; + + var index = importKeyword.Length; + if (index >= text.Length || IsTypeScriptIdentifierPart(text[index])) + return false; + + return char.IsWhiteSpace(text[index]) || text[index] is '{' or '*'; + } + + private static bool IsNamedExportLine(string text) + { + var index = 0; + if (!TryConsumeKeyword(text, "export", ref index)) + return false; + + SkipWhitespace(text, ref index); + if (index < text.Length && text[index] == '{') + return true; + + if (!TryConsumeKeyword(text, "type", ref index)) + return false; + + SkipWhitespace(text, ref index); + return index < text.Length && text[index] == '{'; + } + + private static bool IsExportStarAliasLine(string text) + { + var index = 0; + if (!TryConsumeKeyword(text, "export", ref index)) + return false; + + SkipWhitespace(text, ref index); + if (TryConsumeKeyword(text, "type", ref index)) + SkipWhitespace(text, ref index); + + if (index >= text.Length || text[index] != '*') + return false; + + index++; + SkipWhitespace(text, ref index); + return TryConsumeKeyword(text, "as", ref index); + } + + private static bool IsInsideMultilineImportExportAlias( + IReadOnlyList preparedLines, + int lineIndex, + string preparedLine) + { + var asIndex = -1; + foreach (var keywordIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "as")) + { + asIndex = keywordIndex; + break; + } + + return asIndex >= 0 && IsInsideImportExportBraceAt(preparedLines, lineIndex, asIndex); + } + + private static bool IsInsideImportExportBraceAt(IReadOnlyList preparedLines, int lineIndex, int column) + { + var unmatchedClosingBraces = 0; + for (var currentLine = lineIndex; currentLine >= 0; currentLine--) + { + var line = preparedLines[currentLine]; + var startColumn = currentLine == lineIndex ? Math.Min(column, line.Length) - 1 : line.Length - 1; + for (var index = startColumn; index >= 0; index--) + { + if (line[index] == '}') + { + unmatchedClosingBraces++; + continue; + } + + if (line[index] != '{') + continue; + + if (unmatchedClosingBraces > 0) + { + unmatchedClosingBraces--; + continue; + } + + return IsImportExportOpeningBrace(preparedLines, currentLine, index); + } + } + + return false; + } + + private static int SkipLeadingDecorators(string line) + { + var index = 0; + while (index < line.Length) + { + while (index < line.Length && char.IsWhiteSpace(line[index])) + index++; + + if (index >= line.Length || line[index] != '@') + return index; + + index++; + while (index < line.Length && (IsTypeScriptIdentifierPart(line[index]) || line[index] == '.')) + index++; + + while (index < line.Length && char.IsWhiteSpace(line[index])) + index++; + + if (index < line.Length && line[index] == '(') + { + var closeParen = ReferenceExtractor.FindMatchingChar(line, index, '(', ')'); + if (closeParen < 0) + return index; + + index = closeParen + 1; + } + + while (index < line.Length && char.IsWhiteSpace(line[index])) + index++; + } + + return index; + } + + private static bool IsImportExportOpeningBrace(IReadOnlyList preparedLines, int openLineIndex, int openColumn) + { + var sameLine = preparedLines[openLineIndex]; + var sameLineStart = 0; + while (sameLineStart < openColumn && char.IsWhiteSpace(sameLine[sameLineStart])) + sameLineStart++; + + var sameLineEnd = openColumn; + while (sameLineEnd > sameLineStart && char.IsWhiteSpace(sameLine[sameLineEnd - 1])) + sameLineEnd--; + + if (sameLineEnd > sameLineStart) + { + var sameLinePrefix = sameLine.Substring(sameLineStart, sameLineEnd - sameLineStart); + return IsImportBracePrefix(sameLinePrefix) || IsNamedExportBracePrefix(sameLinePrefix); + } + + for (var lineIndex = openLineIndex - 1; lineIndex >= 0; lineIndex--) + { + var previousLineText = preparedLines[lineIndex]; + var previousLineStart = 0; + while (previousLineStart < previousLineText.Length && char.IsWhiteSpace(previousLineText[previousLineStart])) + previousLineStart++; + + var previousLineEnd = previousLineText.Length; + while (previousLineEnd > previousLineStart && char.IsWhiteSpace(previousLineText[previousLineEnd - 1])) + previousLineEnd--; + + if (previousLineEnd <= previousLineStart) + continue; + + var previousLine = previousLineText.Substring(previousLineStart, previousLineEnd - previousLineStart); + return IsImportBracePrefix(previousLine) || IsNamedExportBracePrefix(previousLine); + } + + return false; + } + + private static bool IsImportBracePrefix(string text) + { + if (text.IndexOf(';') >= 0 || ContainsTopLevelKeyword(text, "from")) + return false; + + var index = 0; + if (!TryConsumeKeyword(text, "import", ref index)) + return false; + + SkipWhitespace(text, ref index); + if (index >= text.Length) + return true; + + if (TryConsumeKeyword(text, "type", ref index)) + { + SkipWhitespace(text, ref index); + if (index >= text.Length) + return true; + } + + var end = text.Length; + while (end > 0 && char.IsWhiteSpace(text[end - 1])) + end--; + + return end > 0 && text[end - 1] == ','; + } + + private static bool ContainsTopLevelKeyword(string text, string keyword) + { + foreach (var _ in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(text, keyword)) + return true; + + return false; + } + + private static bool IsNamedExportBracePrefix(string text) + { + var index = 0; + if (!TryConsumeKeyword(text, "export", ref index)) + return false; + + SkipWhitespace(text, ref index); + if (index >= text.Length) + return true; + + if (!TryConsumeKeyword(text, "type", ref index)) + return false; + + SkipWhitespace(text, ref index); + return index >= text.Length; + } + + private static bool TryConsumeKeyword(string text, string keyword, ref int index) + { + if (index + keyword.Length > text.Length + || string.CompareOrdinal(text, index, keyword, 0, keyword.Length) != 0) + { + return false; + } + + var after = index + keyword.Length; + if (after < text.Length && IsTypeScriptIdentifierPart(text[after])) + return false; + + index = after; + return true; + } + + private static void SkipWhitespace(string text, ref int index) + { + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + } + + private static bool IsTypeScriptIdentifierPart(char ch) => + ch == '_' || ch == '$' || char.IsLetterOrDigit(ch); + + private static bool IsTypeScriptIdentifier(string text) => + IsTypeScriptIdentifier(text.AsSpan()); + + private static bool IsTypeScriptIdentifier(ReadOnlySpan text) + { + if (text.Length == 0 || !IsTypeScriptIdentifierStart(text[0])) + return false; + + for (var index = 1; index < text.Length; index++) + { + if (!IsTypeScriptIdentifierPart(text[index])) + return false; + } + + return true; + } + + private static bool IsTypeScriptIdentifierStart(char ch) => + ch == '_' || ch == '$' || char.IsLetter(ch); +} diff --git a/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.NamespaceAliases.cs b/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.NamespaceAliases.cs new file mode 100644 index 000000000..a15e865b4 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.NamespaceAliases.cs @@ -0,0 +1,301 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class TypeScriptReferenceExtractor +{ + private static void EmitNamespaceAliasQualifiedReferences( + IReadOnlyList preparedLines, + int lineIndex, + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlyList namespaceAliases) + { + if (namespaceAliases.Count == 0 || IsImportExportAliasLine(preparedLines, lineIndex, preparedLine)) + return; + + foreach (var binding in namespaceAliases) + { + if (lineNumber <= binding.BindingLine + || (binding.EndLine is int endLine && lineNumber > endLine) + || (binding.ShadowLine is int shadowLine && lineNumber >= shadowLine) + || IsInsideScopedShadow(binding.ScopedShadowRanges, lineNumber)) + { + continue; + } + + foreach (var matchIndex in EnumerateNamespaceAliasQualifiedReferenceStarts(preparedLine, binding.Alias)) + { + ReferenceExtractor.AddReference( + references, + seen, + fileId, + binding.ModuleSpecifier, + matchIndex, + "reference", + context, + lineNumber, + resolveContainerForColumn(matchIndex)); + } + } + } + + private static IEnumerable EnumerateNamespaceAliasQualifiedReferenceStarts(string text, string alias) + { + if (string.IsNullOrEmpty(alias)) + yield break; + + var searchIndex = 0; + while (searchIndex < text.Length) + { + var aliasIndex = text.IndexOf(alias, searchIndex, StringComparison.Ordinal); + if (aliasIndex < 0) + yield break; + + searchIndex = aliasIndex + Math.Max(1, alias.Length); + if (aliasIndex > 0 && IsTypeScriptIdentifierPart(text[aliasIndex - 1])) + continue; + + var afterAlias = aliasIndex + alias.Length; + if (afterAlias < text.Length && IsTypeScriptIdentifierPart(text[afterAlias])) + continue; + + var dotIndex = SkipWhitespace(text, afterAlias); + if (dotIndex >= text.Length || text[dotIndex] != '.') + continue; + + var memberIndex = SkipWhitespace(text, dotIndex + 1); + if (memberIndex >= text.Length || !IsTypeScriptNamespaceMemberStart(text[memberIndex])) + continue; + + yield return aliasIndex; + } + } + + private static bool IsTypeScriptNamespaceMemberStart(char ch) => + ch == '_' || ch == '$' || ch is >= 'A' and <= 'Z' || ch is >= 'a' and <= 'z'; + + private static IReadOnlyDictionary> BuildLocalDeclarationLinesByName(IReadOnlyList preparedLines) + { + Dictionary>? linesByName = null; + for (var index = 0; index < preparedLines.Count; index++) + { + var line = preparedLines[index]; + if (NamespaceImportExportRegex.IsMatch(line) || DynamicImportNamespaceRegex.IsMatch(line)) + continue; + + var match = LocalDeclarationRegex.Match(line); + if (!match.Success) + continue; + + var name = match.Groups["name"].Value; + linesByName ??= new Dictionary>(16, StringComparer.Ordinal); + if (!linesByName.TryGetValue(name, out var lines)) + { + lines = new List(1); + linesByName[name] = lines; + } + + lines.Add(index + 1); + } + + return linesByName ?? EmptyLocalDeclarationLinesByName; + } + + private static int? FindShadowLine( + IReadOnlyDictionary> localDeclarationLinesByName, + string alias, + int bindingLine) + { + if (!localDeclarationLinesByName.TryGetValue(alias, out var declarationLines)) + return null; + + foreach (var line in declarationLines) + { + if (line > bindingLine) + return line; + } + + return null; + } + + private static int[] BuildBraceDepthsBeforeLine(IReadOnlyList preparedLines) + { + var depths = new int[preparedLines.Count]; + var depth = 0; + for (var index = 0; index < preparedLines.Count; index++) + { + depths[index] = depth; + foreach (var ch in preparedLines[index]) + { + if (ch == '{') + depth++; + else if (ch == '}' && depth > 0) + depth--; + } + } + + return depths; + } + + private static int? FindDynamicImportAliasEndLine( + IReadOnlyList preparedLines, + IReadOnlyList braceDepths, + int bindingLineIndex) + { + var bindingDepth = braceDepths[bindingLineIndex]; + if (bindingDepth <= 0) + return null; + + for (var index = bindingLineIndex + 1; index < preparedLines.Count; index++) + { + if (braceDepths[index] < bindingDepth) + return index; + } + + return preparedLines.Count; + } + + private static IReadOnlyList BuildParameterShadowRanges( + IReadOnlyList preparedLines, + int[] braceDepths, + string alias) + { + List? ranges = null; + for (var index = 0; index < preparedLines.Count; index++) + { + if (!TryGetSingleLineCallableParameters(preparedLines[index], out var parameters) + || !ParameterListDeclaresName(parameters, alias)) + { + continue; + } + + var endLine = FindBlockEndLine(preparedLines, braceDepths, index); + if (endLine >= index + 1) + (ranges ??= new List(2)).Add(new LineRange(index + 1, endLine)); + } + + return ranges is null ? Array.Empty() : ranges; + } + + private static IReadOnlyList GetParameterShadowRanges( + IReadOnlyList preparedLines, + int[] braceDepths, + Dictionary> parameterShadowRangesByAlias, + string alias) + { + if (parameterShadowRangesByAlias.TryGetValue(alias, out var ranges)) + return ranges; + + ranges = BuildParameterShadowRanges(preparedLines, braceDepths, alias); + parameterShadowRangesByAlias[alias] = ranges; + return ranges; + } + + private static bool TryGetSingleLineCallableParameters(string line, out string parameters) + { + parameters = string.Empty; + var trimmed = line.TrimStart(); + if (trimmed.StartsWith("if ", StringComparison.Ordinal) + || trimmed.StartsWith("if(", StringComparison.Ordinal) + || trimmed.StartsWith("for ", StringComparison.Ordinal) + || trimmed.StartsWith("for(", StringComparison.Ordinal) + || trimmed.StartsWith("while ", StringComparison.Ordinal) + || trimmed.StartsWith("while(", StringComparison.Ordinal) + || trimmed.StartsWith("switch ", StringComparison.Ordinal) + || trimmed.StartsWith("switch(", StringComparison.Ordinal) + || trimmed.Contains("=>", StringComparison.Ordinal)) + { + return false; + } + + var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(line, '('); + if (openParen < 0) + return false; + + var closeParen = ReferenceExtractor.FindMatchingChar(line, openParen, '(', ')'); + if (closeParen <= openParen) + return false; + + var afterParameters = line[(closeParen + 1)..]; + if (!afterParameters.Contains('{', StringComparison.Ordinal)) + return false; + + parameters = line.Substring(openParen + 1, closeParen - openParen - 1); + return trimmed.StartsWith("function ", StringComparison.Ordinal) + || trimmed.StartsWith("export function ", StringComparison.Ordinal) + || trimmed.StartsWith("export async function ", StringComparison.Ordinal) + || trimmed.StartsWith("async function ", StringComparison.Ordinal) + || IsLikelyMethodDeclarationPrefix(line[..openParen]); + } + + private static bool IsLikelyMethodDeclarationPrefix(string prefix) + { + var trimmed = prefix.Trim(); + if (trimmed.Length == 0 || trimmed.Contains('=')) + return false; + + var lastSpace = trimmed.LastIndexOf(' '); + var name = lastSpace >= 0 ? trimmed[(lastSpace + 1)..] : trimmed; + return IsTypeScriptIdentifier(name); + } + + private static bool ParameterListDeclaresName(string parameters, string alias) + { + var remaining = parameters.AsSpan(); + var aliasSpan = alias.AsSpan(); + while (true) + { + var commaIndex = remaining.IndexOf(','); + var item = commaIndex < 0 ? remaining : remaining[..commaIndex]; + item = item.TrimStart(); + if (item.StartsWith("...".AsSpan(), StringComparison.Ordinal)) + item = item[3..].TrimStart(); + + if (item.StartsWith(aliasSpan, StringComparison.Ordinal)) + { + var after = item.Length == alias.Length ? '\0' : item[alias.Length]; + if (after is '\0' or ':' or '?' or '=' || char.IsWhiteSpace(after)) + return true; + } + + if (commaIndex < 0) + break; + + remaining = remaining[(commaIndex + 1)..]; + } + + return false; + } + + private static int FindBlockEndLine(IReadOnlyList preparedLines, IReadOnlyList braceDepths, int startLineIndex) + { + var startDepth = braceDepths[startLineIndex]; + for (var index = startLineIndex + 1; index < preparedLines.Count; index++) + { + if (braceDepths[index] <= startDepth) + return index; + } + + return preparedLines.Count; + } + + private static bool IsInsideScopedShadow(IReadOnlyList ranges, int lineNumber) + { + foreach (var range in ranges) + { + if (lineNumber >= range.StartLine && lineNumber <= range.EndLine) + return true; + } + + return false; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.TypeDeclarations.cs b/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.TypeDeclarations.cs new file mode 100644 index 000000000..a7ddcfe95 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.TypeDeclarations.cs @@ -0,0 +1,470 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class TypeScriptReferenceExtractor +{ + private static void EmitMappedTypeMemberReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var bracketStart = preparedLine.IndexOf('['); + if (bracketStart < 0) + return; + + var bracketEnd = ReferenceExtractor.FindMatchingChar(preparedLine, bracketStart, '[', ']'); + if (bracketEnd <= bracketStart) + return; + + var clause = preparedLine.Substring(bracketStart + 1, bracketEnd - bracketStart - 1); + if (!clause.Contains("keyof", StringComparison.Ordinal) + && !clause.Contains(" in ", StringComparison.Ordinal) + && !clause.Contains(" as ", StringComparison.Ordinal)) + { + return; + } + + var clauseStart = bracketStart + 1; + ReferenceExtractor.AddTypeExpressionSegments( + references, + seen, + fileId, + clause, + clauseStart, + context, + lineNumber, + resolveContainerForColumn(clauseStart), + "typescript", + MappedTypeClauseIgnoredSegments); + + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', bracketEnd + 1); + if (colonIndex < 0) + return; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); + if (typeStart >= preparedLine.Length) + return; + + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart, stopAtArrow: false); + if (typeEnd <= typeStart) + return; + + ReferenceExtractor.AddTypeExpressionSegments( + references, + seen, + fileId, + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + context, + lineNumber, + resolveContainerForColumn(typeStart), + "typescript"); + } + + public static bool IsSatisfiesTypeOperand(string preparedLine, int tokenIndex) + { + foreach (var keywordIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "satisfies")) + { + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, keywordIndex + "satisfies".Length); + if (typeStart >= preparedLine.Length || tokenIndex < typeStart) + continue; + + var typeEnd = TypedLanguageReferenceExtractor.FindKeywordFollowingTypeExpressionEnd(preparedLine, typeStart, "typescript"); + if (typeEnd <= typeStart) + continue; + + if (tokenIndex < typeEnd) + return true; + } + + return false; + } + + private static void EmitGenericConstraintTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + for (var index = 0; index < preparedLine.Length; index++) + { + if (preparedLine[index] != '<') + continue; + + var closeIndex = ReferenceExtractor.FindMatchingChar(preparedLine, index, '<', '>'); + if (closeIndex <= index) + continue; + + var clauseStart = index + 1; + var clause = preparedLine.AsSpan(clauseStart, closeIndex - clauseStart); + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(clause)) + { + var fragment = clause.Slice(segmentStart, segmentLength).ToString(); + foreach (var extendsIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(fragment, "extends")) + { + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, extendsIndex + "extends".Length); + if (typeStart >= fragment.Length) + continue; + + var typeEnd = FindGenericConstraintExpressionEnd(fragment, typeStart); + if (typeEnd <= typeStart) + continue; + + var absoluteStart = clauseStart + segmentStart + typeStart; + ReferenceExtractor.AddTypeScriptTypeExpressionSegments( + references, + seen, + fileId, + fragment.Substring(typeStart, typeEnd - typeStart), + absoluteStart, + context, + lineNumber, + resolveContainerForColumn(absoluteStart)); + } + } + + index = closeIndex; + } + } + + private static int FindGenericConstraintExpressionEnd(string fragment, int typeStart) + { + var equalsIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(fragment, '=', typeStart); + return equalsIndex >= 0 ? equalsIndex : fragment.Length; + } + + private static void EmitHeritageTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var trimmed = preparedLine.TrimStart(); + if (!(trimmed.StartsWith("class ", StringComparison.Ordinal) + || trimmed.StartsWith("abstract class ", StringComparison.Ordinal) + || trimmed.StartsWith("export class ", StringComparison.Ordinal) + || trimmed.StartsWith("export abstract class ", StringComparison.Ordinal) + || trimmed.StartsWith("interface ", StringComparison.Ordinal) + || trimmed.StartsWith("export interface ", StringComparison.Ordinal))) + { + return; + } + + EmitHeritageKeyword(preparedLine, "extends", references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitHeritageKeyword(preparedLine, "implements", references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static void EmitTypeAliasTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (!TryFindTypeAliasShape(preparedLine, out var nameEnd, out var assignmentIndex)) + return; + + var genericOpen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '<', nameEnd); + if (genericOpen >= 0 && genericOpen < assignmentIndex) + { + var genericClose = ReferenceExtractor.FindMatchingChar(preparedLine, genericOpen, '<', '>'); + if (genericClose > genericOpen && genericClose < assignmentIndex) + { + EmitTypeParameterDefaultReferences( + preparedLine, + genericOpen + 1, + genericClose, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + } + + var rhsStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, assignmentIndex + 1); + if (rhsStart >= preparedLine.Length) + return; + + var rhsEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd( + preparedLine, + rhsStart, + stopAtComma: false, + stopAtArrow: false); + if (rhsEnd <= rhsStart) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(rhsStart, rhsEnd - rhsStart), + rhsStart, + "typescript", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(rhsStart)); + } + + private static bool TryFindTypeAliasShape(string line, out int nameEnd, out int assignmentIndex) + { + nameEnd = -1; + assignmentIndex = -1; + + var index = 0; + SkipWhitespace(line, ref index); + TryConsumeKeyword(line, "export", ref index); + SkipWhitespace(line, ref index); + TryConsumeKeyword(line, "declare", ref index); + SkipWhitespace(line, ref index); + if (!TryConsumeKeyword(line, "type", ref index)) + return false; + + SkipWhitespace(line, ref index); + if (index >= line.Length || !IsTypeScriptIdentifierStart(line[index])) + return false; + + index++; + while (index < line.Length && IsTypeScriptIdentifierPart(line[index])) + index++; + + nameEnd = index; + assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(line, '=', nameEnd); + return assignmentIndex > nameEnd; + } + + private static void EmitTypeParameterDefaultReferences( + string line, + int listStart, + int listEnd, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var parameterList = line.AsSpan(listStart, listEnd - listStart); + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(parameterList)) + { + var fragment = parameterList.Slice(segmentStart, segmentLength).ToString(); + var equalsIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(fragment, '='); + if (equalsIndex < 0) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, equalsIndex + 1); + if (typeStart >= fragment.Length) + continue; + + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart, stopAtArrow: false); + if (typeEnd <= typeStart) + continue; + + var absoluteStart = listStart + segmentStart + typeStart; + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + fragment.Substring(typeStart, typeEnd - typeStart), + absoluteStart, + "typescript", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(absoluteStart)); + } + } + + private static void EmitHeritageKeyword( + string preparedLine, + string keyword, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + foreach (var keywordIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, keyword)) + { + var listStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, keywordIndex + keyword.Length); + var listEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, listStart, stopAtComma: false); + if (listEnd <= listStart) + continue; + + TypedLanguageReferenceExtractor.EmitCommaSeparatedTypeListReferences( + preparedLine, + listStart, + listEnd, + "typescript", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + } + + private static void EmitCallableSignatureTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar( + preparedLine, + '(', + SkipLeadingDecorators(preparedLine)); + if (openParen <= 0) + return; + + var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); + if (closeParen < 0) + return; + + TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( + preparedLine, + openParen + 1, + closeParen, + "typescript", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + + var returnColon = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, closeParen + 1); + if (returnColon >= preparedLine.Length || preparedLine[returnColon] != ':') + return; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, returnColon + 1); + if (typeStart >= preparedLine.Length) + return; + + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart, stopAtArrow: false); + if (typeEnd <= typeStart) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "typescript", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + + private static void EmitDecoratedMemberTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var memberStart = SkipLeadingDecorators(preparedLine); + if (memberStart <= 0 || memberStart >= preparedLine.Length) + return; + + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', memberStart); + if (colonIndex < 0) + return; + + var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '(', memberStart); + if (openParen >= 0 && openParen < colonIndex) + return; + + var equalsIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', memberStart); + if (equalsIndex >= 0 && equalsIndex < colonIndex) + return; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); + if (typeStart >= preparedLine.Length) + return; + + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart, stopAtArrow: false); + if (typeEnd <= typeStart) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "typescript", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + + private static void EmitFunctionPropertyTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':'); + if (colonIndex < 0) + return; + + var equalsIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '='); + if (equalsIndex >= 0 && equalsIndex < colonIndex) + return; + + var questionIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '?'); + if (questionIndex >= 0 && questionIndex != colonIndex - 1) + return; + + var prefix = preparedLine.Substring(0, colonIndex).TrimEnd(); + if (prefix.Length == 0 || prefix.EndsWith(")", StringComparison.Ordinal)) + return; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); + if (typeStart >= preparedLine.Length) + return; + + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart, stopAtArrow: false); + if (typeEnd <= typeStart) + return; + + var container = resolveContainerForColumn(typeStart); + TypedLanguageReferenceExtractor.TryEmitTypeScriptFunctionTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + references, + seen, + fileId, + context, + lineNumber, + container); + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.cs index 968d00c8f..d7482e670 100644 --- a/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/TypeScriptReferenceExtractor.cs @@ -4,7 +4,7 @@ namespace CodeIndex.Indexer; -internal static class TypeScriptReferenceExtractor +internal static partial class TypeScriptReferenceExtractor { internal readonly record struct LineRange(int StartLine, int EndLine); internal readonly record struct TypeAliasBinding( @@ -618,1659 +618,4 @@ private static bool HasIdentifierBoundaries(string line, int start, int length) return !IsTypeScriptIdentifierPart(before) && !IsTypeScriptIdentifierPart(after); } - private static void EmitAsTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - foreach (var asIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "as")) - { - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, asIndex + "as".Length); - if (typeStart >= preparedLine.Length || TryConsumeKeywordAt(preparedLine, "const", typeStart)) - continue; - - var typeEnd = TypedLanguageReferenceExtractor.FindKeywordFollowingTypeExpressionEnd(preparedLine, typeStart, "typescript"); - if (typeEnd <= typeStart) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "typescript", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - } - - private static void EmitConstAssertionReferences( - IReadOnlyList preparedLines, - IReadOnlyList rawLines, - int lineIndex, - string preparedLine, - string rawLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - foreach (var asIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "as")) - { - var constIndex = SkipWhitespace(preparedLine, asIndex + "as".Length); - if (!TryConsumeKeywordAt(preparedLine, "const", constIndex)) - continue; - - var rawAsIndex = rawLine.IndexOf(" as const", Math.Min(asIndex, rawLine.Length), StringComparison.Ordinal); - if (rawAsIndex < 0) - rawAsIndex = asIndex; - else - rawAsIndex++; - var rawConstIndex = SkipWhitespace(rawLine, rawAsIndex + "as".Length); - ReferenceExtractor.AddReference( - references, - seen, - fileId, - "const", - rawConstIndex, - "const_assertion", - context, - lineNumber, - resolveContainerForColumn(asIndex)); - - EmitConstAssertionLiteralTypeReferences( - preparedLines, - rawLines, - lineIndex, - asIndex, - rawAsIndex, - references, - seen, - fileId, - resolveContainerForColumn); - } - } - - private static void EmitConstAssertionLiteralTypeReferences( - IReadOnlyList preparedLines, - IReadOnlyList rawLines, - int assertionLineIndex, - int preparedAsIndex, - int asIndex, - List references, - ReferenceDedupeSet seen, - long fileId, - Func resolveContainerForColumn) - { - if (!TryFindConstAssertionLiteralOpen( - preparedLines, - assertionLineIndex, - preparedAsIndex, - out var literalOpenLineIndex, - out var literalOpenColumn)) - { - return; - } - - var insideBlockComment = false; - for (var currentLineIndex = literalOpenLineIndex; currentLineIndex <= assertionLineIndex; currentLineIndex++) - { - var rawLine = rawLines[currentLineIndex]; - var scanStart = currentLineIndex == literalOpenLineIndex ? literalOpenColumn + 1 : 0; - var scanEnd = currentLineIndex == assertionLineIndex ? Math.Min(asIndex, rawLine.Length) : rawLine.Length; - if (scanStart >= scanEnd) - continue; - - for (var index = scanStart; index < scanEnd; index++) - { - if (SkipConstAssertionComment(rawLine, scanEnd, ref index, ref insideBlockComment)) - continue; - - if (rawLine[index] is '"' or '\'' or '`') - { - var literalStart = index; - index = SkipQuotedLiteral(rawLine, index); - if (index <= literalStart + 1 - || !HasStandaloneConstAssertionLiteralBoundaries( - rawLines, - literalOpenLineIndex, - literalOpenColumn, - assertionLineIndex, - asIndex, - currentLineIndex, - literalStart, - index + 1)) - { - continue; - } - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - rawLine.Substring(literalStart, index - literalStart + 1), - literalStart, - "type_reference", - rawLine.Trim(), - currentLineIndex + 1, - ResolveConstAssertionLiteralContainer( - currentLineIndex, - assertionLineIndex, - literalStart, - resolveContainerForColumn)); - continue; - } - - if (IsNumberLiteralStart(rawLine, index)) - { - var literalStart = index; - index = SkipNumberLiteral(rawLine, index); - if (!HasStandaloneConstAssertionLiteralBoundaries( - rawLines, - literalOpenLineIndex, - literalOpenColumn, - assertionLineIndex, - asIndex, - currentLineIndex, - literalStart, - index)) - { - index--; - continue; - } - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - rawLine.Substring(literalStart, index - literalStart), - literalStart, - "type_reference", - rawLine.Trim(), - currentLineIndex + 1, - ResolveConstAssertionLiteralContainer( - currentLineIndex, - assertionLineIndex, - literalStart, - resolveContainerForColumn)); - index--; - continue; - } - - if (!TryReadLiteralKeyword(rawLine, index, scanEnd, out var keyword)) - continue; - if (!HasStandaloneConstAssertionLiteralBoundaries( - rawLines, - literalOpenLineIndex, - literalOpenColumn, - assertionLineIndex, - asIndex, - currentLineIndex, - index, - index + keyword.Length)) - { - continue; - } - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - keyword, - index, - "type_reference", - rawLine.Trim(), - currentLineIndex + 1, - ResolveConstAssertionLiteralContainer( - currentLineIndex, - assertionLineIndex, - index, - resolveContainerForColumn)); - index += keyword.Length - 1; - } - } - } - - private static bool SkipConstAssertionComment(string line, int scanEnd, ref int index, ref bool insideBlockComment) - { - if (insideBlockComment) - { - var end = line.IndexOf("*/", index, Math.Max(0, scanEnd - index), StringComparison.Ordinal); - if (end < 0) - { - index = scanEnd; - return true; - } - - index = end + 1; - insideBlockComment = false; - return true; - } - - if (index + 1 >= scanEnd || line[index] != '/') - return false; - - if (line[index + 1] == '/') - { - index = scanEnd; - return true; - } - - if (line[index + 1] != '*') - return false; - - insideBlockComment = true; - index++; - return true; - } - - private static bool HasStandaloneConstAssertionLiteralBoundaries( - IReadOnlyList preparedLines, - int literalOpenLineIndex, - int literalOpenColumn, - int assertionLineIndex, - int preparedAsIndex, - int literalLineIndex, - int literalStartColumn, - int literalEndColumn) - { - var previous = FindPreviousNonWhitespace( - preparedLines, - literalOpenLineIndex, - literalOpenColumn, - literalLineIndex, - literalStartColumn); - var next = FindNextNonWhitespace( - preparedLines, - literalLineIndex, - literalEndColumn, - assertionLineIndex, - preparedAsIndex); - - if (previous == ':' && HasQuestionBeforeLiteralValue(rawLines: preparedLines, literalLineIndex, literalStartColumn)) - return false; - - return previous is '[' or '{' or ':' or ',' - && next is ',' or ']' or '}'; - } - - private static bool HasQuestionBeforeLiteralValue( - IReadOnlyList rawLines, - int literalLineIndex, - int literalStartColumn) - { - var line = rawLines[literalLineIndex]; - for (var index = literalStartColumn - 1; index >= 0; index--) - { - if (line[index] == '?') - return true; - - if (line[index] is ',' or '{' or '[') - return false; - } - - return false; - } - - private static char? FindPreviousNonWhitespace( - IReadOnlyList lines, - int minLineIndex, - int minColumn, - int lineIndex, - int column) - { - for (var currentLineIndex = lineIndex; currentLineIndex >= minLineIndex; currentLineIndex--) - { - var line = lines[currentLineIndex]; - var index = currentLineIndex == lineIndex ? column - 1 : line.Length - 1; - var stop = currentLineIndex == minLineIndex ? minColumn : 0; - var lineCommentStart = IndexOfLineCommentOutsideString(line, stop, Math.Max(0, index - stop + 1)); - if (lineCommentStart >= 0) - index = lineCommentStart - 1; - for (; index >= stop; index--) - { - if (line[index] == '/' && index > stop && line[index - 1] == '*') - { - var commentStart = line.LastIndexOf("/*", index - 1, index - stop, StringComparison.Ordinal); - if (commentStart >= 0) - { - index = commentStart; - continue; - } - } - - if (!char.IsWhiteSpace(line[index])) - return line[index]; - } - } - - return null; - } - - private static int IndexOfLineCommentOutsideString(string line, int startIndex, int count) - { - var endIndex = Math.Min(line.Length, startIndex + count); - char? quote = null; - for (var index = startIndex; index + 1 < endIndex; index++) - { - if (quote is char activeQuote) - { - if (line[index] == '\\') - { - index++; - continue; - } - - if (line[index] == activeQuote) - quote = null; - continue; - } - - if (line[index] is '"' or '\'' or '`') - { - quote = line[index]; - continue; - } - - if (line[index] == '/' && line[index + 1] == '/') - return index; - } - - return -1; - } - - private static char? FindNextNonWhitespace( - IReadOnlyList lines, - int lineIndex, - int column, - int maxLineIndex, - int maxColumn) - { - for (var currentLineIndex = lineIndex; currentLineIndex <= maxLineIndex; currentLineIndex++) - { - var line = lines[currentLineIndex]; - var index = currentLineIndex == lineIndex ? column : 0; - var stop = currentLineIndex == maxLineIndex ? Math.Min(maxColumn, line.Length) : line.Length; - for (; index < stop; index++) - { - if (index + 1 < stop && line[index] == '/' && line[index + 1] == '*') - { - var commentEnd = line.IndexOf("*/", index + 2, stop - index - 2, StringComparison.Ordinal); - if (commentEnd < 0) - return null; - - index = commentEnd + 1; - continue; - } - - if (index + 1 < stop && line[index] == '/' && line[index + 1] == '/') - return null; - - if (!char.IsWhiteSpace(line[index])) - return line[index]; - } - } - - return null; - } - - private static SymbolRecord? ResolveConstAssertionLiteralContainer( - int literalLineIndex, - int assertionLineIndex, - int column, - Func resolveContainerForColumn) - { - return literalLineIndex == assertionLineIndex ? resolveContainerForColumn(column) : null; - } - - private static bool TryFindConstAssertionLiteralOpen( - IReadOnlyList preparedLines, - int assertionLineIndex, - int asIndex, - out int openLineIndex, - out int openColumn) - { - openLineIndex = -1; - openColumn = -1; - for (var lineIndex = assertionLineIndex; lineIndex >= 0; lineIndex--) - { - var line = preparedLines[lineIndex]; - var index = lineIndex == assertionLineIndex ? asIndex - 1 : line.Length - 1; - for (; index >= 0; index--) - { - if (char.IsWhiteSpace(line[index])) - continue; - - if (line[index] is ']' or '}') - { - var openChar = line[index] == ']' ? '[' : '{'; - return TryFindMatchingOpenChar( - preparedLines, - lineIndex, - index, - openChar, - line[index], - out openLineIndex, - out openColumn); - } - - return false; - } - } - - return false; - } - - private static bool TryFindMatchingOpenChar( - IReadOnlyList lines, - int closeLineIndex, - int closeColumn, - char openChar, - char closeChar, - out int openLineIndex, - out int openColumn) - { - openLineIndex = -1; - openColumn = -1; - var depth = 0; - for (var lineIndex = closeLineIndex; lineIndex >= 0; lineIndex--) - { - var line = lines[lineIndex]; - var index = lineIndex == closeLineIndex ? closeColumn : line.Length - 1; - for (; index >= 0; index--) - { - if (line[index] == closeChar) - { - depth++; - continue; - } - - if (line[index] != openChar) - continue; - - depth--; - if (depth == 0) - { - openLineIndex = lineIndex; - openColumn = index; - return true; - } - } - } - - return false; - } - - private static int SkipQuotedLiteral(string text, int quoteIndex) - { - var quote = text[quoteIndex]; - for (var index = quoteIndex + 1; index < text.Length; index++) - { - if (text[index] == '\\') - { - index++; - continue; - } - - if (text[index] == quote) - return index; - } - - return text.Length - 1; - } - - private static bool IsNumberLiteralStart(string text, int index) - { - if (index >= text.Length) - return false; - - var startsWithDigit = char.IsDigit(text[index]); - var startsWithNegativeSign = text[index] == '-' - && index + 1 < text.Length - && char.IsDigit(text[index + 1]); - if (!startsWithDigit && !startsWithNegativeSign) - return false; - - return index == 0 || !IsTypeScriptIdentifierPart(text[index - 1]); - } - - private static int SkipNumberLiteral(string text, int index) - { - if (index < text.Length && text[index] == '-') - index++; - - if (index + 1 < text.Length - && text[index] == '0' - && text[index + 1] is 'x' or 'X' or 'b' or 'B' or 'o' or 'O') - { - var radixPrefix = text[index + 1]; - index += 2; - while (index < text.Length && (IsRadixDigit(text[index], radixPrefix) || text[index] == '_')) - index++; - - if (index < text.Length && text[index] == 'n') - index++; - - return index; - } - - while (index < text.Length && (char.IsDigit(text[index]) || text[index] == '_')) - index++; - - if (index < text.Length && text[index] == '.') - { - index++; - while (index < text.Length && (char.IsDigit(text[index]) || text[index] == '_')) - index++; - } - - if (index < text.Length && text[index] is 'e' or 'E') - { - var exponentIndex = index + 1; - if (exponentIndex < text.Length && text[exponentIndex] is '+' or '-') - exponentIndex++; - - var digitStart = exponentIndex; - while (exponentIndex < text.Length && (char.IsDigit(text[exponentIndex]) || text[exponentIndex] == '_')) - exponentIndex++; - - if (exponentIndex > digitStart) - index = exponentIndex; - } - - if (index < text.Length && text[index] == 'n') - index++; - - return index; - } - - private static bool IsRadixDigit(char ch, char radixPrefix) - { - return radixPrefix switch - { - 'x' or 'X' => char.IsAsciiHexDigit(ch), - 'b' or 'B' => ch is '0' or '1', - 'o' or 'O' => ch is >= '0' and <= '7', - _ => false, - }; - } - - private static bool TryReadLiteralKeyword(string text, int index, int endExclusive, out string keyword) - { - foreach (var candidate in LiteralKeywords) - { - if (index + candidate.Length > endExclusive - || string.CompareOrdinal(text, index, candidate, 0, candidate.Length) != 0) - { - continue; - } - - var beforeOk = index == 0 || !IsTypeScriptIdentifierPart(text[index - 1]); - var after = index + candidate.Length; - var afterOk = after >= text.Length || !IsTypeScriptIdentifierPart(text[after]); - if (!beforeOk || !afterOk) - continue; - - keyword = candidate; - return true; - } - - keyword = string.Empty; - return false; - } - - private static bool TryConsumeKeywordAt(string text, string keyword, int index) - { - if (index < 0 || index + keyword.Length > text.Length) - return false; - - if (string.CompareOrdinal(text, index, keyword, 0, keyword.Length) != 0) - return false; - - var beforeOk = index == 0 || !IsTypeScriptIdentifierPart(text[index - 1]); - var after = index + keyword.Length; - var afterOk = after >= text.Length || !IsTypeScriptIdentifierPart(text[after]); - return beforeOk && afterOk; - } - - private static int SkipWhitespace(string text, int index) - { - while (index < text.Length && char.IsWhiteSpace(text[index])) - index++; - - return index; - } - - private static void EmitNamespaceAliasQualifiedReferences( - IReadOnlyList preparedLines, - int lineIndex, - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - IReadOnlyList namespaceAliases) - { - if (namespaceAliases.Count == 0 || IsImportExportAliasLine(preparedLines, lineIndex, preparedLine)) - return; - - foreach (var binding in namespaceAliases) - { - if (lineNumber <= binding.BindingLine - || (binding.EndLine is int endLine && lineNumber > endLine) - || (binding.ShadowLine is int shadowLine && lineNumber >= shadowLine) - || IsInsideScopedShadow(binding.ScopedShadowRanges, lineNumber)) - { - continue; - } - - foreach (var matchIndex in EnumerateNamespaceAliasQualifiedReferenceStarts(preparedLine, binding.Alias)) - { - ReferenceExtractor.AddReference( - references, - seen, - fileId, - binding.ModuleSpecifier, - matchIndex, - "reference", - context, - lineNumber, - resolveContainerForColumn(matchIndex)); - } - } - } - - private static IEnumerable EnumerateNamespaceAliasQualifiedReferenceStarts(string text, string alias) - { - if (string.IsNullOrEmpty(alias)) - yield break; - - var searchIndex = 0; - while (searchIndex < text.Length) - { - var aliasIndex = text.IndexOf(alias, searchIndex, StringComparison.Ordinal); - if (aliasIndex < 0) - yield break; - - searchIndex = aliasIndex + Math.Max(1, alias.Length); - if (aliasIndex > 0 && IsTypeScriptIdentifierPart(text[aliasIndex - 1])) - continue; - - var afterAlias = aliasIndex + alias.Length; - if (afterAlias < text.Length && IsTypeScriptIdentifierPart(text[afterAlias])) - continue; - - var dotIndex = SkipWhitespace(text, afterAlias); - if (dotIndex >= text.Length || text[dotIndex] != '.') - continue; - - var memberIndex = SkipWhitespace(text, dotIndex + 1); - if (memberIndex >= text.Length || !IsTypeScriptNamespaceMemberStart(text[memberIndex])) - continue; - - yield return aliasIndex; - } - } - - private static bool IsTypeScriptNamespaceMemberStart(char ch) => - ch == '_' || ch == '$' || ch is >= 'A' and <= 'Z' || ch is >= 'a' and <= 'z'; - - private static IReadOnlyDictionary> BuildLocalDeclarationLinesByName(IReadOnlyList preparedLines) - { - Dictionary>? linesByName = null; - for (var index = 0; index < preparedLines.Count; index++) - { - var line = preparedLines[index]; - if (NamespaceImportExportRegex.IsMatch(line) || DynamicImportNamespaceRegex.IsMatch(line)) - continue; - - var match = LocalDeclarationRegex.Match(line); - if (!match.Success) - continue; - - var name = match.Groups["name"].Value; - linesByName ??= new Dictionary>(16, StringComparer.Ordinal); - if (!linesByName.TryGetValue(name, out var lines)) - { - lines = new List(1); - linesByName[name] = lines; - } - - lines.Add(index + 1); - } - - return linesByName ?? EmptyLocalDeclarationLinesByName; - } - - private static int? FindShadowLine( - IReadOnlyDictionary> localDeclarationLinesByName, - string alias, - int bindingLine) - { - if (!localDeclarationLinesByName.TryGetValue(alias, out var declarationLines)) - return null; - - foreach (var line in declarationLines) - { - if (line > bindingLine) - return line; - } - - return null; - } - - private static int[] BuildBraceDepthsBeforeLine(IReadOnlyList preparedLines) - { - var depths = new int[preparedLines.Count]; - var depth = 0; - for (var index = 0; index < preparedLines.Count; index++) - { - depths[index] = depth; - foreach (var ch in preparedLines[index]) - { - if (ch == '{') - depth++; - else if (ch == '}' && depth > 0) - depth--; - } - } - - return depths; - } - - private static int? FindDynamicImportAliasEndLine( - IReadOnlyList preparedLines, - IReadOnlyList braceDepths, - int bindingLineIndex) - { - var bindingDepth = braceDepths[bindingLineIndex]; - if (bindingDepth <= 0) - return null; - - for (var index = bindingLineIndex + 1; index < preparedLines.Count; index++) - { - if (braceDepths[index] < bindingDepth) - return index; - } - - return preparedLines.Count; - } - - private static IReadOnlyList BuildParameterShadowRanges( - IReadOnlyList preparedLines, - int[] braceDepths, - string alias) - { - List? ranges = null; - for (var index = 0; index < preparedLines.Count; index++) - { - if (!TryGetSingleLineCallableParameters(preparedLines[index], out var parameters) - || !ParameterListDeclaresName(parameters, alias)) - { - continue; - } - - var endLine = FindBlockEndLine(preparedLines, braceDepths, index); - if (endLine >= index + 1) - (ranges ??= new List(2)).Add(new LineRange(index + 1, endLine)); - } - - return ranges is null ? Array.Empty() : ranges; - } - - private static IReadOnlyList GetParameterShadowRanges( - IReadOnlyList preparedLines, - int[] braceDepths, - Dictionary> parameterShadowRangesByAlias, - string alias) - { - if (parameterShadowRangesByAlias.TryGetValue(alias, out var ranges)) - return ranges; - - ranges = BuildParameterShadowRanges(preparedLines, braceDepths, alias); - parameterShadowRangesByAlias[alias] = ranges; - return ranges; - } - - private static bool TryGetSingleLineCallableParameters(string line, out string parameters) - { - parameters = string.Empty; - var trimmed = line.TrimStart(); - if (trimmed.StartsWith("if ", StringComparison.Ordinal) - || trimmed.StartsWith("if(", StringComparison.Ordinal) - || trimmed.StartsWith("for ", StringComparison.Ordinal) - || trimmed.StartsWith("for(", StringComparison.Ordinal) - || trimmed.StartsWith("while ", StringComparison.Ordinal) - || trimmed.StartsWith("while(", StringComparison.Ordinal) - || trimmed.StartsWith("switch ", StringComparison.Ordinal) - || trimmed.StartsWith("switch(", StringComparison.Ordinal) - || trimmed.Contains("=>", StringComparison.Ordinal)) - { - return false; - } - - var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(line, '('); - if (openParen < 0) - return false; - - var closeParen = ReferenceExtractor.FindMatchingChar(line, openParen, '(', ')'); - if (closeParen <= openParen) - return false; - - var afterParameters = line[(closeParen + 1)..]; - if (!afterParameters.Contains('{', StringComparison.Ordinal)) - return false; - - parameters = line.Substring(openParen + 1, closeParen - openParen - 1); - return trimmed.StartsWith("function ", StringComparison.Ordinal) - || trimmed.StartsWith("export function ", StringComparison.Ordinal) - || trimmed.StartsWith("export async function ", StringComparison.Ordinal) - || trimmed.StartsWith("async function ", StringComparison.Ordinal) - || IsLikelyMethodDeclarationPrefix(line[..openParen]); - } - - private static bool IsLikelyMethodDeclarationPrefix(string prefix) - { - var trimmed = prefix.Trim(); - if (trimmed.Length == 0 || trimmed.Contains('=')) - return false; - - var lastSpace = trimmed.LastIndexOf(' '); - var name = lastSpace >= 0 ? trimmed[(lastSpace + 1)..] : trimmed; - return IsTypeScriptIdentifier(name); - } - - private static bool ParameterListDeclaresName(string parameters, string alias) - { - var remaining = parameters.AsSpan(); - var aliasSpan = alias.AsSpan(); - while (true) - { - var commaIndex = remaining.IndexOf(','); - var item = commaIndex < 0 ? remaining : remaining[..commaIndex]; - item = item.TrimStart(); - if (item.StartsWith("...".AsSpan(), StringComparison.Ordinal)) - item = item[3..].TrimStart(); - - if (item.StartsWith(aliasSpan, StringComparison.Ordinal)) - { - var after = item.Length == alias.Length ? '\0' : item[alias.Length]; - if (after is '\0' or ':' or '?' or '=' || char.IsWhiteSpace(after)) - return true; - } - - if (commaIndex < 0) - break; - - remaining = remaining[(commaIndex + 1)..]; - } - - return false; - } - - private static int FindBlockEndLine(IReadOnlyList preparedLines, IReadOnlyList braceDepths, int startLineIndex) - { - var startDepth = braceDepths[startLineIndex]; - for (var index = startLineIndex + 1; index < preparedLines.Count; index++) - { - if (braceDepths[index] <= startDepth) - return index; - } - - return preparedLines.Count; - } - - private static bool IsInsideScopedShadow(IReadOnlyList ranges, int lineNumber) - { - foreach (var range in ranges) - { - if (lineNumber >= range.StartLine && lineNumber <= range.EndLine) - return true; - } - - return false; - } - - private static void EmitMappedTypeMemberReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var bracketStart = preparedLine.IndexOf('['); - if (bracketStart < 0) - return; - - var bracketEnd = ReferenceExtractor.FindMatchingChar(preparedLine, bracketStart, '[', ']'); - if (bracketEnd <= bracketStart) - return; - - var clause = preparedLine.Substring(bracketStart + 1, bracketEnd - bracketStart - 1); - if (!clause.Contains("keyof", StringComparison.Ordinal) - && !clause.Contains(" in ", StringComparison.Ordinal) - && !clause.Contains(" as ", StringComparison.Ordinal)) - { - return; - } - - var clauseStart = bracketStart + 1; - ReferenceExtractor.AddTypeExpressionSegments( - references, - seen, - fileId, - clause, - clauseStart, - context, - lineNumber, - resolveContainerForColumn(clauseStart), - "typescript", - MappedTypeClauseIgnoredSegments); - - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', bracketEnd + 1); - if (colonIndex < 0) - return; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); - if (typeStart >= preparedLine.Length) - return; - - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart, stopAtArrow: false); - if (typeEnd <= typeStart) - return; - - ReferenceExtractor.AddTypeExpressionSegments( - references, - seen, - fileId, - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - context, - lineNumber, - resolveContainerForColumn(typeStart), - "typescript"); - } - - public static bool IsSatisfiesTypeOperand(string preparedLine, int tokenIndex) - { - foreach (var keywordIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "satisfies")) - { - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, keywordIndex + "satisfies".Length); - if (typeStart >= preparedLine.Length || tokenIndex < typeStart) - continue; - - var typeEnd = TypedLanguageReferenceExtractor.FindKeywordFollowingTypeExpressionEnd(preparedLine, typeStart, "typescript"); - if (typeEnd <= typeStart) - continue; - - if (tokenIndex < typeEnd) - return true; - } - - return false; - } - - private static void EmitGenericConstraintTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - for (var index = 0; index < preparedLine.Length; index++) - { - if (preparedLine[index] != '<') - continue; - - var closeIndex = ReferenceExtractor.FindMatchingChar(preparedLine, index, '<', '>'); - if (closeIndex <= index) - continue; - - var clauseStart = index + 1; - var clause = preparedLine.AsSpan(clauseStart, closeIndex - clauseStart); - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(clause)) - { - var fragment = clause.Slice(segmentStart, segmentLength).ToString(); - foreach (var extendsIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(fragment, "extends")) - { - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, extendsIndex + "extends".Length); - if (typeStart >= fragment.Length) - continue; - - var typeEnd = FindGenericConstraintExpressionEnd(fragment, typeStart); - if (typeEnd <= typeStart) - continue; - - var absoluteStart = clauseStart + segmentStart + typeStart; - ReferenceExtractor.AddTypeScriptTypeExpressionSegments( - references, - seen, - fileId, - fragment.Substring(typeStart, typeEnd - typeStart), - absoluteStart, - context, - lineNumber, - resolveContainerForColumn(absoluteStart)); - } - } - - index = closeIndex; - } - } - - private static int FindGenericConstraintExpressionEnd(string fragment, int typeStart) - { - var equalsIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(fragment, '=', typeStart); - return equalsIndex >= 0 ? equalsIndex : fragment.Length; - } - - private static void EmitHeritageTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var trimmed = preparedLine.TrimStart(); - if (!(trimmed.StartsWith("class ", StringComparison.Ordinal) - || trimmed.StartsWith("abstract class ", StringComparison.Ordinal) - || trimmed.StartsWith("export class ", StringComparison.Ordinal) - || trimmed.StartsWith("export abstract class ", StringComparison.Ordinal) - || trimmed.StartsWith("interface ", StringComparison.Ordinal) - || trimmed.StartsWith("export interface ", StringComparison.Ordinal))) - { - return; - } - - EmitHeritageKeyword(preparedLine, "extends", references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitHeritageKeyword(preparedLine, "implements", references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static void EmitTypeAliasTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (!TryFindTypeAliasShape(preparedLine, out var nameEnd, out var assignmentIndex)) - return; - - var genericOpen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '<', nameEnd); - if (genericOpen >= 0 && genericOpen < assignmentIndex) - { - var genericClose = ReferenceExtractor.FindMatchingChar(preparedLine, genericOpen, '<', '>'); - if (genericClose > genericOpen && genericClose < assignmentIndex) - { - EmitTypeParameterDefaultReferences( - preparedLine, - genericOpen + 1, - genericClose, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - } - - var rhsStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, assignmentIndex + 1); - if (rhsStart >= preparedLine.Length) - return; - - var rhsEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd( - preparedLine, - rhsStart, - stopAtComma: false, - stopAtArrow: false); - if (rhsEnd <= rhsStart) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(rhsStart, rhsEnd - rhsStart), - rhsStart, - "typescript", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(rhsStart)); - } - - private static bool TryFindTypeAliasShape(string line, out int nameEnd, out int assignmentIndex) - { - nameEnd = -1; - assignmentIndex = -1; - - var index = 0; - SkipWhitespace(line, ref index); - TryConsumeKeyword(line, "export", ref index); - SkipWhitespace(line, ref index); - TryConsumeKeyword(line, "declare", ref index); - SkipWhitespace(line, ref index); - if (!TryConsumeKeyword(line, "type", ref index)) - return false; - - SkipWhitespace(line, ref index); - if (index >= line.Length || !IsTypeScriptIdentifierStart(line[index])) - return false; - - index++; - while (index < line.Length && IsTypeScriptIdentifierPart(line[index])) - index++; - - nameEnd = index; - assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(line, '=', nameEnd); - return assignmentIndex > nameEnd; - } - - private static void EmitTypeParameterDefaultReferences( - string line, - int listStart, - int listEnd, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var parameterList = line.AsSpan(listStart, listEnd - listStart); - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(parameterList)) - { - var fragment = parameterList.Slice(segmentStart, segmentLength).ToString(); - var equalsIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(fragment, '='); - if (equalsIndex < 0) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, equalsIndex + 1); - if (typeStart >= fragment.Length) - continue; - - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart, stopAtArrow: false); - if (typeEnd <= typeStart) - continue; - - var absoluteStart = listStart + segmentStart + typeStart; - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - fragment.Substring(typeStart, typeEnd - typeStart), - absoluteStart, - "typescript", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(absoluteStart)); - } - } - - private static void EmitHeritageKeyword( - string preparedLine, - string keyword, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - foreach (var keywordIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, keyword)) - { - var listStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, keywordIndex + keyword.Length); - var listEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, listStart, stopAtComma: false); - if (listEnd <= listStart) - continue; - - TypedLanguageReferenceExtractor.EmitCommaSeparatedTypeListReferences( - preparedLine, - listStart, - listEnd, - "typescript", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - } - - private static void EmitCallableSignatureTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar( - preparedLine, - '(', - SkipLeadingDecorators(preparedLine)); - if (openParen <= 0) - return; - - var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); - if (closeParen < 0) - return; - - TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( - preparedLine, - openParen + 1, - closeParen, - "typescript", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - - var returnColon = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, closeParen + 1); - if (returnColon >= preparedLine.Length || preparedLine[returnColon] != ':') - return; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, returnColon + 1); - if (typeStart >= preparedLine.Length) - return; - - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart, stopAtArrow: false); - if (typeEnd <= typeStart) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "typescript", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - - private static void EmitDecoratedMemberTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var memberStart = SkipLeadingDecorators(preparedLine); - if (memberStart <= 0 || memberStart >= preparedLine.Length) - return; - - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', memberStart); - if (colonIndex < 0) - return; - - var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '(', memberStart); - if (openParen >= 0 && openParen < colonIndex) - return; - - var equalsIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', memberStart); - if (equalsIndex >= 0 && equalsIndex < colonIndex) - return; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); - if (typeStart >= preparedLine.Length) - return; - - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart, stopAtArrow: false); - if (typeEnd <= typeStart) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "typescript", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - - private static void EmitFunctionPropertyTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':'); - if (colonIndex < 0) - return; - - var equalsIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '='); - if (equalsIndex >= 0 && equalsIndex < colonIndex) - return; - - var questionIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '?'); - if (questionIndex >= 0 && questionIndex != colonIndex - 1) - return; - - var prefix = preparedLine.Substring(0, colonIndex).TrimEnd(); - if (prefix.Length == 0 || prefix.EndsWith(")", StringComparison.Ordinal)) - return; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); - if (typeStart >= preparedLine.Length) - return; - - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart, stopAtArrow: false); - if (typeEnd <= typeStart) - return; - - var container = resolveContainerForColumn(typeStart); - TypedLanguageReferenceExtractor.TryEmitTypeScriptFunctionTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - references, - seen, - fileId, - context, - lineNumber, - container); - } - - private static bool IsImportExportAliasLine(IReadOnlyList preparedLines, int lineIndex, string preparedLine) - { - var trimmed = preparedLine.TrimStart(); - return IsImportDeclarationLine(trimmed) - || IsNamedExportLine(trimmed) - || IsExportStarAliasLine(trimmed) - || IsInsideMultilineImportExportAlias(preparedLines, lineIndex, preparedLine); - } - - private static bool IsImportDeclarationLine(string text) - { - const string importKeyword = "import"; - if (!text.StartsWith(importKeyword, StringComparison.Ordinal)) - return false; - - var index = importKeyword.Length; - if (index >= text.Length || IsTypeScriptIdentifierPart(text[index])) - return false; - - return char.IsWhiteSpace(text[index]) || text[index] is '{' or '*'; - } - - private static bool IsNamedExportLine(string text) - { - var index = 0; - if (!TryConsumeKeyword(text, "export", ref index)) - return false; - - SkipWhitespace(text, ref index); - if (index < text.Length && text[index] == '{') - return true; - - if (!TryConsumeKeyword(text, "type", ref index)) - return false; - - SkipWhitespace(text, ref index); - return index < text.Length && text[index] == '{'; - } - - private static bool IsExportStarAliasLine(string text) - { - var index = 0; - if (!TryConsumeKeyword(text, "export", ref index)) - return false; - - SkipWhitespace(text, ref index); - if (TryConsumeKeyword(text, "type", ref index)) - SkipWhitespace(text, ref index); - - if (index >= text.Length || text[index] != '*') - return false; - - index++; - SkipWhitespace(text, ref index); - return TryConsumeKeyword(text, "as", ref index); - } - - private static bool IsInsideMultilineImportExportAlias( - IReadOnlyList preparedLines, - int lineIndex, - string preparedLine) - { - var asIndex = -1; - foreach (var keywordIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "as")) - { - asIndex = keywordIndex; - break; - } - - return asIndex >= 0 && IsInsideImportExportBraceAt(preparedLines, lineIndex, asIndex); - } - - private static bool IsInsideImportExportBraceAt(IReadOnlyList preparedLines, int lineIndex, int column) - { - var unmatchedClosingBraces = 0; - for (var currentLine = lineIndex; currentLine >= 0; currentLine--) - { - var line = preparedLines[currentLine]; - var startColumn = currentLine == lineIndex ? Math.Min(column, line.Length) - 1 : line.Length - 1; - for (var index = startColumn; index >= 0; index--) - { - if (line[index] == '}') - { - unmatchedClosingBraces++; - continue; - } - - if (line[index] != '{') - continue; - - if (unmatchedClosingBraces > 0) - { - unmatchedClosingBraces--; - continue; - } - - return IsImportExportOpeningBrace(preparedLines, currentLine, index); - } - } - - return false; - } - - private static int SkipLeadingDecorators(string line) - { - var index = 0; - while (index < line.Length) - { - while (index < line.Length && char.IsWhiteSpace(line[index])) - index++; - - if (index >= line.Length || line[index] != '@') - return index; - - index++; - while (index < line.Length && (IsTypeScriptIdentifierPart(line[index]) || line[index] == '.')) - index++; - - while (index < line.Length && char.IsWhiteSpace(line[index])) - index++; - - if (index < line.Length && line[index] == '(') - { - var closeParen = ReferenceExtractor.FindMatchingChar(line, index, '(', ')'); - if (closeParen < 0) - return index; - - index = closeParen + 1; - } - - while (index < line.Length && char.IsWhiteSpace(line[index])) - index++; - } - - return index; - } - - private static bool IsImportExportOpeningBrace(IReadOnlyList preparedLines, int openLineIndex, int openColumn) - { - var sameLine = preparedLines[openLineIndex]; - var sameLineStart = 0; - while (sameLineStart < openColumn && char.IsWhiteSpace(sameLine[sameLineStart])) - sameLineStart++; - - var sameLineEnd = openColumn; - while (sameLineEnd > sameLineStart && char.IsWhiteSpace(sameLine[sameLineEnd - 1])) - sameLineEnd--; - - if (sameLineEnd > sameLineStart) - { - var sameLinePrefix = sameLine.Substring(sameLineStart, sameLineEnd - sameLineStart); - return IsImportBracePrefix(sameLinePrefix) || IsNamedExportBracePrefix(sameLinePrefix); - } - - for (var lineIndex = openLineIndex - 1; lineIndex >= 0; lineIndex--) - { - var previousLineText = preparedLines[lineIndex]; - var previousLineStart = 0; - while (previousLineStart < previousLineText.Length && char.IsWhiteSpace(previousLineText[previousLineStart])) - previousLineStart++; - - var previousLineEnd = previousLineText.Length; - while (previousLineEnd > previousLineStart && char.IsWhiteSpace(previousLineText[previousLineEnd - 1])) - previousLineEnd--; - - if (previousLineEnd <= previousLineStart) - continue; - - var previousLine = previousLineText.Substring(previousLineStart, previousLineEnd - previousLineStart); - return IsImportBracePrefix(previousLine) || IsNamedExportBracePrefix(previousLine); - } - - return false; - } - - private static bool IsImportBracePrefix(string text) - { - if (text.IndexOf(';') >= 0 || ContainsTopLevelKeyword(text, "from")) - return false; - - var index = 0; - if (!TryConsumeKeyword(text, "import", ref index)) - return false; - - SkipWhitespace(text, ref index); - if (index >= text.Length) - return true; - - if (TryConsumeKeyword(text, "type", ref index)) - { - SkipWhitespace(text, ref index); - if (index >= text.Length) - return true; - } - - var end = text.Length; - while (end > 0 && char.IsWhiteSpace(text[end - 1])) - end--; - - return end > 0 && text[end - 1] == ','; - } - - private static bool ContainsTopLevelKeyword(string text, string keyword) - { - foreach (var _ in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(text, keyword)) - return true; - - return false; - } - - private static bool IsNamedExportBracePrefix(string text) - { - var index = 0; - if (!TryConsumeKeyword(text, "export", ref index)) - return false; - - SkipWhitespace(text, ref index); - if (index >= text.Length) - return true; - - if (!TryConsumeKeyword(text, "type", ref index)) - return false; - - SkipWhitespace(text, ref index); - return index >= text.Length; - } - - private static bool TryConsumeKeyword(string text, string keyword, ref int index) - { - if (index + keyword.Length > text.Length - || string.CompareOrdinal(text, index, keyword, 0, keyword.Length) != 0) - { - return false; - } - - var after = index + keyword.Length; - if (after < text.Length && IsTypeScriptIdentifierPart(text[after])) - return false; - - index = after; - return true; - } - - private static void SkipWhitespace(string text, ref int index) - { - while (index < text.Length && char.IsWhiteSpace(text[index])) - index++; - } - - private static bool IsTypeScriptIdentifierPart(char ch) => - ch == '_' || ch == '$' || char.IsLetterOrDigit(ch); - - private static bool IsTypeScriptIdentifier(string text) => - IsTypeScriptIdentifier(text.AsSpan()); - - private static bool IsTypeScriptIdentifier(ReadOnlySpan text) - { - if (text.Length == 0 || !IsTypeScriptIdentifierStart(text[0])) - return false; - - for (var index = 1; index < text.Length; index++) - { - if (!IsTypeScriptIdentifierPart(text[index])) - return false; - } - - return true; - } - - private static bool IsTypeScriptIdentifierStart(char ch) => - ch == '_' || ch == '$' || char.IsLetter(ch); } From cce23b56647ec22534f07d8b3bda95828fc51903 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:22:46 +0900 Subject: [PATCH 044/101] Split SQL reference extraction phases --- .../SqlReferenceExtractor.LineMasking.cs | 335 ++++ .../SqlReferenceExtractor.QualifiedColumns.cs | 415 ++++ .../SqlReferenceExtractor.Sources.cs | 362 ++++ .../SqlReferenceExtractor.StatementState.cs | 616 ++++++ .../Languages/SqlReferenceExtractor.cs | 1684 ----------------- 5 files changed, 1728 insertions(+), 1684 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.LineMasking.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.QualifiedColumns.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.Sources.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.StatementState.cs diff --git a/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.LineMasking.cs b/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.LineMasking.cs new file mode 100644 index 000000000..2dd9fd335 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.LineMasking.cs @@ -0,0 +1,335 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class SqlReferenceExtractor +{ + private static string PrepareLineForIdentifierScan( + string line, + IdentifierScanState state, + string? statementPrefix, + out bool lineEndedByLineComment, + out IdentifierScanState nextState) + { + lineEndedByLineComment = false; + if (string.IsNullOrEmpty(line)) + { + nextState = state; + return line; + } + + char[]? sanitized = null; + bool inBlockComment = state.InBlockComment; + string? dollarQuoteDelimiter = state.DollarQuoteDelimiter; + bool inSingleQuotedString = state.InSingleQuotedString; + + void BlankRange(int start, int endExclusive) + { + start = Math.Max(0, start); + endExclusive = Math.Min(line.Length, endExclusive); + sanitized ??= line.ToCharArray(); + for (int blankIndex = start; blankIndex < endExclusive; blankIndex++) + sanitized[blankIndex] = ' '; + } + + for (int i = 0; i < line.Length;) + { + if (inBlockComment) + { + int closing = line.IndexOf("*/", i, StringComparison.Ordinal); + int end = closing >= 0 ? closing + 2 : line.Length; + BlankRange(i, end); + if (closing < 0) + break; + i = end; + inBlockComment = false; + continue; + } + if (!string.IsNullOrEmpty(dollarQuoteDelimiter)) + { + int closing = line.IndexOf(dollarQuoteDelimiter, i, StringComparison.Ordinal); + if (closing < 0) + { + BlankRange(i, line.Length); + break; + } + + int nextContent = SkipWhitespaceAhead(line, closing + dollarQuoteDelimiter.Length); + if (nextContent < line.Length + && line[nextContent] != ';' + && line[nextContent] != ',' + && line[nextContent] != ')' + && line[nextContent] != ']') + { + int nestedClosing = line.IndexOf( + dollarQuoteDelimiter, + closing + dollarQuoteDelimiter.Length, + StringComparison.Ordinal); + if (nestedClosing >= 0) + { + int end = nestedClosing + dollarQuoteDelimiter.Length; + BlankRange(i, end); + i = end; + continue; + } + } + + int closingEnd = closing + dollarQuoteDelimiter.Length; + BlankRange(i, closingEnd); + i = closingEnd; + dollarQuoteDelimiter = null; + continue; + } + if (inSingleQuotedString) + { + int closing = FindClosingSingleQuote(line, i); + int end = closing >= 0 ? closing + 1 : line.Length; + BlankRange(i, end); + i = end; + if (closing >= 0) + { + inSingleQuotedString = false; + continue; + } + + break; + } + + char c = line[i]; + if (c == '"') + { + int closing = FindClosingDoubleQuote(line, i + 1); + if (closing < 0) + break; + i = closing + 1; + continue; + } + if (c == '`') + { + int closing = line.IndexOf('`', i + 1); + if (closing < 0) + break; + i = closing + 1; + continue; + } + if (c == '[') + { + int closing = line.IndexOf(']', i + 1); + if (closing < 0) + break; + i = closing + 1; + continue; + } + if (c == '\'') + { + int closing = FindClosingSingleQuote(line, i + 1); + int end = closing >= 0 ? closing + 1 : line.Length; + BlankRange(i, end); + i = end; + if (closing < 0) + inSingleQuotedString = true; + continue; + } + if (c == '/' && i + 1 < line.Length && line[i + 1] == '*') + { + BlankRange(i, i + 2); + i += 2; + inBlockComment = true; + continue; + } + if (c == '-' && i + 1 < line.Length && line[i + 1] == '-') + { + lineEndedByLineComment = true; + BlankRange(i, line.Length); + break; + } + if (c == '#') + { + if (ShouldTreatHashAsComment(line, i, statementPrefix)) + { + lineEndedByLineComment = true; + BlankRange(i, line.Length); + break; + } + } + if (c == '$' && TryReadDollarQuoteDelimiter(line, i, out var delimiter)) + { + BlankRange(i, i + delimiter.Length); + i += delimiter.Length; + dollarQuoteDelimiter = delimiter; + continue; + } + + i++; + } + + nextState = new IdentifierScanState(inBlockComment, dollarQuoteDelimiter, inSingleQuotedString); + return sanitized is null ? line : new string(sanitized); + } + + private static bool ShouldTreatHashAsComment(string line, int hashIndex, string? statementPrefix) + { + if (hashIndex < 0 || hashIndex >= line.Length || line[hashIndex] != '#') + return false; + + int probe = hashIndex - 1; + while (probe >= 0 && char.IsWhiteSpace(line[probe])) + probe--; + if (probe < 0 && !string.IsNullOrWhiteSpace(statementPrefix)) + { + var combined = statementPrefix + "\n" + line; + return ShouldTreatHashAsCommentCore(combined, statementPrefix.Length + 1 + hashIndex); + } + + return ShouldTreatHashAsCommentCore(line, hashIndex); + } + + private static bool ShouldTreatHashAsCommentCore(string line, int hashIndex) + { + if (hashIndex < 0 || hashIndex >= line.Length || line[hashIndex] != '#') + return false; + + int next = hashIndex + 1; + if (hashIndex > 0 + && line[hashIndex - 1] == '#' + && next < line.Length + && (char.IsLetterOrDigit(line[next]) || line[next] == '_')) + return false; + if (next + 1 < line.Length + && line[next] == '#' + && (char.IsLetterOrDigit(line[next + 1]) || line[next + 1] == '_')) + return false; + if (next >= line.Length || !(char.IsLetterOrDigit(line[next]) || line[next] == '_')) + return true; + + int probe = hashIndex - 1; + while (probe >= 0 && char.IsWhiteSpace(line[probe])) + probe--; + while (probe >= 0 && line[probe] == ',') + { + var priorListItem = line[..probe]; + int sourceStart = FindLastCommaOutsideQuotedIdentifiers(priorListItem); + if (sourceStart >= 0) + sourceStart++; + else + { + var usingMatches = UsingKeywordRegex.Matches(priorListItem); + if (usingMatches.Count > 0) + sourceStart = usingMatches[^1].Index + usingMatches[^1].Length; + else + { + sourceStart = priorListItem.LastIndexOf('#'); + if (sourceStart < 0) + return true; + } + } + while (sourceStart < priorListItem.Length && char.IsWhiteSpace(priorListItem[sourceStart])) + sourceStart++; + + var listMatch = TrailingTempIdentifierRegex.Match(priorListItem[sourceStart..]); + if (!listMatch.Success) + return true; + + probe = sourceStart - 1; + while (probe >= 0 && char.IsWhiteSpace(line[probe])) + probe--; + } + if (probe < 0) + return true; + if (line[probe] == '.') + return false; + if (line[probe] == ')') + { + int depth = 1; + probe--; + while (probe >= 0 && depth > 0) + { + if (line[probe] == ')') + depth++; + else if (line[probe] == '(') + depth--; + probe--; + } + while (probe >= 0 && char.IsWhiteSpace(line[probe])) + probe--; + if (probe < 0) + return true; + + int modifierEnd = probe; + while (probe >= 0 && char.IsLetter(line[probe])) + probe--; + int modifierStart = probe + 1; + if (modifierStart <= modifierEnd + && string.Equals(line[modifierStart..(modifierEnd + 1)], "TOP", StringComparison.OrdinalIgnoreCase)) + { + while (probe >= 0 && char.IsWhiteSpace(line[probe])) + probe--; + if (probe < 0) + return true; + } + } + + int tokenEnd = probe; + while (probe >= 0 && char.IsLetter(line[probe])) + probe--; + int tokenStart = probe + 1; + if (tokenStart > tokenEnd) + return true; + + var token = line[tokenStart..(tokenEnd + 1)]; + return !string.Equals(token, "FROM", StringComparison.OrdinalIgnoreCase) + && !string.Equals(token, "JOIN", StringComparison.OrdinalIgnoreCase) + && !string.Equals(token, "MERGE", StringComparison.OrdinalIgnoreCase) + && !string.Equals(token, "USING", StringComparison.OrdinalIgnoreCase) + && !string.Equals(token, "INTO", StringComparison.OrdinalIgnoreCase) + && !string.Equals(token, "UPDATE", StringComparison.OrdinalIgnoreCase) + && !string.Equals(token, "TABLE", StringComparison.OrdinalIgnoreCase) + && !string.Equals(token, "EXEC", StringComparison.OrdinalIgnoreCase) + && !string.Equals(token, "EXECUTE", StringComparison.OrdinalIgnoreCase) + && !string.Equals(token, "CALL", StringComparison.OrdinalIgnoreCase) + && !string.Equals(token, "PROCEDURE", StringComparison.OrdinalIgnoreCase) + && !string.Equals(token, "PROC", StringComparison.OrdinalIgnoreCase) + && !string.Equals(token, "FUNCTION", StringComparison.OrdinalIgnoreCase); + } + + private static int FindLastCommaOutsideQuotedIdentifiers(string text) + { + int lastComma = -1; + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (c == '"') + { + int closing = FindClosingDoubleQuote(text, i + 1); + if (closing < 0) + break; + i = closing; + continue; + } + if (c == '`') + { + int closing = text.IndexOf('`', i + 1); + if (closing < 0) + break; + i = closing; + continue; + } + if (c == '[') + { + int closing = text.IndexOf(']', i + 1); + if (closing < 0) + break; + i = closing; + continue; + } + if (c == ',') + lastComma = i; + } + + return lastComma; + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.QualifiedColumns.cs b/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.QualifiedColumns.cs new file mode 100644 index 000000000..36d8cdac2 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.QualifiedColumns.cs @@ -0,0 +1,415 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class SqlReferenceExtractor +{ + private readonly record struct TextSegment(string Text, int StartIndex); + + private static void EmitQualifiedColumnReferences( + string text, + int textStart, + string statement, + int statementStart, + int statementLineOffset, + int lineOffset, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + Func resolveContainerForCall, + Func shouldIgnoreName, + string referenceKind) + { + foreach (Match match in BoundedRegex.EnumerateMatches(QualifiedColumnReferenceRegex, text)) + { + if (IsInsideDoubleQuotedRegion(text, match.Index)) + continue; + + var nameGroup = match.Groups["name"]; + EmitMergeColumnReference( + nameGroup.Value, + textStart + nameGroup.Index, + statement, + statementStart, + statementLineOffset, + lineOffset, + context, + lineNumber, + references, + seen, + fileId, + resolveContainerForCall, + shouldIgnoreName, + referenceKind); + } + } + + private static void EmitMergeColumnReference( + string rawName, + int rawIndex, + string statement, + int statementStart, + int statementLineOffset, + int lineOffset, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + Func resolveContainerForCall, + Func shouldIgnoreName, + string referenceKind) + { + var trimmedStart = 0; + while (trimmedStart < rawName.Length && char.IsWhiteSpace(rawName[trimmedStart])) + trimmedStart++; + var trimmedEnd = rawName.Length; + while (trimmedEnd > trimmedStart && char.IsWhiteSpace(rawName[trimmedEnd - 1])) + trimmedEnd--; + if (trimmedStart >= trimmedEnd) + return; + + rawName = rawName[trimmedStart..trimmedEnd]; + rawIndex += trimmedStart; + var leafIndex = FindQualifiedIdentifierLeafIndex(rawName); + rawIndex += leafIndex; + rawName = rawName[leafIndex..].TrimStart(); + + var match = BoundedRegex.Match( + rawName, + $"^(?{QuotedIdentifierPattern}|{BareIdentifierPattern})", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!match.Success) + return; + + var nameGroup = match.Groups["name"]; + var absoluteNameIndex = rawIndex + nameGroup.Index; + if (absoluteNameIndex < statementLineOffset) + return; + + NormalizeIdentifier(nameGroup.Value, absoluteNameIndex, out var resolvedName, out var nameIndex, out var wasQuoted); + if (!wasQuoted && shouldIgnoreName(resolvedName)) + return; + + var nameColumn = nameIndex + statementStart - lineOffset; + var container = resolveContainerForCall(absoluteNameIndex); + ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, referenceKind, context, lineNumber, container); + } + + private static int FindQualifiedIdentifierLeafIndex(string rawName) + { + var leafStart = 0; + var quote = '\0'; + for (var i = 0; i < rawName.Length; i++) + { + var ch = rawName[i]; + if (quote != '\0') + { + if (quote == '[') + { + if (ch == ']') + { + if (i + 1 < rawName.Length && rawName[i + 1] == ']') + i++; + else + quote = '\0'; + } + continue; + } + + if (ch == quote) + { + if (i + 1 < rawName.Length && rawName[i + 1] == quote) + i++; + else + quote = '\0'; + } + continue; + } + + if (ch is '[' or '"' or '`') + { + quote = ch; + continue; + } + + if (ch != '.') + continue; + + leafStart = i + 1; + while (leafStart < rawName.Length && char.IsWhiteSpace(rawName[leafStart])) + leafStart++; + } + + return leafStart; + } + + private static IEnumerable SplitTopLevelCommaSegments(string text, int textStart) + { + var segmentStart = 0; + var depth = 0; + var quote = '\0'; + for (var i = 0; i < text.Length; i++) + { + var ch = text[i]; + if (quote != '\0') + { + if (quote == '[') + { + if (ch == ']') + { + if (i + 1 < text.Length && text[i + 1] == ']') + i++; + else + quote = '\0'; + } + continue; + } + + if (ch == quote) + { + if (i + 1 < text.Length && text[i + 1] == quote) + i++; + else + quote = '\0'; + } + continue; + } + + if (ch is '[' or '"' or '`' or '\'') + { + quote = ch; + continue; + } + + if (ch == '(') + { + depth++; + continue; + } + if (ch == ')' && depth > 0) + { + depth--; + continue; + } + if (ch != ',' || depth != 0) + continue; + + yield return new TextSegment(text[segmentStart..i], textStart + segmentStart); + segmentStart = i + 1; + } + + yield return new TextSegment(text[segmentStart..], textStart + segmentStart); + } + + private static int IndexOfTopLevelChar(string text, char value) + { + var depth = 0; + var quote = '\0'; + for (var i = 0; i < text.Length; i++) + { + var ch = text[i]; + if (quote != '\0') + { + if (quote == '[') + { + if (ch == ']') + { + if (i + 1 < text.Length && text[i + 1] == ']') + i++; + else + quote = '\0'; + } + continue; + } + + if (ch == quote) + { + if (i + 1 < text.Length && text[i + 1] == quote) + i++; + else + quote = '\0'; + } + continue; + } + + if (ch is '[' or '"' or '`' or '\'') + { + quote = ch; + continue; + } + + if (ch == '(') + { + depth++; + continue; + } + if (ch == ')' && depth > 0) + { + depth--; + continue; + } + if (ch == value && depth == 0) + return i; + } + + return -1; + } + + private static void EmitGeneratedColumnDependencyReferences( + string statement, + int statementStart, + int statementLineOffset, + int lineOffset, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + Func resolveContainerForCall, + Func shouldIgnoreName) + { + var hasAsKeyword = statement.IndexOf("AS", StringComparison.OrdinalIgnoreCase) >= 0; + var hasGeneratedKeyword = statement.IndexOf("GENERATED", StringComparison.OrdinalIgnoreCase) >= 0; + var hasNextKeyword = statement.IndexOf("NEXT", StringComparison.OrdinalIgnoreCase) >= 0; + if (!hasAsKeyword && !hasGeneratedKeyword && !hasNextKeyword) + return; + + if (!GeneratedColumnMarkerRegex.IsMatch(statement)) + return; + + if (hasAsKeyword || hasGeneratedKeyword) + { + foreach (Match match in GeneratedColumnExpressionStartRegex.Matches(statement)) + { + if (match.Index < statementLineOffset || IsInsideDoubleQuotedRegion(statement, match.Index)) + continue; + if (match.Value.TrimStart().StartsWith("AS", StringComparison.OrdinalIgnoreCase) + && !IsLikelyComputedColumnAsExpression(statement, match.Index)) + { + continue; + } + + var openParenIndex = statement.IndexOf('(', match.Index + match.Length - 1); + if (openParenIndex < 0) + continue; + + var closeParenIndex = FindMatchingParen(statement, openParenIndex); + if (closeParenIndex <= openParenIndex) + continue; + + EmitSqlExpressionIdentifierDependencies( + statement, + openParenIndex + 1, + closeParenIndex, + statementStart, + statementLineOffset, + lineOffset, + context, + lineNumber, + references, + seen, + fileId, + resolveContainerForCall, + shouldIgnoreName); + } + } + + if (statement.IndexOf("DEFAULT", StringComparison.OrdinalIgnoreCase) >= 0 + && hasNextKeyword + && statement.IndexOf("VALUE", StringComparison.OrdinalIgnoreCase) >= 0 + && statement.IndexOf("FOR", StringComparison.OrdinalIgnoreCase) >= 0) + { + foreach (Match match in DefaultNextValueForExpressionRegex.Matches(statement)) + { + if (match.Index < statementLineOffset || IsInsideDoubleQuotedRegion(statement, match.Index)) + continue; + + var sequence = match.Groups["name"]; + EmitSqlExpressionIdentifierDependencies( + statement, + sequence.Index, + sequence.Index + sequence.Length, + statementStart, + statementLineOffset, + lineOffset, + context, + lineNumber, + references, + seen, + fileId, + resolveContainerForCall, + shouldIgnoreName); + } + } + } + + private static void EmitSqlExpressionIdentifierDependencies( + string statement, + int startIndex, + int endIndexExclusive, + int statementStart, + int statementLineOffset, + int lineOffset, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + Func resolveContainerForCall, + Func shouldIgnoreName) + { + var expression = statement[startIndex..endIndexExclusive]; + foreach (Match match in SqlExpressionIdentifierRegex.Matches(expression)) + { + var rawIndex = startIndex + match.Index; + if (rawIndex < statementLineOffset || IsInsideDoubleQuotedRegion(statement, rawIndex)) + continue; + + var rawName = match.Value; + NormalizeIdentifier(rawName, rawIndex, out var resolvedName, out var nameIndex, out var wasQuoted); + if (!wasQuoted && (shouldIgnoreName(resolvedName) || IsGeneratedColumnDependencyKeyword(resolvedName))) + continue; + + var nameColumn = nameIndex + statementStart - lineOffset; + var container = resolveContainerForCall(rawIndex); + ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, "generated_column_dependency", context, lineNumber, container); + } + } + + private static bool IsGeneratedColumnDependencyKeyword(string name) + => name.Equals("GENERATED", StringComparison.OrdinalIgnoreCase) + || name.Equals("ALWAYS", StringComparison.OrdinalIgnoreCase) + || name.Equals("AS", StringComparison.OrdinalIgnoreCase) + || name.Equals("DEFAULT", StringComparison.OrdinalIgnoreCase) + || name.Equals("NEXT", StringComparison.OrdinalIgnoreCase) + || name.Equals("VALUE", StringComparison.OrdinalIgnoreCase) + || name.Equals("FOR", StringComparison.OrdinalIgnoreCase) + || name.Equals("STORED", StringComparison.OrdinalIgnoreCase) + || name.Equals("VIRTUAL", StringComparison.OrdinalIgnoreCase) + || name.Equals("PERSISTED", StringComparison.OrdinalIgnoreCase) + || name.Equals("NULL", StringComparison.OrdinalIgnoreCase) + || name.Equals("NOT", StringComparison.OrdinalIgnoreCase); + + private static bool IsLikelyComputedColumnAsExpression(string statement, int asIndex) + { + var prefix = statement[..asIndex]; + if (prefix.IndexOf("TABLE", StringComparison.OrdinalIgnoreCase) < 0) + return false; + if (prefix.IndexOf("ALTER", StringComparison.OrdinalIgnoreCase) < 0 + && prefix.IndexOf("CREATE", StringComparison.OrdinalIgnoreCase) < 0) + { + return false; + } + + return Regex.IsMatch(prefix, @"(? references, + ReferenceDedupeSet seen, + long fileId, + HashSet establishedTempObjectNames, + HashSet suppressedCallIndices, + Func resolveContainerForCall, + Func shouldIgnoreName, + string referenceKind = "reference") + { + if (rawIndex < statementLineOffset) + return; + + var followedByOpenParen = IsFollowedByOpenParen(statement, rawIndex + rawName.Length); + NormalizeIdentifier(rawName, rawIndex, out var resolvedName, out var nameIndex, out var wasQuoted); + int nameColumn = nameIndex + statementStart - lineOffset; + if (!wasQuoted && shouldIgnoreName(resolvedName)) + return; + if (followedByOpenParen) + { + var container = resolveContainerForCall(rawIndex); + ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, "call", context, lineNumber, container); + if (!wasQuoted) + suppressedCallIndices.Add(GetCallLikeSuppressionIndex(statement, rawIndex) + statementStart - lineOffset); + return; + } + if (resolvedName.StartsWith("#", StringComparison.Ordinal) + && !establishedTempObjectNames.Contains(resolvedName)) + return; + + var referenceContainer = resolveContainerForCall(rawIndex); + ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, referenceKind, context, lineNumber, referenceContainer); + } + + private static string GetSourceReferenceKind(int index, IReadOnlyList? cteBodySpans) + { + if (cteBodySpans == null) + return "reference"; + + foreach (var span in cteBodySpans) + { + if (index >= span.StartIndex && index < span.EndIndexExclusive) + return "cte_body_reference"; + } + + return "reference"; + } + + private static List? FindCteBodySpans(string statement) + { + if (statement.IndexOf("WITH", StringComparison.OrdinalIgnoreCase) < 0) + return null; + + List? spans = null; + foreach (Match match in CteDefinitionRegex.Matches(statement)) + { + if (IsInsideDoubleQuotedRegion(statement, match.Index)) + continue; + + var openParenIndex = match.Index + match.Length - 1; + var closeParenIndex = FindMatchingParen(statement, openParenIndex); + (spans ??= []).Add(new CteBodySpan(openParenIndex + 1, closeParenIndex < 0 ? statement.Length : closeParenIndex)); + } + + return spans; + } + + private static int FindMatchingParen(string text, int openParenIndex) + { + var depth = 0; + for (var i = openParenIndex; i < text.Length; i++) + { + if (text[i] == '(') + { + depth++; + continue; + } + + if (text[i] != ')') + continue; + + depth--; + if (depth == 0) + return i; + } + + return -1; + } + + private static int FindMatchingOpenParen(string text, int closeParenIndex) + { + var depth = 0; + for (var i = closeParenIndex; i >= 0; i--) + { + if (text[i] == ')') + { + depth++; + continue; + } + + if (text[i] != '(') + continue; + + depth--; + if (depth == 0) + return i; + } + + return -1; + } + + private static void EmitSelectIntoTargetReferences( + string statement, + int statementStart, + int statementLineOffset, + int lineOffset, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + Func resolveContainerForCall, + Func shouldIgnoreName, + HashSet? suppressedCallIndices = null) + { + foreach (Match match in SelectIntoTargetStatementRegex.Matches(statement)) + { + if (IsInsideDoubleQuotedRegion(statement, match.Index)) + continue; + var nameGroup = match.Groups["name"]; + if (nameGroup.Index < statementLineOffset) + continue; + NormalizeIdentifier(nameGroup.Value, nameGroup.Index, out var resolvedName, out var nameIndex, out var wasQuoted); + int nameColumn = nameIndex + statementStart - lineOffset; + if (!wasQuoted && shouldIgnoreName(resolvedName)) + continue; + + var container = resolveContainerForCall(nameGroup.Index); + ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, "reference", context, lineNumber, container); + } + } + + private static void EmitTargetReferences( + string statement, + int statementStart, + int statementLineOffset, + int lineOffset, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + HashSet suppressedCallIndices, + Func resolveContainerForCall, + Func shouldIgnoreName) + { + foreach (Match match in TargetReferenceRegex.Matches(statement)) + { + if (IsInsideDoubleQuotedRegion(statement, match.Index)) + continue; + if (!TryGetTrailingQualifiedIdentifierLeaf(match, out var rawName, out var rawIndex)) + continue; + if (rawIndex < statementLineOffset) + continue; + NormalizeIdentifier(rawName, rawIndex, out var resolvedName, out var nameIndex, out var wasQuoted); + int nameColumn = nameIndex + statementStart - lineOffset; + if (!wasQuoted && shouldIgnoreName(resolvedName)) + continue; + if (!wasQuoted + && string.Equals(resolvedName, "SET", StringComparison.OrdinalIgnoreCase) + && match.Value.StartsWith("UPDATE", StringComparison.OrdinalIgnoreCase)) + continue; + if (!wasQuoted + && string.Equals(resolvedName, "STATISTICS", StringComparison.OrdinalIgnoreCase) + && match.Value.StartsWith("UPDATE", StringComparison.OrdinalIgnoreCase)) + continue; + + var container = resolveContainerForCall(rawIndex); + ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, "reference", context, lineNumber, container); + if (IsFollowedByOpenParen(statement, rawIndex + rawName.Length)) + AddCallLikeSuppressionIndices(suppressedCallIndices, statement, rawIndex, statementStart, lineOffset); + } + } + + private static bool TryGetTrailingQualifiedIdentifierLeaf(Match match, out string rawName, out int rawIndex) => + TryGetTrailingQualifiedIdentifierLeaf(match.Value, match.Index, out rawName, out rawIndex); + + private static bool TryGetTrailingQualifiedIdentifierLeaf( + string text, + int absoluteOffset, + out string rawName, + out int rawIndex) + { + rawName = string.Empty; + rawIndex = -1; + + var end = text.Length; + while (end > 0 && char.IsWhiteSpace(text[end - 1])) + end--; + if (end <= 0) + return false; + + var last = text[end - 1]; + if (last == ']') + { + var bracketStart = text.LastIndexOf('[', end - 2); + if (bracketStart >= 0) + { + rawName = text[bracketStart..end]; + rawIndex = absoluteOffset + bracketStart; + return true; + } + + return false; + } + + if (last == '`') + { + var backtickStart = text.LastIndexOf('`', end - 2); + if (backtickStart >= 0) + { + rawName = text[backtickStart..end]; + rawIndex = absoluteOffset + backtickStart; + return true; + } + + return false; + } + + if (last == '"') + { + var doubleQuoteStart = FindOpeningDoubleQuoteForIdentifier(text, end - 1); + if (doubleQuoteStart >= 0) + { + rawName = text[doubleQuoteStart..end]; + rawIndex = absoluteOffset + doubleQuoteStart; + return true; + } + + return false; + } + + var bareStart = end - 1; + while (bareStart >= 0 && IsSqlBareIdentifierPart(text[bareStart])) + bareStart--; + bareStart++; + if (bareStart >= end) + return false; + + var identifierStart = bareStart; + if (identifierStart > 0 && text[identifierStart - 1] == '#') + { + identifierStart--; + if (identifierStart > 0 && text[identifierStart - 1] == '#') + identifierStart--; + if (identifierStart > 0 && text[identifierStart - 1] == '#') + return false; + } + else if (!IsSqlBareIdentifierStart(text[identifierStart])) + { + return false; + } + + rawName = text[identifierStart..end]; + rawIndex = absoluteOffset + identifierStart; + return true; + } + + private static int FindOpeningDoubleQuoteForIdentifier(string text, int closingQuoteIndex) + { + var index = closingQuoteIndex - 1; + while (index >= 0) + { + if (text[index] != '"') + { + index--; + continue; + } + + var runStart = index; + while (runStart > 0 && text[runStart - 1] == '"') + runStart--; + var runLength = index - runStart + 1; + if (runLength % 2 == 1) + return runStart; + + index = runStart - 1; + } + + return -1; + } + + private static bool IsSqlBareIdentifierStart(char value) => + value == '_' || char.IsLetter(value); + + private static bool IsSqlBareIdentifierPart(char value) + { + if (char.IsLetterOrDigit(value) || value == '_' || value == '$') + return true; + + var category = CharUnicodeInfo.GetUnicodeCategory(value); + return category is UnicodeCategory.NonSpacingMark + or UnicodeCategory.SpacingCombiningMark + or UnicodeCategory.ConnectorPunctuation; + } + + private static void EmitMultiTargetReferences( + MatchCollection matches, + string statement, + int statementStart, + int statementLineOffset, + int lineOffset, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + Func resolveContainerForCall, + Func shouldIgnoreName, + HashSet? suppressedCallIndices = null) + { + foreach (Match match in matches) + { + if (IsInsideDoubleQuotedRegion(statement, match.Index)) + continue; + + foreach (Capture capture in match.Groups["name"].Captures) + { + if (capture.Index < statementLineOffset) + continue; + NormalizeIdentifier(capture.Value, capture.Index, out var resolvedName, out var nameIndex, out var wasQuoted); + int nameColumn = nameIndex + statementStart - lineOffset; + if (!wasQuoted && shouldIgnoreName(resolvedName)) + continue; + + var container = resolveContainerForCall(capture.Index); + ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, "reference", context, lineNumber, container); + if (suppressedCallIndices != null && IsFollowedByOpenParen(statement, capture.Index + capture.Length)) + AddCallLikeSuppressionIndices(suppressedCallIndices, statement, capture.Index, statementStart, lineOffset); + } + } + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.StatementState.cs b/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.StatementState.cs new file mode 100644 index 000000000..feda404d4 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.StatementState.cs @@ -0,0 +1,616 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class SqlReferenceExtractor +{ + private static void NormalizeIdentifier( + string rawName, + int rawIndex, + out string resolvedName, + out int resolvedIndex, + out bool wasQuoted) + { + if (rawName.Length >= 2 + && ((rawName[0] == '[' && rawName[^1] == ']') + || (rawName[0] == '`' && rawName[^1] == '`') + || (rawName[0] == '"' && rawName[^1] == '"'))) + { + resolvedName = rawName.Substring(1, rawName.Length - 2); + if (rawName[0] == '"') + resolvedName = resolvedName.Replace("\"\"", "\"", StringComparison.Ordinal); + else if (rawName[0] == '[') + resolvedName = resolvedName.Replace("]]", "]", StringComparison.Ordinal); + resolvedIndex = rawIndex + 1; + wasQuoted = true; + return; + } + + resolvedName = rawName; + resolvedIndex = rawIndex; + wasQuoted = false; + } + + private static bool IsFollowedByOpenParen(string line, int index) + { + while (index < line.Length && char.IsWhiteSpace(line[index])) + index++; + + return index < line.Length && line[index] == '('; + } + + private static int GetCallLikeSuppressionIndex(string line, int index) + { + while (index < line.Length && line[index] == '#') + index++; + + return index; + } + + private static void AddCallLikeSuppressionIndices( + HashSet suppressedCallIndices, + string line, + int leafIndex, + int statementStart, + int lineOffset) + { + var leafSuppressionIndex = GetCallLikeSuppressionIndex(line, leafIndex) + statementStart - lineOffset; + suppressedCallIndices.Add(leafSuppressionIndex); + + var qualifiedStart = FindQualifiedIdentifierStart(line, leafIndex); + if (qualifiedStart == leafIndex) + return; + + suppressedCallIndices.Add(GetCallLikeSuppressionIndex(line, qualifiedStart) + statementStart - lineOffset); + } + + private static int FindQualifiedIdentifierStart(string line, int leafIndex) + { + var start = leafIndex; + while (start > 0) + { + var scan = start - 1; + while (scan >= 0 && char.IsWhiteSpace(line[scan])) + scan--; + if (scan < 0 || line[scan] != '.') + break; + + scan--; + while (scan >= 0 && char.IsWhiteSpace(line[scan])) + scan--; + if (scan < 0) + break; + + start = ScanIdentifierSegmentStart(line, scan); + } + + return start; + } + + private static int ScanIdentifierSegmentStart(string line, int index) + { + if (line[index] == ']') + { + index--; + while (index >= 0) + { + if (line[index] == '[') + return index; + index--; + } + + return 0; + } + + if (line[index] is '"' or '`') + { + var quote = line[index--]; + while (index >= 0) + { + if (line[index] == quote) + return index; + index--; + } + + return 0; + } + + while (index >= 0 && IsIdentifierContinuationForReverseScan(line[index])) + index--; + + return index + 1; + } + + private static bool IsIdentifierContinuationForReverseScan(char ch) + => ch is '_' or '$' or '#' + || char.IsLetterOrDigit(ch) + || char.GetUnicodeCategory(ch) is System.Globalization.UnicodeCategory.NonSpacingMark + or System.Globalization.UnicodeCategory.SpacingCombiningMark + or System.Globalization.UnicodeCategory.ConnectorPunctuation; + + private static string CombineStatementPrefix(string prefix, string line, out int lineOffset) + { + if (string.IsNullOrEmpty(prefix)) + { + lineOffset = 0; + return line; + } + + lineOffset = prefix.Length + 1; + return prefix + "\n" + line; + } + + private static string AdvanceStatementPrefix( + string combined, + int statementStart, + bool lineEndedByLineComment) + { + var remaining = statementStart == 0 ? combined : combined[statementStart..]; + if (!lineEndedByLineComment) + return remaining; + + return CanStatementRequireLineCommentCarry(remaining) ? remaining : string.Empty; + } + + private static bool ShouldFlushTempObjectPrefixAtLineBoundary( + string prefix, + string nextLine) + { + if (string.IsNullOrWhiteSpace(prefix) || string.IsNullOrWhiteSpace(nextLine)) + return false; + if (!CanStatementEstablishTempObject(prefix)) + return false; + + return StartsTopLevelStatement(nextLine); + } + + private static bool CanStatementEstablishTempObject(string statement) + { + if (statement.IndexOf('#') < 0) + return false; + + var mayContainTargetStatement = statement.IndexOf("INSERT", StringComparison.OrdinalIgnoreCase) >= 0 + || statement.IndexOf("UPDATE", StringComparison.OrdinalIgnoreCase) >= 0 + || statement.IndexOf("MERGE", StringComparison.OrdinalIgnoreCase) >= 0 + || statement.IndexOf("DELETE", StringComparison.OrdinalIgnoreCase) >= 0 + || statement.IndexOf("ALTER", StringComparison.OrdinalIgnoreCase) >= 0 + || statement.IndexOf("BULK", StringComparison.OrdinalIgnoreCase) >= 0; + if (mayContainTargetStatement && TargetReferenceRegex.IsMatch(statement)) + return true; + + if (statement.IndexOf("TRUNCATE", StringComparison.OrdinalIgnoreCase) >= 0 + && TruncateTargetRegex.IsMatch(statement)) + { + return true; + } + + if (statement.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase) >= 0 + && statement.IndexOf("INTO", StringComparison.OrdinalIgnoreCase) >= 0 + && SelectIntoTargetStatementRegex.IsMatch(statement)) + { + return true; + } + + if (statement.IndexOf("CREATE", StringComparison.OrdinalIgnoreCase) < 0) + return false; + + return (statement.IndexOf("TABLE", StringComparison.OrdinalIgnoreCase) >= 0 + && CreateTempTableRegex.IsMatch(statement)) + || ((statement.IndexOf("PROC", StringComparison.OrdinalIgnoreCase) >= 0 + || statement.IndexOf("FUNCTION", StringComparison.OrdinalIgnoreCase) >= 0) + && CreateTempRoutineRegex.IsMatch(statement)); + } + + private static bool CanStatementRequireLineCommentCarry(string statement) + { + if (string.IsNullOrWhiteSpace(statement)) + return false; + + return CanStatementEstablishTempObject(statement) + || TargetReferencePrefixRegex.IsMatch(statement) + || FromListContinuationPrefixRegex.IsMatch(statement) + || SelectIntoTargetPrefixRegex.IsMatch(statement) + || DeleteUsingPrefixRegex.IsMatch(statement) + || DeleteUsingListContinuationPrefixRegex.IsMatch(statement) + || MergeUsingPrefixRegex.IsMatch(statement) + || MergeTargetHintContinuationPrefixRegex.IsMatch(statement); + } + + private static bool StartsTopLevelStatement(string line) + { + int index = 0; + while (index < line.Length && char.IsWhiteSpace(line[index])) + index++; + if (index >= line.Length || !char.IsLetter(line[index])) + return false; + + int start = index; + while (index < line.Length && char.IsLetter(line[index])) + index++; + + var keyword = line[start..index].ToUpperInvariant(); + if (keyword == "WITH") + { + while (index < line.Length && char.IsWhiteSpace(line[index])) + index++; + + return index >= line.Length || line[index] != '('; + } + + return keyword switch + { + "SELECT" => true, + "INSERT" => true, + "UPDATE" => true, + "DELETE" => true, + "MERGE" => true, + "CREATE" => true, + "ALTER" => true, + "DROP" => true, + "TRUNCATE" => true, + "SET" => true, + "DECLARE" => true, + "IF" => true, + "WHILE" => true, + "DO" => true, + "BEGIN" => true, + "EXEC" => true, + "EXECUTE" => true, + "CALL" => true, + _ => false, + }; + } + + private static int FindStatementTerminator(string text, int startIndex) + { + for (int i = startIndex; i < text.Length; i++) + { + char c = text[i]; + if (c == ';') + return i; + if (c == '`') + { + int closing = text.IndexOf('`', i + 1); + if (closing < 0) + return -1; + i = closing; + continue; + } + if (c == '[') + { + int closing = text.IndexOf(']', i + 1); + if (closing < 0) + return -1; + i = closing; + continue; + } + if (c == '"') + { + int closing = FindClosingDoubleQuote(text, i + 1); + if (closing < 0) + return -1; + i = closing; + } + } + + return -1; + } + + private static int FindClosingDoubleQuote(string text, int startIndex) + { + for (int i = startIndex; i < text.Length; i++) + { + if (text[i] != '"') + continue; + if (i + 1 < text.Length && text[i + 1] == '"') + { + i++; + continue; + } + + return i; + } + + return -1; + } + + private static int FindClosingSingleQuote(string text, int startIndex) + { + for (int i = startIndex; i < text.Length; i++) + { + if (text[i] == '\\' && i + 1 < text.Length) + { + i++; + continue; + } + if (text[i] != '\'') + continue; + if (i + 1 < text.Length && text[i + 1] == '\'') + { + i++; + continue; + } + + return i; + } + + return -1; + } + + private static bool IsInsideDoubleQuotedRegion(string text, int index) + { + if (index <= 0) + return false; + + bool inside = false; + for (int i = 0; i < index && i < text.Length; i++) + { + if (text[i] != '"') + continue; + if (inside && i + 1 < index && text[i + 1] == '"') + { + i++; + continue; + } + + inside = !inside; + } + + return inside; + } + + private static bool TryReadDollarQuoteDelimiter( + string line, + int index, + out string delimiter) + { + delimiter = string.Empty; + if (index < 0 || index >= line.Length || line[index] != '$') + return false; + if (index > 0 && (char.IsLetterOrDigit(line[index - 1]) || line[index - 1] == '_')) + return false; + if (index + 1 >= line.Length) + return false; + if (line[index + 1] == '$') + { + delimiter = "$$"; + return true; + } + if (!(char.IsLetter(line[index + 1]) || line[index + 1] == '_')) + return false; + + int probe = index + 2; + while (probe < line.Length && (char.IsLetterOrDigit(line[probe]) || line[probe] == '_')) + probe++; + if (probe >= line.Length || line[probe] != '$') + return false; + + delimiter = line[index..(probe + 1)]; + return true; + } + + private static int SkipWhitespaceAhead(string text, int index) + { + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + return index; + } + + private static void CollectTempObjectNamesFromStatement( + string statement, + HashSet names) + { + if (statement.IndexOf('#') < 0) + return; + + var mayContainTargetStatement = statement.IndexOf("INSERT", StringComparison.OrdinalIgnoreCase) >= 0 + || statement.IndexOf("UPDATE", StringComparison.OrdinalIgnoreCase) >= 0 + || statement.IndexOf("MERGE", StringComparison.OrdinalIgnoreCase) >= 0 + || statement.IndexOf("DELETE", StringComparison.OrdinalIgnoreCase) >= 0 + || statement.IndexOf("ALTER", StringComparison.OrdinalIgnoreCase) >= 0 + || statement.IndexOf("BULK", StringComparison.OrdinalIgnoreCase) >= 0; + if (mayContainTargetStatement) + CollectTempObjectNamesFromTargetMatches(TargetReferenceRegex.Matches(statement), statement, names); + + if (statement.IndexOf("TRUNCATE", StringComparison.OrdinalIgnoreCase) >= 0) + CollectTempObjectNamesFromMatches(TruncateTargetRegex.Matches(statement), statement, names); + + if (statement.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase) >= 0 + && statement.IndexOf("INTO", StringComparison.OrdinalIgnoreCase) >= 0) + { + CollectTempObjectNamesFromMatches(SelectIntoTargetStatementRegex.Matches(statement), statement, names); + } + + if (statement.IndexOf("CREATE", StringComparison.OrdinalIgnoreCase) < 0) + return; + + if (statement.IndexOf("TABLE", StringComparison.OrdinalIgnoreCase) >= 0) + CollectTempObjectNamesFromMatches(CreateTempTableRegex.Matches(statement), statement, names); + if (statement.IndexOf("PROC", StringComparison.OrdinalIgnoreCase) >= 0 + || statement.IndexOf("FUNCTION", StringComparison.OrdinalIgnoreCase) >= 0) + { + CollectTempObjectNamesFromMatches(CreateTempRoutineRegex.Matches(statement), statement, names); + } + } + + private static void CollectTempObjectNamesFromTargetMatches(MatchCollection matches, string statement, HashSet names) + { + foreach (Match match in matches) + { + if (IsInsideDoubleQuotedRegion(statement, match.Index)) + continue; + if (!TryGetTrailingQualifiedIdentifierLeaf(match, out var rawName, out var rawIndex)) + continue; + + NormalizeIdentifier(rawName, rawIndex, out var resolvedName, out _, out _); + if (resolvedName.StartsWith("#", StringComparison.Ordinal)) + names.Add(resolvedName); + } + } + + private static void CollectTempObjectNamesFromMatches(MatchCollection matches, string statement, HashSet names) + { + foreach (Match match in matches) + { + if (IsInsideDoubleQuotedRegion(statement, match.Index)) + continue; + var nameGroup = match.Groups["name"]; + if (nameGroup.Captures.Count == 0) + continue; + + foreach (Capture capture in nameGroup.Captures) + { + NormalizeIdentifier(capture.Value, capture.Index, out var resolvedName, out _, out _); + if (resolvedName.StartsWith("#", StringComparison.Ordinal)) + names.Add(resolvedName); + } + } + } + + private static bool TryFindDefinitionLeafSpan( + string line, + string qualifiedName, + Dictionary patternCache, + out DefinitionLeafSpan span) + { + span = default; + if (string.IsNullOrWhiteSpace(line) || string.IsNullOrWhiteSpace(qualifiedName)) + return false; + + if (!TryGetDefinitionLeafPattern(qualifiedName, patternCache, out var leafPattern)) + return false; + + var match = BoundedRegex.Match(line, leafPattern.Pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!match.Success) + return false; + + var leafGroup = match.Groups["leaf"]; + if (!leafGroup.Success) + return false; + + span = new DefinitionLeafSpan(leafPattern.LeafName, leafGroup.Index, leafGroup.Index + leafGroup.Length); + return true; + } + + private static bool TryGetDefinitionLeafPattern( + string qualifiedName, + Dictionary patternCache, + out DefinitionLeafPattern leafPattern) + { + if (patternCache.TryGetValue(qualifiedName, out leafPattern)) + return true; + + var leafName = SqlNameResolver.GetLeafName(qualifiedName); + if (string.IsNullOrWhiteSpace(leafName)) + return false; + + if (!TryBuildQualifiedNameSourcePattern(qualifiedName, out var pattern)) + return false; + + leafPattern = new DefinitionLeafPattern(leafName, pattern); + patternCache[qualifiedName] = leafPattern; + return true; + } + + private static bool TryBuildQualifiedNameSourcePattern(string qualifiedName, out string pattern) + { + pattern = string.Empty; + var trimmed = qualifiedName.Trim(); + if (trimmed.Length == 0) + return false; + + var builder = new StringBuilder(trimmed.Length + "(?)".Length); + string? pendingSegment = null; + var segmentStart = 0; + char quote = '\0'; + + for (var i = 0; i < trimmed.Length; i++) + { + var ch = trimmed[i]; + if (quote != '\0') + { + if (quote == '[') + { + if (ch == ']') + { + if (i + 1 < trimmed.Length && trimmed[i + 1] == ']') + i++; + else + quote = '\0'; + } + + continue; + } + + if (ch == quote) + { + if (i + 1 < trimmed.Length && trimmed[i + 1] == quote) + i++; + else + quote = '\0'; + } + + continue; + } + + if (ch is '[' or '"' or '`') + { + quote = ch; + continue; + } + + if (ch == '.') + { + QueueQualifiedNameSourcePatternSegment(builder, trimmed, segmentStart, i, ref pendingSegment); + segmentStart = i + 1; + continue; + } + + } + + QueueQualifiedNameSourcePatternSegment(builder, trimmed, segmentStart, trimmed.Length, ref pendingSegment); + if (pendingSegment is null) + return false; + + AppendQualifiedNameSourcePatternSegment(builder, pendingSegment, isLeaf: true); + pattern = builder.ToString(); + return true; + } + + private static void QueueQualifiedNameSourcePatternSegment( + StringBuilder builder, + string text, + int segmentStart, + int segmentEnd, + ref string? pendingSegment) + { + while (segmentStart < segmentEnd && char.IsWhiteSpace(text[segmentStart])) + segmentStart++; + while (segmentEnd > segmentStart && char.IsWhiteSpace(text[segmentEnd - 1])) + segmentEnd--; + if (segmentStart >= segmentEnd) + return; + + if (pendingSegment is not null) + AppendQualifiedNameSourcePatternSegment(builder, pendingSegment, isLeaf: false); + + pendingSegment = text[segmentStart..segmentEnd]; + } + + private static void AppendQualifiedNameSourcePatternSegment(StringBuilder builder, string segment, bool isLeaf) + { + if (builder.Length > 0) + builder.Append(@"\s*\.\s*"); + + var escaped = Regex.Escape(segment); + if (isLeaf) + builder.Append("(?").Append(escaped).Append(')'); + else + builder.Append(escaped); + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.cs index 0c777750a..e27009707 100644 --- a/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/SqlReferenceExtractor.cs @@ -844,1688 +844,4 @@ private static void EmitMergeInsertColumnReferences( } } - private readonly record struct TextSegment(string Text, int StartIndex); - - private static void EmitQualifiedColumnReferences( - string text, - int textStart, - string statement, - int statementStart, - int statementLineOffset, - int lineOffset, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - Func resolveContainerForCall, - Func shouldIgnoreName, - string referenceKind) - { - foreach (Match match in BoundedRegex.EnumerateMatches(QualifiedColumnReferenceRegex, text)) - { - if (IsInsideDoubleQuotedRegion(text, match.Index)) - continue; - - var nameGroup = match.Groups["name"]; - EmitMergeColumnReference( - nameGroup.Value, - textStart + nameGroup.Index, - statement, - statementStart, - statementLineOffset, - lineOffset, - context, - lineNumber, - references, - seen, - fileId, - resolveContainerForCall, - shouldIgnoreName, - referenceKind); - } - } - - private static void EmitMergeColumnReference( - string rawName, - int rawIndex, - string statement, - int statementStart, - int statementLineOffset, - int lineOffset, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - Func resolveContainerForCall, - Func shouldIgnoreName, - string referenceKind) - { - var trimmedStart = 0; - while (trimmedStart < rawName.Length && char.IsWhiteSpace(rawName[trimmedStart])) - trimmedStart++; - var trimmedEnd = rawName.Length; - while (trimmedEnd > trimmedStart && char.IsWhiteSpace(rawName[trimmedEnd - 1])) - trimmedEnd--; - if (trimmedStart >= trimmedEnd) - return; - - rawName = rawName[trimmedStart..trimmedEnd]; - rawIndex += trimmedStart; - var leafIndex = FindQualifiedIdentifierLeafIndex(rawName); - rawIndex += leafIndex; - rawName = rawName[leafIndex..].TrimStart(); - - var match = BoundedRegex.Match( - rawName, - $"^(?{QuotedIdentifierPattern}|{BareIdentifierPattern})", - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - if (!match.Success) - return; - - var nameGroup = match.Groups["name"]; - var absoluteNameIndex = rawIndex + nameGroup.Index; - if (absoluteNameIndex < statementLineOffset) - return; - - NormalizeIdentifier(nameGroup.Value, absoluteNameIndex, out var resolvedName, out var nameIndex, out var wasQuoted); - if (!wasQuoted && shouldIgnoreName(resolvedName)) - return; - - var nameColumn = nameIndex + statementStart - lineOffset; - var container = resolveContainerForCall(absoluteNameIndex); - ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, referenceKind, context, lineNumber, container); - } - - private static int FindQualifiedIdentifierLeafIndex(string rawName) - { - var leafStart = 0; - var quote = '\0'; - for (var i = 0; i < rawName.Length; i++) - { - var ch = rawName[i]; - if (quote != '\0') - { - if (quote == '[') - { - if (ch == ']') - { - if (i + 1 < rawName.Length && rawName[i + 1] == ']') - i++; - else - quote = '\0'; - } - continue; - } - - if (ch == quote) - { - if (i + 1 < rawName.Length && rawName[i + 1] == quote) - i++; - else - quote = '\0'; - } - continue; - } - - if (ch is '[' or '"' or '`') - { - quote = ch; - continue; - } - - if (ch != '.') - continue; - - leafStart = i + 1; - while (leafStart < rawName.Length && char.IsWhiteSpace(rawName[leafStart])) - leafStart++; - } - - return leafStart; - } - - private static IEnumerable SplitTopLevelCommaSegments(string text, int textStart) - { - var segmentStart = 0; - var depth = 0; - var quote = '\0'; - for (var i = 0; i < text.Length; i++) - { - var ch = text[i]; - if (quote != '\0') - { - if (quote == '[') - { - if (ch == ']') - { - if (i + 1 < text.Length && text[i + 1] == ']') - i++; - else - quote = '\0'; - } - continue; - } - - if (ch == quote) - { - if (i + 1 < text.Length && text[i + 1] == quote) - i++; - else - quote = '\0'; - } - continue; - } - - if (ch is '[' or '"' or '`' or '\'') - { - quote = ch; - continue; - } - - if (ch == '(') - { - depth++; - continue; - } - if (ch == ')' && depth > 0) - { - depth--; - continue; - } - if (ch != ',' || depth != 0) - continue; - - yield return new TextSegment(text[segmentStart..i], textStart + segmentStart); - segmentStart = i + 1; - } - - yield return new TextSegment(text[segmentStart..], textStart + segmentStart); - } - - private static int IndexOfTopLevelChar(string text, char value) - { - var depth = 0; - var quote = '\0'; - for (var i = 0; i < text.Length; i++) - { - var ch = text[i]; - if (quote != '\0') - { - if (quote == '[') - { - if (ch == ']') - { - if (i + 1 < text.Length && text[i + 1] == ']') - i++; - else - quote = '\0'; - } - continue; - } - - if (ch == quote) - { - if (i + 1 < text.Length && text[i + 1] == quote) - i++; - else - quote = '\0'; - } - continue; - } - - if (ch is '[' or '"' or '`' or '\'') - { - quote = ch; - continue; - } - - if (ch == '(') - { - depth++; - continue; - } - if (ch == ')' && depth > 0) - { - depth--; - continue; - } - if (ch == value && depth == 0) - return i; - } - - return -1; - } - - private static void EmitGeneratedColumnDependencyReferences( - string statement, - int statementStart, - int statementLineOffset, - int lineOffset, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - Func resolveContainerForCall, - Func shouldIgnoreName) - { - var hasAsKeyword = statement.IndexOf("AS", StringComparison.OrdinalIgnoreCase) >= 0; - var hasGeneratedKeyword = statement.IndexOf("GENERATED", StringComparison.OrdinalIgnoreCase) >= 0; - var hasNextKeyword = statement.IndexOf("NEXT", StringComparison.OrdinalIgnoreCase) >= 0; - if (!hasAsKeyword && !hasGeneratedKeyword && !hasNextKeyword) - return; - - if (!GeneratedColumnMarkerRegex.IsMatch(statement)) - return; - - if (hasAsKeyword || hasGeneratedKeyword) - { - foreach (Match match in GeneratedColumnExpressionStartRegex.Matches(statement)) - { - if (match.Index < statementLineOffset || IsInsideDoubleQuotedRegion(statement, match.Index)) - continue; - if (match.Value.TrimStart().StartsWith("AS", StringComparison.OrdinalIgnoreCase) - && !IsLikelyComputedColumnAsExpression(statement, match.Index)) - { - continue; - } - - var openParenIndex = statement.IndexOf('(', match.Index + match.Length - 1); - if (openParenIndex < 0) - continue; - - var closeParenIndex = FindMatchingParen(statement, openParenIndex); - if (closeParenIndex <= openParenIndex) - continue; - - EmitSqlExpressionIdentifierDependencies( - statement, - openParenIndex + 1, - closeParenIndex, - statementStart, - statementLineOffset, - lineOffset, - context, - lineNumber, - references, - seen, - fileId, - resolveContainerForCall, - shouldIgnoreName); - } - } - - if (statement.IndexOf("DEFAULT", StringComparison.OrdinalIgnoreCase) >= 0 - && hasNextKeyword - && statement.IndexOf("VALUE", StringComparison.OrdinalIgnoreCase) >= 0 - && statement.IndexOf("FOR", StringComparison.OrdinalIgnoreCase) >= 0) - { - foreach (Match match in DefaultNextValueForExpressionRegex.Matches(statement)) - { - if (match.Index < statementLineOffset || IsInsideDoubleQuotedRegion(statement, match.Index)) - continue; - - var sequence = match.Groups["name"]; - EmitSqlExpressionIdentifierDependencies( - statement, - sequence.Index, - sequence.Index + sequence.Length, - statementStart, - statementLineOffset, - lineOffset, - context, - lineNumber, - references, - seen, - fileId, - resolveContainerForCall, - shouldIgnoreName); - } - } - } - - private static void EmitSqlExpressionIdentifierDependencies( - string statement, - int startIndex, - int endIndexExclusive, - int statementStart, - int statementLineOffset, - int lineOffset, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - Func resolveContainerForCall, - Func shouldIgnoreName) - { - var expression = statement[startIndex..endIndexExclusive]; - foreach (Match match in SqlExpressionIdentifierRegex.Matches(expression)) - { - var rawIndex = startIndex + match.Index; - if (rawIndex < statementLineOffset || IsInsideDoubleQuotedRegion(statement, rawIndex)) - continue; - - var rawName = match.Value; - NormalizeIdentifier(rawName, rawIndex, out var resolvedName, out var nameIndex, out var wasQuoted); - if (!wasQuoted && (shouldIgnoreName(resolvedName) || IsGeneratedColumnDependencyKeyword(resolvedName))) - continue; - - var nameColumn = nameIndex + statementStart - lineOffset; - var container = resolveContainerForCall(rawIndex); - ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, "generated_column_dependency", context, lineNumber, container); - } - } - - private static bool IsGeneratedColumnDependencyKeyword(string name) - => name.Equals("GENERATED", StringComparison.OrdinalIgnoreCase) - || name.Equals("ALWAYS", StringComparison.OrdinalIgnoreCase) - || name.Equals("AS", StringComparison.OrdinalIgnoreCase) - || name.Equals("DEFAULT", StringComparison.OrdinalIgnoreCase) - || name.Equals("NEXT", StringComparison.OrdinalIgnoreCase) - || name.Equals("VALUE", StringComparison.OrdinalIgnoreCase) - || name.Equals("FOR", StringComparison.OrdinalIgnoreCase) - || name.Equals("STORED", StringComparison.OrdinalIgnoreCase) - || name.Equals("VIRTUAL", StringComparison.OrdinalIgnoreCase) - || name.Equals("PERSISTED", StringComparison.OrdinalIgnoreCase) - || name.Equals("NULL", StringComparison.OrdinalIgnoreCase) - || name.Equals("NOT", StringComparison.OrdinalIgnoreCase); - - private static bool IsLikelyComputedColumnAsExpression(string statement, int asIndex) - { - var prefix = statement[..asIndex]; - if (prefix.IndexOf("TABLE", StringComparison.OrdinalIgnoreCase) < 0) - return false; - if (prefix.IndexOf("ALTER", StringComparison.OrdinalIgnoreCase) < 0 - && prefix.IndexOf("CREATE", StringComparison.OrdinalIgnoreCase) < 0) - { - return false; - } - - return Regex.IsMatch(prefix, @"(? references, - ReferenceDedupeSet seen, - long fileId, - HashSet establishedTempObjectNames, - HashSet suppressedCallIndices, - Func resolveContainerForCall, - Func shouldIgnoreName, - string referenceKind = "reference") - { - if (rawIndex < statementLineOffset) - return; - - var followedByOpenParen = IsFollowedByOpenParen(statement, rawIndex + rawName.Length); - NormalizeIdentifier(rawName, rawIndex, out var resolvedName, out var nameIndex, out var wasQuoted); - int nameColumn = nameIndex + statementStart - lineOffset; - if (!wasQuoted && shouldIgnoreName(resolvedName)) - return; - if (followedByOpenParen) - { - var container = resolveContainerForCall(rawIndex); - ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, "call", context, lineNumber, container); - if (!wasQuoted) - suppressedCallIndices.Add(GetCallLikeSuppressionIndex(statement, rawIndex) + statementStart - lineOffset); - return; - } - if (resolvedName.StartsWith("#", StringComparison.Ordinal) - && !establishedTempObjectNames.Contains(resolvedName)) - return; - - var referenceContainer = resolveContainerForCall(rawIndex); - ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, referenceKind, context, lineNumber, referenceContainer); - } - - private static string GetSourceReferenceKind(int index, IReadOnlyList? cteBodySpans) - { - if (cteBodySpans == null) - return "reference"; - - foreach (var span in cteBodySpans) - { - if (index >= span.StartIndex && index < span.EndIndexExclusive) - return "cte_body_reference"; - } - - return "reference"; - } - - private static List? FindCteBodySpans(string statement) - { - if (statement.IndexOf("WITH", StringComparison.OrdinalIgnoreCase) < 0) - return null; - - List? spans = null; - foreach (Match match in CteDefinitionRegex.Matches(statement)) - { - if (IsInsideDoubleQuotedRegion(statement, match.Index)) - continue; - - var openParenIndex = match.Index + match.Length - 1; - var closeParenIndex = FindMatchingParen(statement, openParenIndex); - (spans ??= []).Add(new CteBodySpan(openParenIndex + 1, closeParenIndex < 0 ? statement.Length : closeParenIndex)); - } - - return spans; - } - - private static int FindMatchingParen(string text, int openParenIndex) - { - var depth = 0; - for (var i = openParenIndex; i < text.Length; i++) - { - if (text[i] == '(') - { - depth++; - continue; - } - - if (text[i] != ')') - continue; - - depth--; - if (depth == 0) - return i; - } - - return -1; - } - - private static int FindMatchingOpenParen(string text, int closeParenIndex) - { - var depth = 0; - for (var i = closeParenIndex; i >= 0; i--) - { - if (text[i] == ')') - { - depth++; - continue; - } - - if (text[i] != '(') - continue; - - depth--; - if (depth == 0) - return i; - } - - return -1; - } - - private static void EmitSelectIntoTargetReferences( - string statement, - int statementStart, - int statementLineOffset, - int lineOffset, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - Func resolveContainerForCall, - Func shouldIgnoreName, - HashSet? suppressedCallIndices = null) - { - foreach (Match match in SelectIntoTargetStatementRegex.Matches(statement)) - { - if (IsInsideDoubleQuotedRegion(statement, match.Index)) - continue; - var nameGroup = match.Groups["name"]; - if (nameGroup.Index < statementLineOffset) - continue; - NormalizeIdentifier(nameGroup.Value, nameGroup.Index, out var resolvedName, out var nameIndex, out var wasQuoted); - int nameColumn = nameIndex + statementStart - lineOffset; - if (!wasQuoted && shouldIgnoreName(resolvedName)) - continue; - - var container = resolveContainerForCall(nameGroup.Index); - ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, "reference", context, lineNumber, container); - } - } - - private static void EmitTargetReferences( - string statement, - int statementStart, - int statementLineOffset, - int lineOffset, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - HashSet suppressedCallIndices, - Func resolveContainerForCall, - Func shouldIgnoreName) - { - foreach (Match match in TargetReferenceRegex.Matches(statement)) - { - if (IsInsideDoubleQuotedRegion(statement, match.Index)) - continue; - if (!TryGetTrailingQualifiedIdentifierLeaf(match, out var rawName, out var rawIndex)) - continue; - if (rawIndex < statementLineOffset) - continue; - NormalizeIdentifier(rawName, rawIndex, out var resolvedName, out var nameIndex, out var wasQuoted); - int nameColumn = nameIndex + statementStart - lineOffset; - if (!wasQuoted && shouldIgnoreName(resolvedName)) - continue; - if (!wasQuoted - && string.Equals(resolvedName, "SET", StringComparison.OrdinalIgnoreCase) - && match.Value.StartsWith("UPDATE", StringComparison.OrdinalIgnoreCase)) - continue; - if (!wasQuoted - && string.Equals(resolvedName, "STATISTICS", StringComparison.OrdinalIgnoreCase) - && match.Value.StartsWith("UPDATE", StringComparison.OrdinalIgnoreCase)) - continue; - - var container = resolveContainerForCall(rawIndex); - ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, "reference", context, lineNumber, container); - if (IsFollowedByOpenParen(statement, rawIndex + rawName.Length)) - AddCallLikeSuppressionIndices(suppressedCallIndices, statement, rawIndex, statementStart, lineOffset); - } - } - - private static bool TryGetTrailingQualifiedIdentifierLeaf(Match match, out string rawName, out int rawIndex) => - TryGetTrailingQualifiedIdentifierLeaf(match.Value, match.Index, out rawName, out rawIndex); - - private static bool TryGetTrailingQualifiedIdentifierLeaf( - string text, - int absoluteOffset, - out string rawName, - out int rawIndex) - { - rawName = string.Empty; - rawIndex = -1; - - var end = text.Length; - while (end > 0 && char.IsWhiteSpace(text[end - 1])) - end--; - if (end <= 0) - return false; - - var last = text[end - 1]; - if (last == ']') - { - var bracketStart = text.LastIndexOf('[', end - 2); - if (bracketStart >= 0) - { - rawName = text[bracketStart..end]; - rawIndex = absoluteOffset + bracketStart; - return true; - } - - return false; - } - - if (last == '`') - { - var backtickStart = text.LastIndexOf('`', end - 2); - if (backtickStart >= 0) - { - rawName = text[backtickStart..end]; - rawIndex = absoluteOffset + backtickStart; - return true; - } - - return false; - } - - if (last == '"') - { - var doubleQuoteStart = FindOpeningDoubleQuoteForIdentifier(text, end - 1); - if (doubleQuoteStart >= 0) - { - rawName = text[doubleQuoteStart..end]; - rawIndex = absoluteOffset + doubleQuoteStart; - return true; - } - - return false; - } - - var bareStart = end - 1; - while (bareStart >= 0 && IsSqlBareIdentifierPart(text[bareStart])) - bareStart--; - bareStart++; - if (bareStart >= end) - return false; - - var identifierStart = bareStart; - if (identifierStart > 0 && text[identifierStart - 1] == '#') - { - identifierStart--; - if (identifierStart > 0 && text[identifierStart - 1] == '#') - identifierStart--; - if (identifierStart > 0 && text[identifierStart - 1] == '#') - return false; - } - else if (!IsSqlBareIdentifierStart(text[identifierStart])) - { - return false; - } - - rawName = text[identifierStart..end]; - rawIndex = absoluteOffset + identifierStart; - return true; - } - - private static int FindOpeningDoubleQuoteForIdentifier(string text, int closingQuoteIndex) - { - var index = closingQuoteIndex - 1; - while (index >= 0) - { - if (text[index] != '"') - { - index--; - continue; - } - - var runStart = index; - while (runStart > 0 && text[runStart - 1] == '"') - runStart--; - var runLength = index - runStart + 1; - if (runLength % 2 == 1) - return runStart; - - index = runStart - 1; - } - - return -1; - } - - private static bool IsSqlBareIdentifierStart(char value) => - value == '_' || char.IsLetter(value); - - private static bool IsSqlBareIdentifierPart(char value) - { - if (char.IsLetterOrDigit(value) || value == '_' || value == '$') - return true; - - var category = CharUnicodeInfo.GetUnicodeCategory(value); - return category is UnicodeCategory.NonSpacingMark - or UnicodeCategory.SpacingCombiningMark - or UnicodeCategory.ConnectorPunctuation; - } - - private static void EmitMultiTargetReferences( - MatchCollection matches, - string statement, - int statementStart, - int statementLineOffset, - int lineOffset, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - Func resolveContainerForCall, - Func shouldIgnoreName, - HashSet? suppressedCallIndices = null) - { - foreach (Match match in matches) - { - if (IsInsideDoubleQuotedRegion(statement, match.Index)) - continue; - - foreach (Capture capture in match.Groups["name"].Captures) - { - if (capture.Index < statementLineOffset) - continue; - NormalizeIdentifier(capture.Value, capture.Index, out var resolvedName, out var nameIndex, out var wasQuoted); - int nameColumn = nameIndex + statementStart - lineOffset; - if (!wasQuoted && shouldIgnoreName(resolvedName)) - continue; - - var container = resolveContainerForCall(capture.Index); - ReferenceExtractor.AddReference(references, seen, fileId, resolvedName, nameColumn, "reference", context, lineNumber, container); - if (suppressedCallIndices != null && IsFollowedByOpenParen(statement, capture.Index + capture.Length)) - AddCallLikeSuppressionIndices(suppressedCallIndices, statement, capture.Index, statementStart, lineOffset); - } - } - } - - private static void NormalizeIdentifier( - string rawName, - int rawIndex, - out string resolvedName, - out int resolvedIndex, - out bool wasQuoted) - { - if (rawName.Length >= 2 - && ((rawName[0] == '[' && rawName[^1] == ']') - || (rawName[0] == '`' && rawName[^1] == '`') - || (rawName[0] == '"' && rawName[^1] == '"'))) - { - resolvedName = rawName.Substring(1, rawName.Length - 2); - if (rawName[0] == '"') - resolvedName = resolvedName.Replace("\"\"", "\"", StringComparison.Ordinal); - else if (rawName[0] == '[') - resolvedName = resolvedName.Replace("]]", "]", StringComparison.Ordinal); - resolvedIndex = rawIndex + 1; - wasQuoted = true; - return; - } - - resolvedName = rawName; - resolvedIndex = rawIndex; - wasQuoted = false; - } - - private static bool IsFollowedByOpenParen(string line, int index) - { - while (index < line.Length && char.IsWhiteSpace(line[index])) - index++; - - return index < line.Length && line[index] == '('; - } - - private static int GetCallLikeSuppressionIndex(string line, int index) - { - while (index < line.Length && line[index] == '#') - index++; - - return index; - } - - private static void AddCallLikeSuppressionIndices( - HashSet suppressedCallIndices, - string line, - int leafIndex, - int statementStart, - int lineOffset) - { - var leafSuppressionIndex = GetCallLikeSuppressionIndex(line, leafIndex) + statementStart - lineOffset; - suppressedCallIndices.Add(leafSuppressionIndex); - - var qualifiedStart = FindQualifiedIdentifierStart(line, leafIndex); - if (qualifiedStart == leafIndex) - return; - - suppressedCallIndices.Add(GetCallLikeSuppressionIndex(line, qualifiedStart) + statementStart - lineOffset); - } - - private static int FindQualifiedIdentifierStart(string line, int leafIndex) - { - var start = leafIndex; - while (start > 0) - { - var scan = start - 1; - while (scan >= 0 && char.IsWhiteSpace(line[scan])) - scan--; - if (scan < 0 || line[scan] != '.') - break; - - scan--; - while (scan >= 0 && char.IsWhiteSpace(line[scan])) - scan--; - if (scan < 0) - break; - - start = ScanIdentifierSegmentStart(line, scan); - } - - return start; - } - - private static int ScanIdentifierSegmentStart(string line, int index) - { - if (line[index] == ']') - { - index--; - while (index >= 0) - { - if (line[index] == '[') - return index; - index--; - } - - return 0; - } - - if (line[index] is '"' or '`') - { - var quote = line[index--]; - while (index >= 0) - { - if (line[index] == quote) - return index; - index--; - } - - return 0; - } - - while (index >= 0 && IsIdentifierContinuationForReverseScan(line[index])) - index--; - - return index + 1; - } - - private static bool IsIdentifierContinuationForReverseScan(char ch) - => ch is '_' or '$' or '#' - || char.IsLetterOrDigit(ch) - || char.GetUnicodeCategory(ch) is System.Globalization.UnicodeCategory.NonSpacingMark - or System.Globalization.UnicodeCategory.SpacingCombiningMark - or System.Globalization.UnicodeCategory.ConnectorPunctuation; - - private static string CombineStatementPrefix(string prefix, string line, out int lineOffset) - { - if (string.IsNullOrEmpty(prefix)) - { - lineOffset = 0; - return line; - } - - lineOffset = prefix.Length + 1; - return prefix + "\n" + line; - } - - private static string AdvanceStatementPrefix( - string combined, - int statementStart, - bool lineEndedByLineComment) - { - var remaining = statementStart == 0 ? combined : combined[statementStart..]; - if (!lineEndedByLineComment) - return remaining; - - return CanStatementRequireLineCommentCarry(remaining) ? remaining : string.Empty; - } - - private static bool ShouldFlushTempObjectPrefixAtLineBoundary( - string prefix, - string nextLine) - { - if (string.IsNullOrWhiteSpace(prefix) || string.IsNullOrWhiteSpace(nextLine)) - return false; - if (!CanStatementEstablishTempObject(prefix)) - return false; - - return StartsTopLevelStatement(nextLine); - } - - private static bool CanStatementEstablishTempObject(string statement) - { - if (statement.IndexOf('#') < 0) - return false; - - var mayContainTargetStatement = statement.IndexOf("INSERT", StringComparison.OrdinalIgnoreCase) >= 0 - || statement.IndexOf("UPDATE", StringComparison.OrdinalIgnoreCase) >= 0 - || statement.IndexOf("MERGE", StringComparison.OrdinalIgnoreCase) >= 0 - || statement.IndexOf("DELETE", StringComparison.OrdinalIgnoreCase) >= 0 - || statement.IndexOf("ALTER", StringComparison.OrdinalIgnoreCase) >= 0 - || statement.IndexOf("BULK", StringComparison.OrdinalIgnoreCase) >= 0; - if (mayContainTargetStatement && TargetReferenceRegex.IsMatch(statement)) - return true; - - if (statement.IndexOf("TRUNCATE", StringComparison.OrdinalIgnoreCase) >= 0 - && TruncateTargetRegex.IsMatch(statement)) - { - return true; - } - - if (statement.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase) >= 0 - && statement.IndexOf("INTO", StringComparison.OrdinalIgnoreCase) >= 0 - && SelectIntoTargetStatementRegex.IsMatch(statement)) - { - return true; - } - - if (statement.IndexOf("CREATE", StringComparison.OrdinalIgnoreCase) < 0) - return false; - - return (statement.IndexOf("TABLE", StringComparison.OrdinalIgnoreCase) >= 0 - && CreateTempTableRegex.IsMatch(statement)) - || ((statement.IndexOf("PROC", StringComparison.OrdinalIgnoreCase) >= 0 - || statement.IndexOf("FUNCTION", StringComparison.OrdinalIgnoreCase) >= 0) - && CreateTempRoutineRegex.IsMatch(statement)); - } - - private static bool CanStatementRequireLineCommentCarry(string statement) - { - if (string.IsNullOrWhiteSpace(statement)) - return false; - - return CanStatementEstablishTempObject(statement) - || TargetReferencePrefixRegex.IsMatch(statement) - || FromListContinuationPrefixRegex.IsMatch(statement) - || SelectIntoTargetPrefixRegex.IsMatch(statement) - || DeleteUsingPrefixRegex.IsMatch(statement) - || DeleteUsingListContinuationPrefixRegex.IsMatch(statement) - || MergeUsingPrefixRegex.IsMatch(statement) - || MergeTargetHintContinuationPrefixRegex.IsMatch(statement); - } - - private static bool StartsTopLevelStatement(string line) - { - int index = 0; - while (index < line.Length && char.IsWhiteSpace(line[index])) - index++; - if (index >= line.Length || !char.IsLetter(line[index])) - return false; - - int start = index; - while (index < line.Length && char.IsLetter(line[index])) - index++; - - var keyword = line[start..index].ToUpperInvariant(); - if (keyword == "WITH") - { - while (index < line.Length && char.IsWhiteSpace(line[index])) - index++; - - return index >= line.Length || line[index] != '('; - } - - return keyword switch - { - "SELECT" => true, - "INSERT" => true, - "UPDATE" => true, - "DELETE" => true, - "MERGE" => true, - "CREATE" => true, - "ALTER" => true, - "DROP" => true, - "TRUNCATE" => true, - "SET" => true, - "DECLARE" => true, - "IF" => true, - "WHILE" => true, - "DO" => true, - "BEGIN" => true, - "EXEC" => true, - "EXECUTE" => true, - "CALL" => true, - _ => false, - }; - } - - private static int FindStatementTerminator(string text, int startIndex) - { - for (int i = startIndex; i < text.Length; i++) - { - char c = text[i]; - if (c == ';') - return i; - if (c == '`') - { - int closing = text.IndexOf('`', i + 1); - if (closing < 0) - return -1; - i = closing; - continue; - } - if (c == '[') - { - int closing = text.IndexOf(']', i + 1); - if (closing < 0) - return -1; - i = closing; - continue; - } - if (c == '"') - { - int closing = FindClosingDoubleQuote(text, i + 1); - if (closing < 0) - return -1; - i = closing; - } - } - - return -1; - } - - private static int FindClosingDoubleQuote(string text, int startIndex) - { - for (int i = startIndex; i < text.Length; i++) - { - if (text[i] != '"') - continue; - if (i + 1 < text.Length && text[i + 1] == '"') - { - i++; - continue; - } - - return i; - } - - return -1; - } - - private static int FindClosingSingleQuote(string text, int startIndex) - { - for (int i = startIndex; i < text.Length; i++) - { - if (text[i] == '\\' && i + 1 < text.Length) - { - i++; - continue; - } - if (text[i] != '\'') - continue; - if (i + 1 < text.Length && text[i + 1] == '\'') - { - i++; - continue; - } - - return i; - } - - return -1; - } - - private static bool IsInsideDoubleQuotedRegion(string text, int index) - { - if (index <= 0) - return false; - - bool inside = false; - for (int i = 0; i < index && i < text.Length; i++) - { - if (text[i] != '"') - continue; - if (inside && i + 1 < index && text[i + 1] == '"') - { - i++; - continue; - } - - inside = !inside; - } - - return inside; - } - - private static bool TryReadDollarQuoteDelimiter( - string line, - int index, - out string delimiter) - { - delimiter = string.Empty; - if (index < 0 || index >= line.Length || line[index] != '$') - return false; - if (index > 0 && (char.IsLetterOrDigit(line[index - 1]) || line[index - 1] == '_')) - return false; - if (index + 1 >= line.Length) - return false; - if (line[index + 1] == '$') - { - delimiter = "$$"; - return true; - } - if (!(char.IsLetter(line[index + 1]) || line[index + 1] == '_')) - return false; - - int probe = index + 2; - while (probe < line.Length && (char.IsLetterOrDigit(line[probe]) || line[probe] == '_')) - probe++; - if (probe >= line.Length || line[probe] != '$') - return false; - - delimiter = line[index..(probe + 1)]; - return true; - } - - private static int SkipWhitespaceAhead(string text, int index) - { - while (index < text.Length && char.IsWhiteSpace(text[index])) - index++; - return index; - } - - private static void CollectTempObjectNamesFromStatement( - string statement, - HashSet names) - { - if (statement.IndexOf('#') < 0) - return; - - var mayContainTargetStatement = statement.IndexOf("INSERT", StringComparison.OrdinalIgnoreCase) >= 0 - || statement.IndexOf("UPDATE", StringComparison.OrdinalIgnoreCase) >= 0 - || statement.IndexOf("MERGE", StringComparison.OrdinalIgnoreCase) >= 0 - || statement.IndexOf("DELETE", StringComparison.OrdinalIgnoreCase) >= 0 - || statement.IndexOf("ALTER", StringComparison.OrdinalIgnoreCase) >= 0 - || statement.IndexOf("BULK", StringComparison.OrdinalIgnoreCase) >= 0; - if (mayContainTargetStatement) - CollectTempObjectNamesFromTargetMatches(TargetReferenceRegex.Matches(statement), statement, names); - - if (statement.IndexOf("TRUNCATE", StringComparison.OrdinalIgnoreCase) >= 0) - CollectTempObjectNamesFromMatches(TruncateTargetRegex.Matches(statement), statement, names); - - if (statement.IndexOf("SELECT", StringComparison.OrdinalIgnoreCase) >= 0 - && statement.IndexOf("INTO", StringComparison.OrdinalIgnoreCase) >= 0) - { - CollectTempObjectNamesFromMatches(SelectIntoTargetStatementRegex.Matches(statement), statement, names); - } - - if (statement.IndexOf("CREATE", StringComparison.OrdinalIgnoreCase) < 0) - return; - - if (statement.IndexOf("TABLE", StringComparison.OrdinalIgnoreCase) >= 0) - CollectTempObjectNamesFromMatches(CreateTempTableRegex.Matches(statement), statement, names); - if (statement.IndexOf("PROC", StringComparison.OrdinalIgnoreCase) >= 0 - || statement.IndexOf("FUNCTION", StringComparison.OrdinalIgnoreCase) >= 0) - { - CollectTempObjectNamesFromMatches(CreateTempRoutineRegex.Matches(statement), statement, names); - } - } - - private static void CollectTempObjectNamesFromTargetMatches(MatchCollection matches, string statement, HashSet names) - { - foreach (Match match in matches) - { - if (IsInsideDoubleQuotedRegion(statement, match.Index)) - continue; - if (!TryGetTrailingQualifiedIdentifierLeaf(match, out var rawName, out var rawIndex)) - continue; - - NormalizeIdentifier(rawName, rawIndex, out var resolvedName, out _, out _); - if (resolvedName.StartsWith("#", StringComparison.Ordinal)) - names.Add(resolvedName); - } - } - - private static void CollectTempObjectNamesFromMatches(MatchCollection matches, string statement, HashSet names) - { - foreach (Match match in matches) - { - if (IsInsideDoubleQuotedRegion(statement, match.Index)) - continue; - var nameGroup = match.Groups["name"]; - if (nameGroup.Captures.Count == 0) - continue; - - foreach (Capture capture in nameGroup.Captures) - { - NormalizeIdentifier(capture.Value, capture.Index, out var resolvedName, out _, out _); - if (resolvedName.StartsWith("#", StringComparison.Ordinal)) - names.Add(resolvedName); - } - } - } - - private static bool TryFindDefinitionLeafSpan( - string line, - string qualifiedName, - Dictionary patternCache, - out DefinitionLeafSpan span) - { - span = default; - if (string.IsNullOrWhiteSpace(line) || string.IsNullOrWhiteSpace(qualifiedName)) - return false; - - if (!TryGetDefinitionLeafPattern(qualifiedName, patternCache, out var leafPattern)) - return false; - - var match = BoundedRegex.Match(line, leafPattern.Pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - if (!match.Success) - return false; - - var leafGroup = match.Groups["leaf"]; - if (!leafGroup.Success) - return false; - - span = new DefinitionLeafSpan(leafPattern.LeafName, leafGroup.Index, leafGroup.Index + leafGroup.Length); - return true; - } - - private static bool TryGetDefinitionLeafPattern( - string qualifiedName, - Dictionary patternCache, - out DefinitionLeafPattern leafPattern) - { - if (patternCache.TryGetValue(qualifiedName, out leafPattern)) - return true; - - var leafName = SqlNameResolver.GetLeafName(qualifiedName); - if (string.IsNullOrWhiteSpace(leafName)) - return false; - - if (!TryBuildQualifiedNameSourcePattern(qualifiedName, out var pattern)) - return false; - - leafPattern = new DefinitionLeafPattern(leafName, pattern); - patternCache[qualifiedName] = leafPattern; - return true; - } - - private static bool TryBuildQualifiedNameSourcePattern(string qualifiedName, out string pattern) - { - pattern = string.Empty; - var trimmed = qualifiedName.Trim(); - if (trimmed.Length == 0) - return false; - - var builder = new StringBuilder(trimmed.Length + "(?)".Length); - string? pendingSegment = null; - var segmentStart = 0; - char quote = '\0'; - - for (var i = 0; i < trimmed.Length; i++) - { - var ch = trimmed[i]; - if (quote != '\0') - { - if (quote == '[') - { - if (ch == ']') - { - if (i + 1 < trimmed.Length && trimmed[i + 1] == ']') - i++; - else - quote = '\0'; - } - - continue; - } - - if (ch == quote) - { - if (i + 1 < trimmed.Length && trimmed[i + 1] == quote) - i++; - else - quote = '\0'; - } - - continue; - } - - if (ch is '[' or '"' or '`') - { - quote = ch; - continue; - } - - if (ch == '.') - { - QueueQualifiedNameSourcePatternSegment(builder, trimmed, segmentStart, i, ref pendingSegment); - segmentStart = i + 1; - continue; - } - - } - - QueueQualifiedNameSourcePatternSegment(builder, trimmed, segmentStart, trimmed.Length, ref pendingSegment); - if (pendingSegment is null) - return false; - - AppendQualifiedNameSourcePatternSegment(builder, pendingSegment, isLeaf: true); - pattern = builder.ToString(); - return true; - } - - private static void QueueQualifiedNameSourcePatternSegment( - StringBuilder builder, - string text, - int segmentStart, - int segmentEnd, - ref string? pendingSegment) - { - while (segmentStart < segmentEnd && char.IsWhiteSpace(text[segmentStart])) - segmentStart++; - while (segmentEnd > segmentStart && char.IsWhiteSpace(text[segmentEnd - 1])) - segmentEnd--; - if (segmentStart >= segmentEnd) - return; - - if (pendingSegment is not null) - AppendQualifiedNameSourcePatternSegment(builder, pendingSegment, isLeaf: false); - - pendingSegment = text[segmentStart..segmentEnd]; - } - - private static void AppendQualifiedNameSourcePatternSegment(StringBuilder builder, string segment, bool isLeaf) - { - if (builder.Length > 0) - builder.Append(@"\s*\.\s*"); - - var escaped = Regex.Escape(segment); - if (isLeaf) - builder.Append("(?").Append(escaped).Append(')'); - else - builder.Append(escaped); - } - - private static string PrepareLineForIdentifierScan( - string line, - IdentifierScanState state, - string? statementPrefix, - out bool lineEndedByLineComment, - out IdentifierScanState nextState) - { - lineEndedByLineComment = false; - if (string.IsNullOrEmpty(line)) - { - nextState = state; - return line; - } - - char[]? sanitized = null; - bool inBlockComment = state.InBlockComment; - string? dollarQuoteDelimiter = state.DollarQuoteDelimiter; - bool inSingleQuotedString = state.InSingleQuotedString; - - void BlankRange(int start, int endExclusive) - { - start = Math.Max(0, start); - endExclusive = Math.Min(line.Length, endExclusive); - sanitized ??= line.ToCharArray(); - for (int blankIndex = start; blankIndex < endExclusive; blankIndex++) - sanitized[blankIndex] = ' '; - } - - for (int i = 0; i < line.Length;) - { - if (inBlockComment) - { - int closing = line.IndexOf("*/", i, StringComparison.Ordinal); - int end = closing >= 0 ? closing + 2 : line.Length; - BlankRange(i, end); - if (closing < 0) - break; - i = end; - inBlockComment = false; - continue; - } - if (!string.IsNullOrEmpty(dollarQuoteDelimiter)) - { - int closing = line.IndexOf(dollarQuoteDelimiter, i, StringComparison.Ordinal); - if (closing < 0) - { - BlankRange(i, line.Length); - break; - } - - int nextContent = SkipWhitespaceAhead(line, closing + dollarQuoteDelimiter.Length); - if (nextContent < line.Length - && line[nextContent] != ';' - && line[nextContent] != ',' - && line[nextContent] != ')' - && line[nextContent] != ']') - { - int nestedClosing = line.IndexOf( - dollarQuoteDelimiter, - closing + dollarQuoteDelimiter.Length, - StringComparison.Ordinal); - if (nestedClosing >= 0) - { - int end = nestedClosing + dollarQuoteDelimiter.Length; - BlankRange(i, end); - i = end; - continue; - } - } - - int closingEnd = closing + dollarQuoteDelimiter.Length; - BlankRange(i, closingEnd); - i = closingEnd; - dollarQuoteDelimiter = null; - continue; - } - if (inSingleQuotedString) - { - int closing = FindClosingSingleQuote(line, i); - int end = closing >= 0 ? closing + 1 : line.Length; - BlankRange(i, end); - i = end; - if (closing >= 0) - { - inSingleQuotedString = false; - continue; - } - - break; - } - - char c = line[i]; - if (c == '"') - { - int closing = FindClosingDoubleQuote(line, i + 1); - if (closing < 0) - break; - i = closing + 1; - continue; - } - if (c == '`') - { - int closing = line.IndexOf('`', i + 1); - if (closing < 0) - break; - i = closing + 1; - continue; - } - if (c == '[') - { - int closing = line.IndexOf(']', i + 1); - if (closing < 0) - break; - i = closing + 1; - continue; - } - if (c == '\'') - { - int closing = FindClosingSingleQuote(line, i + 1); - int end = closing >= 0 ? closing + 1 : line.Length; - BlankRange(i, end); - i = end; - if (closing < 0) - inSingleQuotedString = true; - continue; - } - if (c == '/' && i + 1 < line.Length && line[i + 1] == '*') - { - BlankRange(i, i + 2); - i += 2; - inBlockComment = true; - continue; - } - if (c == '-' && i + 1 < line.Length && line[i + 1] == '-') - { - lineEndedByLineComment = true; - BlankRange(i, line.Length); - break; - } - if (c == '#') - { - if (ShouldTreatHashAsComment(line, i, statementPrefix)) - { - lineEndedByLineComment = true; - BlankRange(i, line.Length); - break; - } - } - if (c == '$' && TryReadDollarQuoteDelimiter(line, i, out var delimiter)) - { - BlankRange(i, i + delimiter.Length); - i += delimiter.Length; - dollarQuoteDelimiter = delimiter; - continue; - } - - i++; - } - - nextState = new IdentifierScanState(inBlockComment, dollarQuoteDelimiter, inSingleQuotedString); - return sanitized is null ? line : new string(sanitized); - } - - private static bool ShouldTreatHashAsComment(string line, int hashIndex, string? statementPrefix) - { - if (hashIndex < 0 || hashIndex >= line.Length || line[hashIndex] != '#') - return false; - - int probe = hashIndex - 1; - while (probe >= 0 && char.IsWhiteSpace(line[probe])) - probe--; - if (probe < 0 && !string.IsNullOrWhiteSpace(statementPrefix)) - { - var combined = statementPrefix + "\n" + line; - return ShouldTreatHashAsCommentCore(combined, statementPrefix.Length + 1 + hashIndex); - } - - return ShouldTreatHashAsCommentCore(line, hashIndex); - } - - private static bool ShouldTreatHashAsCommentCore(string line, int hashIndex) - { - if (hashIndex < 0 || hashIndex >= line.Length || line[hashIndex] != '#') - return false; - - int next = hashIndex + 1; - if (hashIndex > 0 - && line[hashIndex - 1] == '#' - && next < line.Length - && (char.IsLetterOrDigit(line[next]) || line[next] == '_')) - return false; - if (next + 1 < line.Length - && line[next] == '#' - && (char.IsLetterOrDigit(line[next + 1]) || line[next + 1] == '_')) - return false; - if (next >= line.Length || !(char.IsLetterOrDigit(line[next]) || line[next] == '_')) - return true; - - int probe = hashIndex - 1; - while (probe >= 0 && char.IsWhiteSpace(line[probe])) - probe--; - while (probe >= 0 && line[probe] == ',') - { - var priorListItem = line[..probe]; - int sourceStart = FindLastCommaOutsideQuotedIdentifiers(priorListItem); - if (sourceStart >= 0) - sourceStart++; - else - { - var usingMatches = UsingKeywordRegex.Matches(priorListItem); - if (usingMatches.Count > 0) - sourceStart = usingMatches[^1].Index + usingMatches[^1].Length; - else - { - sourceStart = priorListItem.LastIndexOf('#'); - if (sourceStart < 0) - return true; - } - } - while (sourceStart < priorListItem.Length && char.IsWhiteSpace(priorListItem[sourceStart])) - sourceStart++; - - var listMatch = TrailingTempIdentifierRegex.Match(priorListItem[sourceStart..]); - if (!listMatch.Success) - return true; - - probe = sourceStart - 1; - while (probe >= 0 && char.IsWhiteSpace(line[probe])) - probe--; - } - if (probe < 0) - return true; - if (line[probe] == '.') - return false; - if (line[probe] == ')') - { - int depth = 1; - probe--; - while (probe >= 0 && depth > 0) - { - if (line[probe] == ')') - depth++; - else if (line[probe] == '(') - depth--; - probe--; - } - while (probe >= 0 && char.IsWhiteSpace(line[probe])) - probe--; - if (probe < 0) - return true; - - int modifierEnd = probe; - while (probe >= 0 && char.IsLetter(line[probe])) - probe--; - int modifierStart = probe + 1; - if (modifierStart <= modifierEnd - && string.Equals(line[modifierStart..(modifierEnd + 1)], "TOP", StringComparison.OrdinalIgnoreCase)) - { - while (probe >= 0 && char.IsWhiteSpace(line[probe])) - probe--; - if (probe < 0) - return true; - } - } - - int tokenEnd = probe; - while (probe >= 0 && char.IsLetter(line[probe])) - probe--; - int tokenStart = probe + 1; - if (tokenStart > tokenEnd) - return true; - - var token = line[tokenStart..(tokenEnd + 1)]; - return !string.Equals(token, "FROM", StringComparison.OrdinalIgnoreCase) - && !string.Equals(token, "JOIN", StringComparison.OrdinalIgnoreCase) - && !string.Equals(token, "MERGE", StringComparison.OrdinalIgnoreCase) - && !string.Equals(token, "USING", StringComparison.OrdinalIgnoreCase) - && !string.Equals(token, "INTO", StringComparison.OrdinalIgnoreCase) - && !string.Equals(token, "UPDATE", StringComparison.OrdinalIgnoreCase) - && !string.Equals(token, "TABLE", StringComparison.OrdinalIgnoreCase) - && !string.Equals(token, "EXEC", StringComparison.OrdinalIgnoreCase) - && !string.Equals(token, "EXECUTE", StringComparison.OrdinalIgnoreCase) - && !string.Equals(token, "CALL", StringComparison.OrdinalIgnoreCase) - && !string.Equals(token, "PROCEDURE", StringComparison.OrdinalIgnoreCase) - && !string.Equals(token, "PROC", StringComparison.OrdinalIgnoreCase) - && !string.Equals(token, "FUNCTION", StringComparison.OrdinalIgnoreCase); - } - - private static int FindLastCommaOutsideQuotedIdentifiers(string text) - { - int lastComma = -1; - for (int i = 0; i < text.Length; i++) - { - char c = text[i]; - if (c == '"') - { - int closing = FindClosingDoubleQuote(text, i + 1); - if (closing < 0) - break; - i = closing; - continue; - } - if (c == '`') - { - int closing = text.IndexOf('`', i + 1); - if (closing < 0) - break; - i = closing; - continue; - } - if (c == '[') - { - int closing = text.IndexOf(']', i + 1); - if (closing < 0) - break; - i = closing; - continue; - } - if (c == ',') - lastComma = i; - } - - return lastComma; - } } From 314e79e8cb87fbdb175567fc2d88b291282b6ef8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:25:23 +0900 Subject: [PATCH 045/101] Split C# reference analysis helpers --- .../CSharpReferenceExtractor.CastTypes.cs | 667 +++++++ ...eferenceExtractor.PatternValueReceivers.cs | 795 ++++++++ ...arpReferenceExtractor.QualifiedPatterns.cs | 887 +++++++++ .../CSharpReferenceExtractor.QuerySyntax.cs | 759 ++++++++ .../CSharpReferenceExtractor.Support.cs | 1662 ----------------- ...CSharpReferenceExtractor.ValueReceivers.cs | 1406 -------------- 6 files changed, 3108 insertions(+), 3068 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.CastTypes.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.PatternValueReceivers.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.QualifiedPatterns.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.QuerySyntax.cs diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.CastTypes.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.CastTypes.cs new file mode 100644 index 000000000..4c8795efd --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.CastTypes.cs @@ -0,0 +1,667 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static bool IsCSharpCastPrefixIdentifier(string line, int tokenStartColumn, string token) + { + if (tokenStartColumn > 0 && line[tokenStartColumn - 1] == '@') + return false; + + return string.Equals(token, "return", StringComparison.Ordinal) + || string.Equals(token, "await", StringComparison.Ordinal) + || string.Equals(token, "throw", StringComparison.Ordinal) + || IsCSharpQueryClauseKeyword(token); + } + + private static bool LooksLikeCSharpCastTypeText( + string text, + int lineNumber, + int column, + IReadOnlySet csharpKnownTypeNames, + IReadOnlyList csharpUsingAliases, + IReadOnlyList csharpFunctionValueReceiverNames) + { + var trimmed = text.Trim(); + if (trimmed.Length == 0) + return false; + + var index = 0; + if (!TryConsumeCSharpCastType(trimmed, ref index)) + return false; + + SkipCSharpCastTypeWhitespace(trimmed, ref index); + if (index != trimmed.Length) + return false; + + var shape = AnalyzeCSharpCastTypeShape(trimmed); + if (shape.IdentifierSegments.Count == 0) + return shape.HasTypeOnlySyntax; + + var resolvedQualifiedName = shape.SimpleQualifiedName == null + ? null + : ResolveCSharpQualifiedAliasTarget(shape.SimpleQualifiedName, lineNumber, csharpUsingAliases); + var resolvedBareName = resolvedQualifiedName == null + ? null + : ExtractBareTypeName(resolvedQualifiedName); + + var lastSegment = shape.IdentifierSegments[^1]; + if (HasKnownNonTerminalTypeSegment(shape.IdentifierSegments, csharpKnownTypeNames) + && !IsKnownCSharpCastTypeName(lastSegment, resolvedBareName, csharpKnownTypeNames)) + { + return false; + } + + if (IsKnownCSharpCastTypeName(lastSegment, resolvedBareName, csharpKnownTypeNames) + || (!string.IsNullOrWhiteSpace(resolvedQualifiedName) && csharpKnownTypeNames.Contains(resolvedQualifiedName))) + { + return true; + } + + if (shape.SimpleQualifiedName != null + && string.Equals(shape.SimpleQualifiedName, resolvedQualifiedName, StringComparison.Ordinal) + && HasCSharpFunctionValueReceiverConflict( + GetFirstQualifiedSegment(shape.SimpleQualifiedName), + lineNumber, + column, + csharpFunctionValueReceiverNames)) + { + return false; + } + + if (shape.HasTypeOnlySyntax) + return true; + + return shape.AllIdentifiersTypeLike && shape.IdentifierSegments.Count <= 2; + } + + private static bool TryConsumeCSharpCastType(string text, ref int index) + { + if (!TryConsumeCSharpCastTypeCore(text, ref index)) + return false; + + while (true) + { + var checkpoint = index; + SkipCSharpCastTypeWhitespace(text, ref index); + if (TryConsumeCSharpCastArraySuffix(text, ref index) + || TryConsumeCSharpCastNullableSuffix(text, ref index)) + { + continue; + } + + index = checkpoint; + return true; + } + } + + private static bool TryConsumeCSharpCastTypeCore(string text, ref int index) + { + SkipCSharpCastTypeWhitespace(text, ref index); + if (index < text.Length && text[index] == '(') + return TryConsumeCSharpCastTupleType(text, ref index); + + return TryConsumeCSharpCastQualifiedType(text, ref index); + } + + private static bool TryConsumeCSharpCastQualifiedType(string text, ref int index) + { + if (!TryConsumeCSharpCastIdentifier(text, ref index, out var token)) + return false; + + if (!TryConsumeCSharpCastGenericArgumentList(text, ref index)) + return false; + + while (true) + { + var checkpoint = index; + SkipCSharpCastTypeWhitespace(text, ref index); + if (!TryConsumeCSharpCastQualifiedTypeSeparator(text, ref index)) + { + index = checkpoint; + return true; + } + + if (!TryConsumeCSharpCastIdentifier(text, ref index, out token)) + return false; + + if (!TryConsumeCSharpCastGenericArgumentList(text, ref index)) + return false; + } + } + + private static bool TryConsumeCSharpCastTupleType(string text, ref int index) + { + if (index >= text.Length || text[index] != '(') + return false; + + index++; + while (true) + { + if (!TryConsumeCSharpCastType(text, ref index)) + return false; + + var checkpoint = index; + if (TryConsumeCSharpCastIdentifier(text, ref index, out _)) + { + // Tuple element names are optional and do not affect type-likeness. + } + else + { + index = checkpoint; + } + + SkipCSharpCastTypeWhitespace(text, ref index); + if (index >= text.Length) + return false; + + if (text[index] == ')') + { + index++; + return true; + } + + if (text[index] != ',') + return false; + + index++; + } + } + + private static bool TryConsumeCSharpCastGenericArgumentList(string text, ref int index) + { + var checkpoint = index; + SkipCSharpCastTypeWhitespace(text, ref index); + if (index >= text.Length || text[index] != '<') + { + index = checkpoint; + return true; + } + + index++; + while (true) + { + if (!TryConsumeCSharpCastType(text, ref index)) + return false; + + SkipCSharpCastTypeWhitespace(text, ref index); + if (index >= text.Length) + return false; + + if (text[index] == '>') + { + index++; + return true; + } + + if (text[index] != ',') + return false; + + index++; + } + } + + private static bool TryConsumeCSharpCastArraySuffix(string text, ref int index) + { + if (index >= text.Length || text[index] != '[') + return false; + + index++; + SkipCSharpCastTypeWhitespace(text, ref index); + while (index < text.Length && text[index] == ',') + { + index++; + SkipCSharpCastTypeWhitespace(text, ref index); + } + + if (index >= text.Length || text[index] != ']') + return false; + + index++; + return true; + } + + private static bool TryConsumeCSharpCastNullableSuffix(string text, ref int index) + { + if (index >= text.Length || text[index] != '?') + return false; + + index++; + return true; + } + + private static bool TryConsumeCSharpCastQualifiedTypeSeparator(string text, ref int index) + { + if (index >= text.Length) + return false; + + if (text[index] == '.') + { + index++; + return true; + } + + if (index + 1 < text.Length && text[index] == ':' && text[index + 1] == ':') + { + index += 2; + return true; + } + + return false; + } + + private static bool TryConsumeCSharpCastIdentifier(string text, ref int index, out string token) + { + SkipCSharpCastTypeWhitespace(text, ref index); + token = string.Empty; + if (index >= text.Length) + return false; + + var start = index; + if (text[index] == '@') + { + index++; + if (index >= text.Length || !IsCSharpIdentifierStart(text[index])) + { + index = start; + return false; + } + } + else if (!IsCSharpIdentifierStart(text[index])) + { + return false; + } + + index++; + while (index < text.Length && IsCSharpIdentifierPart(text[index])) + index++; + + token = text.Substring(start, index - start); + return true; + } + + private static CSharpCastTypeShape AnalyzeCSharpCastTypeShape(string text) + { + var segments = new List(); + var simpleQualifiedName = new System.Text.StringBuilder(text.Length); + var hasTypeOnlySyntax = false; + var allIdentifiersTypeLike = true; + var simpleQualifiedCandidate = true; + + for (var index = 0; index < text.Length;) + { + var current = text[index]; + if (char.IsWhiteSpace(current)) + { + index++; + continue; + } + + if (current == '@' || IsCSharpIdentifierStart(current)) + { + var start = index; + if (current == '@') + index++; + if (index < text.Length) + index++; + while (index < text.Length && IsCSharpIdentifierPart(text[index])) + index++; + + var token = text.Substring(start, index - start); + segments.Add(token); + allIdentifiersTypeLike &= IsLikelyCSharpTypeIdentifier(token); + if (simpleQualifiedCandidate) + simpleQualifiedName.Append(token); + continue; + } + + switch (current) + { + case '.': + if (simpleQualifiedCandidate) + simpleQualifiedName.Append(current); + index++; + continue; + case ':': + if (index + 1 < text.Length && text[index + 1] == ':') + { + hasTypeOnlySyntax = true; + if (simpleQualifiedCandidate) + simpleQualifiedName.Append("::"); + index += 2; + continue; + } + + simpleQualifiedCandidate = false; + index++; + continue; + case '<': + case '[': + case '?': + case '(': + hasTypeOnlySyntax = true; + simpleQualifiedCandidate = false; + index++; + continue; + case '>': + case ']': + case ')': + case ',': + simpleQualifiedCandidate = false; + index++; + continue; + default: + simpleQualifiedCandidate = false; + index++; + continue; + } + } + + return new CSharpCastTypeShape( + segments, + simpleQualifiedCandidate && simpleQualifiedName.Length > 0 ? simpleQualifiedName.ToString() : null, + hasTypeOnlySyntax, + allIdentifiersTypeLike); + } + + private static bool HasKnownNonTerminalTypeSegment(IReadOnlyList segments, IReadOnlySet csharpKnownTypeNames) + { + for (var index = 0; index < segments.Count - 1; index++) + { + if (csharpKnownTypeNames.Contains(NormalizeCSharpIdentifier(segments[index]))) + return true; + } + + return false; + } + + private static bool IsKnownCSharpCastTypeName(string candidate, string? resolvedCandidate, IReadOnlySet csharpKnownTypeNames) + { + return csharpKnownTypeNames.Contains(NormalizeCSharpIdentifier(candidate)) + || (!string.IsNullOrWhiteSpace(resolvedCandidate) && csharpKnownTypeNames.Contains(NormalizeCSharpIdentifier(resolvedCandidate))); + } + + private static bool HasCSharpFunctionValueReceiverConflict( + string candidate, + int lineNumber, + int column, + IReadOnlyList csharpFunctionValueReceiverNames) + { + if (string.IsNullOrWhiteSpace(candidate) || csharpFunctionValueReceiverNames.Count == 0) + return false; + + var normalizedCandidate = NormalizeCSharpIdentifier(candidate); + return HasCSharpFunctionValueReceiverName(csharpFunctionValueReceiverNames, normalizedCandidate, lineNumber, column); + } + + private static bool HasCSharpFunctionValueReceiverName( + IReadOnlyList csharpFunctionValueReceiverNames, + string receiverName, + int lineNumber, + int column) + { + for (var index = 0; index < csharpFunctionValueReceiverNames.Count; index++) + { + var record = csharpFunctionValueReceiverNames[index]; + if (IsWithinCSharpScope(record, lineNumber, column) + && string.Equals(record.Name, receiverName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static bool IsLikelyCSharpTypeIdentifier(string token) + { + if (string.IsNullOrEmpty(token)) + return false; + + var normalized = token[0] == '@' ? token.Substring(1) : token; + if (normalized.Length == 0) + return false; + + return IsCSharpBuiltInTypeKeyword(normalized) + || char.IsUpper(normalized[0]); + } + + private static void SkipCSharpCastTypeWhitespace(string text, ref int index) + { + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + } + + private static bool IsCSharpBuiltInTypeKeyword(string text) + { + return string.Equals(text, "bool", StringComparison.Ordinal) + || string.Equals(text, "byte", StringComparison.Ordinal) + || string.Equals(text, "sbyte", StringComparison.Ordinal) + || string.Equals(text, "short", StringComparison.Ordinal) + || string.Equals(text, "ushort", StringComparison.Ordinal) + || string.Equals(text, "int", StringComparison.Ordinal) + || string.Equals(text, "uint", StringComparison.Ordinal) + || string.Equals(text, "long", StringComparison.Ordinal) + || string.Equals(text, "ulong", StringComparison.Ordinal) + || string.Equals(text, "nint", StringComparison.Ordinal) + || string.Equals(text, "nuint", StringComparison.Ordinal) + || string.Equals(text, "char", StringComparison.Ordinal) + || string.Equals(text, "float", StringComparison.Ordinal) + || string.Equals(text, "double", StringComparison.Ordinal) + || string.Equals(text, "decimal", StringComparison.Ordinal) + || string.Equals(text, "string", StringComparison.Ordinal) + || string.Equals(text, "object", StringComparison.Ordinal) + || string.Equals(text, "dynamic", StringComparison.Ordinal); + } + + private static bool CanStartCSharpParenthesizedQueryClauseAfterPlusOrMinus( + IReadOnlyList structuralLines, + int bodyEndIndex, + int operatorLineIndex, + int operatorColumn, + int operatorEndColumn, + char operatorToken) + { + if (operatorLineIndex < 0 || operatorColumn < 0) + return false; + + if (!TryGetPreviousTopLevelToken( + structuralLines, + operatorLineIndex, + operatorColumn - 1, + out var previousTokenLineIndex, + out var previousTokenStartColumn, + out var previousTokenEndColumn, + out var previousIdentifierToken, + out var previousPunctuationToken)) + { + return false; + } + + if (!string.IsNullOrEmpty(previousIdentifierToken) + || previousPunctuationToken != operatorToken + || previousTokenLineIndex != operatorLineIndex + || previousTokenEndColumn != operatorEndColumn - 1) + { + return false; + } + + if (!TryGetPreviousTopLevelToken( + structuralLines, + previousTokenLineIndex, + previousTokenStartColumn - 1, + out var operandTokenLineIndex, + out var operandTokenStartColumn, + out _, + out var operandIdentifierToken, + out var operandPunctuationToken)) + { + return false; + } + + if (!string.IsNullOrEmpty(operandIdentifierToken)) + return true; + + return operandPunctuationToken switch + { + ')' or ']' or '}' or '"' or '\'' => true, + '>' => LooksLikeCSharpQueryGenericTypeArgumentClose( + structuralLines, + bodyEndIndex, + operandTokenLineIndex, + operandTokenStartColumn), + _ => false + }; + } + + private static bool CanStartCSharpParenthesizedQueryClauseAfterBang( + IReadOnlyList structuralLines, + int bodyEndIndex, + int bangLineIndex, + int bangColumn) + { + if (!TryGetPreviousTopLevelToken( + structuralLines, + bangLineIndex, + bangColumn - 1, + out var previousTokenLineIndex, + out var previousTokenStartColumn, + out _, + out var previousIdentifierToken, + out var previousPunctuationToken)) + { + return false; + } + + if (!string.IsNullOrEmpty(previousIdentifierToken)) + return !IsCSharpParenthesizedQueryClausePrefixIdentifier( + structuralLines[previousTokenLineIndex], + previousTokenStartColumn, + previousIdentifierToken); + + return previousPunctuationToken switch + { + ')' or ']' or '}' or '"' or '\'' => true, + '>' => LooksLikeCSharpQueryGenericTypeArgumentClose( + structuralLines, + bodyEndIndex, + previousTokenLineIndex, + previousTokenStartColumn), + _ => false + }; + } + + private static bool IsCSharpParenthesizedQueryClausePrefixIdentifier(string line, int tokenStartColumn, string token) + { + if (tokenStartColumn > 0 && line[tokenStartColumn - 1] == '@') + return false; + + return string.Equals(token, "await", StringComparison.Ordinal) + || string.Equals(token, "throw", StringComparison.Ordinal) + || IsCSharpQueryClauseKeyword(token); + } + + private static bool LooksLikeCSharpNullableTypeSuffixInCastOrTypeTest( + IReadOnlyList structuralLines, + int questionLineIndex, + int questionColumn) + { + var angleDepth = 0; + var bracketDepth = 0; + var parenDepth = 0; + var currentLineIndex = questionLineIndex; + var currentColumn = questionColumn - 1; + while (TryGetPreviousTopLevelToken( + structuralLines, + currentLineIndex, + currentColumn, + out var tokenLineIndex, + out var tokenStartColumn, + out _, + out var identifierToken, + out var punctuationToken)) + { + if (!string.IsNullOrEmpty(identifierToken)) + { + if (angleDepth == 0 + && bracketDepth == 0 + && parenDepth == 0 + && (string.Equals(identifierToken, "as", StringComparison.Ordinal) + || string.Equals(identifierToken, "is", StringComparison.Ordinal))) + { + return true; + } + + currentLineIndex = tokenLineIndex; + currentColumn = tokenStartColumn - 1; + continue; + } + + switch (punctuationToken) + { + case '.': + case '?': + currentLineIndex = tokenLineIndex; + currentColumn = tokenStartColumn - 1; + continue; + case ',': + if (angleDepth > 0 || bracketDepth > 0 || parenDepth > 0) + { + currentLineIndex = tokenLineIndex; + currentColumn = tokenStartColumn - 1; + continue; + } + + return false; + case '>': + angleDepth++; + currentLineIndex = tokenLineIndex; + currentColumn = tokenStartColumn - 1; + continue; + case '<': + if (angleDepth == 0) + return false; + + angleDepth--; + currentLineIndex = tokenLineIndex; + currentColumn = tokenStartColumn - 1; + continue; + case ']': + bracketDepth++; + currentLineIndex = tokenLineIndex; + currentColumn = tokenStartColumn - 1; + continue; + case '[': + if (bracketDepth == 0) + return false; + + bracketDepth--; + currentLineIndex = tokenLineIndex; + currentColumn = tokenStartColumn - 1; + continue; + case ')': + parenDepth++; + currentLineIndex = tokenLineIndex; + currentColumn = tokenStartColumn - 1; + continue; + case '(': + if (parenDepth == 0) + return false; + + parenDepth--; + currentLineIndex = tokenLineIndex; + currentColumn = tokenStartColumn - 1; + continue; + default: + return false; + } + } + + return false; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.PatternValueReceivers.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.PatternValueReceivers.cs new file mode 100644 index 000000000..96fb98cd5 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.PatternValueReceivers.cs @@ -0,0 +1,795 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static bool TryGetCSharpXmlDocCommentSpan( + string line, + bool inDelimitedDocComment, + bool inOrdinaryBlockComment, + out int commentStartIndex, + out int commentEndExclusive, + out bool nextDelimitedDocComment) + { + commentStartIndex = 0; + commentEndExclusive = 0; + nextDelimitedDocComment = inDelimitedDocComment; + if (string.IsNullOrWhiteSpace(line)) + { + commentEndExclusive = inDelimitedDocComment ? line.Length : 0; + return inDelimitedDocComment; + } + + var firstNonWhitespaceIndex = 0; + while (firstNonWhitespaceIndex < line.Length && char.IsWhiteSpace(line[firstNonWhitespaceIndex])) + firstNonWhitespaceIndex++; + + if (inDelimitedDocComment) + { + var closeIndex = line.IndexOf("*/", StringComparison.Ordinal); + nextDelimitedDocComment = closeIndex < 0; + commentStartIndex = 0; + commentEndExclusive = closeIndex < 0 ? line.Length : closeIndex; + return true; + } + + if (inOrdinaryBlockComment) + return false; + + if (line.AsSpan(firstNonWhitespaceIndex).StartsWith("///", StringComparison.Ordinal)) + { + if (line.Length != firstNonWhitespaceIndex + 3 && line[firstNonWhitespaceIndex + 3] == '/') + return false; + + commentStartIndex = firstNonWhitespaceIndex; + commentEndExclusive = line.Length; + return true; + } + + if (!line.AsSpan(firstNonWhitespaceIndex).StartsWith("/**", StringComparison.Ordinal)) + return false; + + var closeAfterOpenIndex = line.IndexOf("*/", firstNonWhitespaceIndex + 3, StringComparison.Ordinal); + nextDelimitedDocComment = closeAfterOpenIndex < 0; + commentStartIndex = firstNonWhitespaceIndex; + commentEndExclusive = closeAfterOpenIndex < 0 ? line.Length : closeAfterOpenIndex; + return true; + } + + private static bool HasCSharpValueReceiverConflict( + string qualifier, + string resolvedQualifier, + int lineNumber, + int column, + SymbolRecord? callContainer, + IReadOnlyDictionary valueReceiverNamesByContainingType, + IReadOnlyDictionary> valueReceiverNamesByFunctionStartLine) + { + if (string.IsNullOrWhiteSpace(qualifier) + || (valueReceiverNamesByContainingType.Count == 0 && valueReceiverNamesByFunctionStartLine.Count == 0)) + return false; + if (!string.Equals(qualifier, resolvedQualifier, StringComparison.Ordinal)) + return false; + + var receiverName = GetFirstQualifiedSegment(qualifier); + if (string.IsNullOrWhiteSpace(receiverName)) + return false; + + if (callContainer != null + && (callContainer.Kind == "function" || callContainer.Kind == "property") + && valueReceiverNamesByFunctionStartLine.TryGetValue(callContainer.StartLine, out var functionNames) + && HasCSharpFunctionValueReceiverName(functionNames, receiverName, lineNumber, column)) + { + return true; + } + + var containingType = GetContainingTypeQualifiedName(callContainer); + return containingType != null + && valueReceiverNamesByContainingType.TryGetValue(containingType, out var names) + && (IsStaticCSharpSymbol(callContainer) + ? names.StaticNames.Contains(receiverName) + : names.StaticNames.Contains(receiverName) || names.InstanceNames.Contains(receiverName)); + } + + private static string? GetContainingTypeQualifiedName(SymbolRecord? symbol) + { + if (symbol == null) + return null; + if (IsTypeLikeSymbolKind(symbol.Kind)) + return CombineQualifiedName(symbol.ContainerQualifiedName, symbol.Name); + return symbol.ContainerQualifiedName; + } + + private static bool IsTypeLikeSymbolKind(string? kind) => + kind is "class" or "struct" or "interface"; + + private static string? CombineQualifiedName(string? parentQualifiedName, string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + if (string.IsNullOrWhiteSpace(parentQualifiedName)) + return name; + return $"{parentQualifiedName}.{name}"; + } + + private static bool IsWithinCSharpScope(CSharpFunctionValueReceiverNameRecord record, int lineNumber, int column) + { + var startsBefore = lineNumber > record.ScopeStartLine + || (lineNumber == record.ScopeStartLine && column >= record.ScopeStartColumn); + if (!startsBefore) + return false; + + return lineNumber < record.ScopeEndLine + || (lineNumber == record.ScopeEndLine && column < record.ScopeEndColumn); + } + + private static void AddCSharpParameterNames( + List names, + string? signature, + int scopeStartLine, + int scopeStartColumn, + int scopeEndLine, + int scopeEndColumn, + HashSet? seenNames = null) + { + if (string.IsNullOrWhiteSpace(signature)) + return; + + var openParen = signature.IndexOf('('); + var closeParen = signature.LastIndexOf(')'); + if (openParen < 0 || closeParen <= openParen) + return; + + var parameters = signature[(openParen + 1)..closeParen]; + if (string.IsNullOrWhiteSpace(parameters)) + return; + + AddTopLevelCSharpParameterNames( + names, + parameters.AsSpan(), + scopeStartLine, + scopeStartColumn, + scopeEndLine, + scopeEndColumn, + seenNames); + } + + private static void AddTopLevelCSharpParameterNames( + List names, + ReadOnlySpan parameters, + int scopeStartLine, + int scopeStartColumn, + int scopeEndLine, + int scopeEndColumn, + HashSet? seenNames) + { + var depthAngle = 0; + var depthParen = 0; + var depthBracket = 0; + var depthBrace = 0; + var segmentStart = 0; + + for (var i = 0; i < parameters.Length; i++) + { + var ch = parameters[i]; + switch (ch) + { + case '<': + depthAngle++; + break; + case '>': + if (depthAngle > 0) + depthAngle--; + break; + case '(': + depthParen++; + break; + case ')': + if (depthParen > 0) + depthParen--; + break; + case '[': + depthBracket++; + break; + case ']': + if (depthBracket > 0) + depthBracket--; + break; + case '{': + depthBrace++; + break; + case '}': + if (depthBrace > 0) + depthBrace--; + break; + case ',': + if (depthAngle == 0 && depthParen == 0 && depthBracket == 0 && depthBrace == 0) + { + AddCSharpParameterSegmentName( + names, + parameters[segmentStart..i], + scopeStartLine, + scopeStartColumn, + scopeEndLine, + scopeEndColumn, + seenNames); + segmentStart = i + 1; + } + break; + } + } + + if (segmentStart <= parameters.Length) + AddCSharpParameterSegmentName( + names, + parameters[segmentStart..], + scopeStartLine, + scopeStartColumn, + scopeEndLine, + scopeEndColumn, + seenNames); + } + + private static void AddCSharpParameterSegmentName( + List names, + ReadOnlySpan segment, + int scopeStartLine, + int scopeStartColumn, + int scopeEndLine, + int scopeEndColumn, + HashSet? seenNames) + { + if (TryExtractTrailingCSharpParameterName(segment, out var name)) + AddCSharpFunctionValueReceiverName(names, name, scopeStartLine, scopeStartColumn, scopeEndLine, scopeEndColumn, seenNames); + } + + private static bool TryExtractTrailingCSharpParameterName(ReadOnlySpan segment, out string name) + { + name = string.Empty; + var trimmed = segment.Trim(); + if (trimmed.Length == 0 || trimmed.Equals("this".AsSpan(), StringComparison.Ordinal)) + return false; + + var end = trimmed.Length - 1; + while (end >= 0 && char.IsWhiteSpace(trimmed[end])) + end--; + while (end >= 0 && (trimmed[end] == '?' || trimmed[end] == '!')) + end--; + var start = end; + while (start >= 0 && IsCSharpIdentifierPart(trimmed[start])) + start--; + if (end < 0 || start >= end) + return false; + + name = NormalizeCSharpIdentifier(trimmed[(start + 1)..(end + 1)].ToString()); + return !string.IsNullOrWhiteSpace(name); + } + + private static void AddCSharpLambdaParameterNames( + List names, + string bodyText, + int startLineNumber, + int scopeEndLine, + HashSet? seenNames = null) + { + if (string.IsNullOrWhiteSpace(bodyText)) + return; + + var searchIndex = 0; + while (searchIndex < bodyText.Length) + { + var arrowIndex = bodyText.IndexOf("=>", searchIndex, StringComparison.Ordinal); + if (arrowIndex < 0) + break; + + var lambdaScopeEnd = FindCSharpArrowExpressionScopeEndPosition(bodyText, arrowIndex, startLineNumber, scopeEndLine); + AddCSharpLambdaParametersBeforeArrow(names, bodyText, arrowIndex, startLineNumber, lambdaScopeEnd, seenNames); + searchIndex = arrowIndex + 2; + } + } + + private static void AddCSharpRecursivePatternValueReceiverNames( + List names, + string bodyText, + IReadOnlyList structuralLines, + int bodyStartIndex, + int bodyEndIndex, + HashSet? seenNames = null) + { + if (string.IsNullOrWhiteSpace(bodyText)) + return; + + var startLineNumber = bodyStartIndex + 1; + foreach (var pattern in FindCSharpRecursivePatternValueNames(bodyText)) + { + var position = GetLineColumnFromOffset(bodyText, pattern.Offset, startLineNumber); + var declarationLineIndex = position.Line - 1; + if (pattern.ArrowIndex >= 0) + { + var scopeEnd = FindCSharpArrowExpressionScopeEndPosition(bodyText, pattern.ArrowIndex, startLineNumber, bodyEndIndex + 1); + AddCSharpFunctionValueReceiverName(names, pattern.Name, position.Line, position.Column, scopeEnd.Line, scopeEnd.Column, seenNames); + continue; + } + + if (pattern.IsCasePattern) + { + if (!TryFindCSharpSwitchCaseScopeEndPosition(structuralLines, bodyEndIndex, declarationLineIndex, position.Column, out var scopeEnd)) + continue; + + AddCSharpFunctionValueReceiverName(names, pattern.Name, position.Line, position.Column, scopeEnd.Line, scopeEnd.Column, seenNames); + continue; + } + + if (!TryFindCSharpDeclarationPatternScopeEndPosition(structuralLines, bodyStartIndex, bodyEndIndex, declarationLineIndex, position.Column, out var declarationScopeEnd)) + continue; + + AddCSharpFunctionValueReceiverName(names, pattern.Name, position.Line, position.Column, declarationScopeEnd.Line, declarationScopeEnd.Column, seenNames); + } + } + + private static IEnumerable FindCSharpRecursivePatternValueNames(string bodyText) + { + for (var index = 0; index < bodyText.Length; index++) + { + if (!IsCSharpIdentifierStart(bodyText[index])) + continue; + + var tokenStart = index; + index++; + while (index < bodyText.Length && IsCSharpIdentifierPart(bodyText[index])) + index++; + + var token = bodyText[tokenStart..index]; + if ((string.Equals(token, "is", StringComparison.Ordinal) || string.Equals(token, "case", StringComparison.Ordinal)) + && TryParseCSharpRecursivePatternDesignation(bodyText, index, string.Equals(token, "case", StringComparison.Ordinal), out var name, out var designationOffset)) + { + yield return new CSharpRecursivePatternValueNameRecord(name, designationOffset, string.Equals(token, "case", StringComparison.Ordinal)); + } + + index--; + } + + foreach (var pattern in FindCSharpSwitchExpressionPatternValueNames(bodyText)) + yield return pattern; + } + + private static IEnumerable FindCSharpSwitchExpressionPatternValueNames(string bodyText) + { + if (string.IsNullOrWhiteSpace(bodyText)) + yield break; + + for (var searchIndex = 0; searchIndex < bodyText.Length;) + { + var arrowIndex = bodyText.IndexOf("=>", searchIndex, StringComparison.Ordinal); + if (arrowIndex < 0) + yield break; + + searchIndex = arrowIndex + 2; + if (IsPotentialCSharpLambdaArrow(bodyText, arrowIndex)) + continue; + + if (!TryFindCSharpSwitchExpressionArmStartOffset(bodyText, arrowIndex, out var armStartOffset)) + continue; + + if (!TryParseCSharpSwitchExpressionArmPatternDesignation(bodyText, armStartOffset, arrowIndex, out var name, out var designationOffset)) + continue; + + yield return new CSharpRecursivePatternValueNameRecord(name, designationOffset, false, arrowIndex); + } + } + + private static bool TryFindCSharpSwitchExpressionArmStartOffset(string bodyText, int arrowIndex, out int armStartOffset) + { + armStartOffset = 0; + if (arrowIndex <= 0 || arrowIndex > bodyText.Length) + return false; + + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + for (var index = arrowIndex - 1; index >= 0; index--) + { + var current = bodyText[index]; + switch (current) + { + case ')': + parenDepth++; + break; + case '(': + if (parenDepth > 0) + parenDepth--; + break; + case ']': + bracketDepth++; + break; + case '[': + if (bracketDepth > 0) + bracketDepth--; + break; + case '}': + braceDepth++; + break; + case '{': + if (braceDepth > 0) + { + braceDepth--; + break; + } + + if (parenDepth == 0 && bracketDepth == 0) + { + armStartOffset = SkipWhitespaceForward(bodyText, index + 1); + return armStartOffset < arrowIndex; + } + + break; + case ',': + if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) + { + armStartOffset = SkipWhitespaceForward(bodyText, index + 1); + return armStartOffset < arrowIndex; + } + + break; + case ';': + if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) + return false; + break; + } + } + + return false; + } + + private static bool TryGetCSharpSwitchExpressionArmTypePatternRange( + string bodyText, + int arrowIndex, + out int bodyStartOffset, + out int armStartOffset, + out int armPatternEndOffset) + { + bodyStartOffset = 0; + armStartOffset = 0; + armPatternEndOffset = 0; + if (!TryFindCSharpSwitchExpressionBodyStartOffset(bodyText, arrowIndex, out bodyStartOffset)) + return false; + + var segmentStartOffset = bodyStartOffset + 1; + if (segmentStartOffset >= arrowIndex) + return false; + + var segmentText = bodyText[segmentStartOffset..arrowIndex]; + var lastCommaOffset = FindLastTopLevelCSharpComma(segmentText); + var relativeArmStart = lastCommaOffset >= 0 + ? SkipWhitespaceForward(segmentText, lastCommaOffset + 1) + : SkipWhitespaceForward(segmentText, 0); + if (relativeArmStart >= segmentText.Length) + return false; + + var armSegment = segmentText[relativeArmStart..]; + var whenOffset = FindTopLevelCSharpWhenKeywordOffset(armSegment); + var relativePatternEnd = whenOffset >= 0 + ? relativeArmStart + whenOffset + : segmentText.Length; + while (relativePatternEnd > relativeArmStart && char.IsWhiteSpace(segmentText[relativePatternEnd - 1])) + relativePatternEnd--; + if (relativePatternEnd <= relativeArmStart) + return false; + + armStartOffset = segmentStartOffset + relativeArmStart; + armPatternEndOffset = segmentStartOffset + relativePatternEnd; + return armStartOffset < armPatternEndOffset; + } + + private static bool TryFindCSharpSwitchExpressionBodyStartOffset(string bodyText, int arrowIndex, out int bodyStartOffset) + { + bodyStartOffset = -1; + if (arrowIndex <= 0 || arrowIndex > bodyText.Length) + return false; + + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + for (var index = arrowIndex - 1; index >= 0; index--) + { + var current = bodyText[index]; + switch (current) + { + case ')': + parenDepth++; + break; + case '(': + if (parenDepth > 0) + parenDepth--; + break; + case ']': + bracketDepth++; + break; + case '[': + if (bracketDepth > 0) + bracketDepth--; + break; + case '}': + braceDepth++; + break; + case '{': + if (braceDepth > 0) + { + braceDepth--; + break; + } + + if (parenDepth == 0 && bracketDepth == 0) + { + bodyStartOffset = index; + return true; + } + + break; + case ';': + if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) + return false; + break; + } + } + + return false; + } + + private static int FindLastTopLevelCSharpComma(string text) + { + var angleDepth = 0; + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var lastComma = -1; + for (var i = 0; i < text.Length; i++) + { + switch (text[i]) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) + angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) + parenDepth--; + break; + case '[': + bracketDepth++; + break; + case ']': + if (bracketDepth > 0) + bracketDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) + braceDepth--; + break; + case ',': + if (angleDepth == 0 && parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) + lastComma = i; + break; + } + } + + return lastComma; + } + + private static bool TryParseCSharpSwitchExpressionArmPatternDesignation( + string bodyText, + int armStartOffset, + int arrowIndex, + out string name, + out int designationOffset) + { + name = string.Empty; + designationOffset = -1; + if (armStartOffset < 0 || armStartOffset >= arrowIndex || arrowIndex > bodyText.Length) + return false; + + var preparedArmLines = StructuralLineMasker.MaskLines( + "csharp", + SplitCSharpSwitchExpressionArmLines(bodyText, armStartOffset, arrowIndex)); + for (var i = 0; i < preparedArmLines.Length; i++) + preparedArmLines[i] = PrepareLine("csharp", preparedArmLines[i]); + + var preparedArmText = string.Join("\n", preparedArmLines); + if (!TryParseCSharpRecursivePatternDesignation(preparedArmText, 0, false, out name, out var relativeOffset) + && !TryParseCSharpSwitchExpressionArmDeclarationPatternDesignation(preparedArmText, out name, out relativeOffset)) + { + return false; + } + + designationOffset = armStartOffset + relativeOffset; + return designationOffset < arrowIndex; + } + + private static string[] SplitCSharpSwitchExpressionArmLines(string bodyText, int startOffset, int endOffset) + { + var length = endOffset - startOffset; + var firstLineBreak = bodyText.IndexOf('\n', startOffset, length); + if (firstLineBreak < 0) + return [bodyText[startOffset..endOffset]]; + + var lineCount = 2; + for (var i = firstLineBreak + 1; i < endOffset; i++) + { + if (bodyText[i] == '\n') + lineCount++; + } + + var lines = new string[lineCount]; + var lineStart = startOffset; + var lineIndex = 0; + for (var i = startOffset; i < endOffset; i++) + { + if (bodyText[i] != '\n') + continue; + + lines[lineIndex++] = bodyText[lineStart..i]; + lineStart = i + 1; + } + + lines[lineIndex] = bodyText[lineStart..endOffset]; + return lines; + } + + private static bool TryParseCSharpSwitchExpressionArmDeclarationPatternDesignation( + string armText, + out string name, + out int designationOffset) + { + name = string.Empty; + designationOffset = -1; + if (string.IsNullOrWhiteSpace(armText)) + return false; + + var whenOffset = FindTopLevelCSharpWhenKeywordOffset(armText); + var patternText = whenOffset >= 0 ? armText[..whenOffset] : armText; + var match = CSharpSwitchExpressionDeclarationPatternValueNameRegex.Match(patternText); + if (!match.Success) + return false; + + name = NormalizeCSharpIdentifier(match.Groups["name"].Value); + designationOffset = match.Groups["name"].Index; + return designationOffset >= 0; + } + + private static bool TryParseCSharpRecursivePatternDesignation( + string bodyText, + int index, + bool isCasePattern, + out string name, + out int designationOffset) + { + name = string.Empty; + designationOffset = -1; + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + var sawRecursiveClause = false; + var previousTopLevelNonWhitespaceChar = '\0'; + for (var i = index; i < bodyText.Length; i++) + { + var current = bodyText[i]; + if (char.IsWhiteSpace(current)) + continue; + + if (braceDepth == 0 && parenDepth == 0 && bracketDepth == 0 && IsCSharpIdentifierStart(current)) + { + var tokenStart = i; + i++; + while (i < bodyText.Length && IsCSharpIdentifierPart(bodyText[i])) + i++; + + var token = bodyText[tokenStart..i]; + i--; + if (sawRecursiveClause + && previousTopLevelNonWhitespaceChar is not '.' and not ':' and not '<' and not '[' and not '?' + && !IsCSharpPatternControlKeyword(token)) + { + name = NormalizeCSharpIdentifier(token); + designationOffset = tokenStart; + return true; + } + + previousTopLevelNonWhitespaceChar = token[^1]; + continue; + } + + switch (current) + { + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) + parenDepth--; + break; + case '[': + bracketDepth++; + break; + case ']': + if (bracketDepth > 0) + bracketDepth--; + break; + case '{': + braceDepth++; + sawRecursiveClause = true; + break; + case '}': + if (braceDepth > 0) + braceDepth--; + break; + } + + if (braceDepth == 0 && parenDepth == 0 && bracketDepth == 0) + previousTopLevelNonWhitespaceChar = current; + } + + return false; + } + + private static bool IsCSharpPatternControlKeyword(string token) => + token is "and" or "or" or "not" or "when" or "null" or "true" or "false"; + + private static int FindTopLevelCSharpWhenKeywordOffset(string text) + { + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + for (var i = 0; i < text.Length; i++) + { + var current = text[i]; + switch (current) + { + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) + parenDepth--; + break; + case '[': + bracketDepth++; + break; + case ']': + if (bracketDepth > 0) + bracketDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) + braceDepth--; + break; + } + + if (parenDepth == 0 + && bracketDepth == 0 + && braceDepth == 0 + && TryConsumeCSharpKeyword(text, i, "when", out _)) + { + return i; + } + } + + return -1; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.QualifiedPatterns.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.QualifiedPatterns.cs new file mode 100644 index 000000000..a75aac08d --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.QualifiedPatterns.cs @@ -0,0 +1,887 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + internal static void EmitCSharpQualifiedEnumMemberReferences( + string preparedLine, + IReadOnlyDictionary> enumMemberLookup, + IReadOnlyList<(int start, int end)>? csharpAttrRangesOnLine, + IReadOnlyList usingAliases, + Func> getValueReceiverNamesByContainingType, + Func>> getValueReceiverNamesByFunctionStartLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForCall) + { + var scan = 0; + while (scan < preparedLine.Length) + { + if (!TryReadCSharpQualifiedAccess(preparedLine, scan, out var parsed)) + { + scan++; + continue; + } + + scan = Math.Max(scan + 1, parsed.NextIndex); + if (!parsed.LastSeparatorWasDot || parsed.Segments.Count < 2) + continue; + + var member = parsed.Segments[^1]; + var memberName = preparedLine.Substring(member.Start, member.End - member.Start); + if (!enumMemberLookup.TryGetValue(memberName, out var targets)) + continue; + + var callContainer = resolveContainerForCall(member.Start); + var qualifier = TrimLeadingCSharpGlobalQualifier(NormalizeCSharpQualifiedSegments(preparedLine, parsed.Segments, parsed.Segments.Count - 1)); + var resolvedQualifier = parsed.HasLeadingGlobalQualifier + ? qualifier + : ResolveCSharpQualifiedAliasTarget(qualifier, lineNumber, usingAliases); + if (!parsed.HasLeadingGlobalQualifier + && HasCSharpValueReceiverConflict( + qualifier, + resolvedQualifier, + lineNumber, + member.Start, + callContainer, + getValueReceiverNamesByContainingType(), + getValueReceiverNamesByFunctionStartLine())) + { + continue; + } + if (!MatchesQualifiedConstantContainer( + resolvedQualifier, + targets, + allowShortNameFallback: !parsed.HasLeadingGlobalQualifier, + allowSingleSegmentQualifiedMatch: parsed.HasLeadingGlobalQualifier)) + continue; + + if (IsCSharpQualifiedConstantPatternReferenceSite(preparedLine, parsed)) + continue; + + var nextTokenIndex = SkipWhitespace(preparedLine, member.End); + if (nextTokenIndex < preparedLine.Length && preparedLine[nextTokenIndex] == '(') + continue; + + var insideCSharpAttributeRange = csharpAttrRangesOnLine != null + && IsInsideCSharpAttributeRange(csharpAttrRangesOnLine, member.Start); + var referenceKind = TryClassifyMetadataReference("csharp", preparedLine, member.Start, insideCSharpAttributeRange) ?? "call"; + + AddReference( + references, + seen, + fileId, + memberName, + member.Start, + referenceKind, + context, + lineNumber, + callContainer); + } + } + + private static bool IsCSharpQualifiedConstantPatternReferenceSite( + string preparedLine, + (IReadOnlyList<(int Start, int End)> Segments, int NextIndex, bool LastSeparatorWasDot, bool HasLeadingGlobalQualifier) parsed) + { + if (!parsed.LastSeparatorWasDot || parsed.Segments.Count < 2) + return false; + + var headCursor = parsed.Segments[0].Start; + if (parsed.HasLeadingGlobalQualifier + && headCursor >= "global::".Length + && preparedLine.AsSpan(headCursor - "global::".Length, "global::".Length).Equals("global::", StringComparison.Ordinal)) + { + headCursor -= "global::".Length; + } + + return IsCSharpConstantPatternAnchor(preparedLine, ref headCursor); + } + + private static bool IsCSharpConstantPatternAnchor(string text, ref int cursor) + { + cursor = SkipCSharpTriviaBackward(text, cursor); + if (TryConsumeTrailingCSharpToken(text, ref cursor, "not")) + cursor = SkipCSharpTriviaBackward(text, cursor); + + while (true) + { + if (TryConsumeTrailingCSharpToken(text, ref cursor, "case")) + return true; + + if (TryConsumeTrailingCSharpToken(text, ref cursor, "is")) + return false; + + if (!TryConsumeTrailingCSharpToken(text, ref cursor, "or") + && !TryConsumeTrailingCSharpToken(text, ref cursor, "and")) + { + return false; + } + + cursor = SkipCSharpTriviaBackward(text, cursor); + if (!SkipCSharpPatternHeadBackward(text, ref cursor)) + return false; + cursor = SkipCSharpTriviaBackward(text, cursor); + if (TryConsumeTrailingCSharpToken(text, ref cursor, "not")) + cursor = SkipCSharpTriviaBackward(text, cursor); + } + } + + private static int SkipCSharpTriviaBackward(string text, int cursor) + { + while (cursor > 0) + { + if (char.IsWhiteSpace(text[cursor - 1])) + { + cursor--; + continue; + } + + if (cursor >= 2 + && text[cursor - 1] == '/' + && text[cursor - 2] == '*') + { + var commentStart = text.LastIndexOf("/*", cursor - 2, StringComparison.Ordinal); + if (commentStart >= 0) + { + cursor = commentStart; + continue; + } + } + + break; + } + + return cursor; + } + + internal static bool IsCSharpPatternHeadCallSite(string[] preparedLines, int lineIndex, string preparedLine, int nameIndex) + { + var whenOffset = FindTopLevelCSharpWhenKeywordOffset(preparedLine); + if (whenOffset >= 0 && nameIndex > whenOffset) + return false; + + var cursor = nameIndex; + if (IsCSharpConstantPatternAnchor(preparedLine, ref cursor)) + return true; + + cursor = nameIndex; + cursor = SkipCSharpTriviaBackward(preparedLine, cursor); + if (TryConsumeTrailingCSharpToken(preparedLine, ref cursor, "not")) + cursor = SkipCSharpTriviaBackward(preparedLine, cursor); + + if (TryConsumeTrailingCSharpToken(preparedLine, ref cursor, "is")) + return true; + + for (var previous = lineIndex - 1; previous >= 0; previous--) + { + var previousLine = preparedLines[previous]; + if (string.IsNullOrWhiteSpace(previousLine)) + continue; + + if (LineEndsWithCSharpToken(previousLine, "case") + || LineEndsWithCSharpToken(previousLine, "is") + || LineEndsWithCSharpToken(previousLine, "not")) + { + return true; + } + + break; + } + + // Switch-expression arms (`Point(...) => ...`) do not have a `case` / `is` anchor, + // so the same positional pattern suppression has to look for the trailing arrow. + if (IsCSharpSwitchExpressionPatternHead(preparedLines, lineIndex, preparedLine, nameIndex)) + return true; + + return false; + } + + private static bool IsCSharpSwitchExpressionPatternHead(string[] preparedLines, int lineIndex, string preparedLine, int nameIndex) + { + var cursor = nameIndex; + while (cursor < preparedLine.Length && IsCSharpIdentifierPart(preparedLine[cursor])) + cursor++; + + cursor = SkipCSharpTriviaForward(preparedLine, cursor); + + var openParenIndex = preparedLine.IndexOf('(', cursor); + if (openParenIndex < 0) + return false; + + var parenDepth = 0; + for (var i = openParenIndex; i < preparedLine.Length; i++) + { + switch (preparedLine[i]) + { + case '(': + parenDepth++; + break; + case ')': + parenDepth--; + if (parenDepth == 0) + { + var afterClose = SkipCSharpTriviaForward(preparedLine, i + 1); + if (afterClose + 1 < preparedLine.Length + && preparedLine[afterClose] == '=' + && preparedLine[afterClose + 1] == '>') + { + return true; + } + + for (var next = lineIndex + 1; next < preparedLines.Length; next++) + { + var nextLine = preparedLines[next]; + if (string.IsNullOrWhiteSpace(nextLine)) + continue; + + var nextCursor = SkipCSharpTriviaForward(nextLine, 0); + return nextCursor + 1 < nextLine.Length + && nextLine[nextCursor] == '=' + && nextLine[nextCursor + 1] == '>'; + } + + return false; + } + break; + } + } + + return false; + } + + private static bool LineEndsWithCSharpToken(string text, string token) + { + var cursor = text.Length; + return TryConsumeTrailingCSharpToken(text, ref cursor, token); + } + + private static bool TryConsumeTrailingCSharpToken(string text, ref int cursor, string token) + { + if (string.IsNullOrEmpty(token)) + return false; + + cursor = SkipCSharpTriviaBackward(text, cursor); + if (cursor < token.Length) + return false; + + var tokenStart = cursor - token.Length; + if (!text.AsSpan(tokenStart, token.Length).Equals(token, StringComparison.Ordinal)) + return false; + + if ((tokenStart > 0 && IsCSharpIdentifierPart(text[tokenStart - 1])) + || (cursor < text.Length && IsCSharpIdentifierPart(text[cursor]))) + { + return false; + } + + cursor = tokenStart; + return true; + } + + private static bool SkipCSharpPatternHeadBackward(string text, ref int cursor) + { + if (!TryConsumeTrailingCSharpIdentifier(text, ref cursor)) + return false; + + while (true) + { + cursor = SkipCSharpTriviaBackward(text, cursor); + if (cursor >= 2 + && text[cursor - 2] == ':' + && text[cursor - 1] == ':') + { + cursor -= 2; + } + else if (cursor > 0 && text[cursor - 1] == '.') + { + cursor--; + } + else + { + break; + } + + cursor = SkipCSharpTriviaBackward(text, cursor); + if (!TryConsumeTrailingCSharpIdentifier(text, ref cursor)) + return false; + } + + return true; + } + + private static bool TryConsumeTrailingCSharpIdentifier(string text, ref int cursor) + { + var end = cursor; + while (cursor > 0 && IsCSharpIdentifierPart(text[cursor - 1])) + cursor--; + + if (cursor == end) + return false; + + if (cursor > 0 && text[cursor - 1] == '@') + cursor--; + + return true; + } + + private static bool TryReadCSharpQualifiedAccess( + string preparedLine, + int start, + out (IReadOnlyList<(int Start, int End)> Segments, int NextIndex, bool LastSeparatorWasDot, bool HasLeadingGlobalQualifier) parsed) + { + parsed = (Array.Empty<(int Start, int End)>(), start, false, false); + + if (start > 0 && IsCSharpIdentifierPart(preparedLine[start - 1])) + return false; + if (start >= preparedLine.Length || !IsCSharpIdentifierStart(preparedLine[start])) + return false; + + var segments = new List<(int Start, int End)>(); + var cursor = start; + var lastSeparatorWasDot = false; + var hasLeadingGlobalQualifier = false; + while (true) + { + if (!TryConsumeCSharpIdentifier(preparedLine, ref cursor, out var segmentStart, out var segmentEnd)) + return false; + + segments.Add((segmentStart, segmentEnd)); + + var separatorStart = SkipWhitespace(preparedLine, cursor); + if (separatorStart + 1 < preparedLine.Length + && preparedLine[separatorStart] == ':' + && preparedLine[separatorStart + 1] == ':') + { + if (segments.Count == 1 + && segmentEnd - segmentStart == "global".Length + && string.CompareOrdinal(preparedLine, segmentStart, "global", 0, "global".Length) == 0) + { + hasLeadingGlobalQualifier = true; + } + + cursor = SkipWhitespace(preparedLine, separatorStart + 2); + lastSeparatorWasDot = false; + continue; + } + + if (separatorStart < preparedLine.Length && preparedLine[separatorStart] == '.') + { + cursor = SkipWhitespace(preparedLine, separatorStart + 1); + lastSeparatorWasDot = true; + continue; + } + + parsed = (segments, cursor, lastSeparatorWasDot, hasLeadingGlobalQualifier); + return true; + } + } + + private static bool TryConsumeCSharpIdentifier( + string preparedLine, + ref int cursor, + out int start, + out int end) + { + start = cursor; + if (cursor >= preparedLine.Length || !IsCSharpIdentifierStart(preparedLine[cursor])) + { + end = cursor; + return false; + } + + cursor++; + while (cursor < preparedLine.Length && IsCSharpIdentifierPart(preparedLine[cursor])) + cursor++; + + end = cursor; + return true; + } + + private static bool TryConsumeCSharpPatternKeyword(string preparedLine, ref int cursor, string keyword) + { + if (!preparedLine.AsSpan(cursor).StartsWith(keyword, StringComparison.Ordinal)) + return false; + + int afterKeyword = cursor + keyword.Length; + if (afterKeyword < preparedLine.Length && !char.IsWhiteSpace(preparedLine[afterKeyword])) + return false; + + cursor = afterKeyword; + return true; + } + + private static bool IsCSharpCaseTypePatternContinuation( + string preparedLine, + string typeExpression, + int cursor, + IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, + IReadOnlyDictionary> csharpQualifiedTypePatternLookup, + IReadOnlyList csharpUsingAliases, + IReadOnlyList csharpUsingStatics, + Func hasActiveSameFileCSharpTypeCandidate, + int lineNumber) + { + if (IsCSharpNonTypePatternExpression(typeExpression)) + return false; + + if (cursor >= preparedLine.Length) + return false; + + return preparedLine[cursor] switch + { + ':' => !IsCSharpConstantPatternMemberHead( + typeExpression, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate), + '{' or '(' or '[' => true, + _ => IsCSharpCaseTypePatternIdentifier( + preparedLine, + typeExpression, + cursor, + csharpQualifiedConstantPatternMemberLookup, + csharpQualifiedTypePatternLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate, + lineNumber) + }; + } + + private static bool IsCSharpCaseTypePatternIdentifier( + string preparedLine, + string typeExpression, + int cursor, + IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, + IReadOnlyDictionary> csharpQualifiedTypePatternLookup, + IReadOnlyList csharpUsingAliases, + IReadOnlyList csharpUsingStatics, + Func hasActiveSameFileCSharpTypeCandidate, + int lineNumber) + { + int tokenCursor = cursor; + if (!TryConsumeCSharpIdentifier(preparedLine, ref tokenCursor, out var start, out var end)) + return false; + + var rawToken = preparedLine[start..end]; + if (rawToken.Length > 0 && rawToken[0] == '@') + return true; + + return rawToken switch + { + "when" => !IsCSharpConstantPatternMemberHead( + typeExpression, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate), + "or" or "and" => !IsCSharpLogicalConstantPatternHead( + preparedLine, + typeExpression, + tokenCursor, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpQualifiedTypePatternLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate), + _ => true, + }; + } + + private static bool TryEmitCSharpLogicalTypePatternHeads( + string preparedLine, + string initialTypeExpression, + int initialTypeIndex, + int continuationIndex, + int lineNumber, + IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, + IReadOnlyDictionary> csharpQualifiedTypePatternLookup, + IReadOnlyList csharpUsingAliases, + IReadOnlyList csharpUsingStatics, + Func hasActiveSameFileCSharpTypeCandidate, + Action emitTypeExpression) + { + var currentTypeExpression = initialTypeExpression; + var currentTypeIndex = initialTypeIndex; + var currentContinuationIndex = continuationIndex; + var sawLogicalKeyword = false; + var emittedAny = false; + while (TryConsumeCSharpLogicalPatternKeyword(preparedLine, currentContinuationIndex, out var nextHeadCursor)) + { + sawLogicalKeyword = true; + if (!IsCSharpLogicalConstantPatternHead( + preparedLine, + currentTypeExpression, + nextHeadCursor, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpQualifiedTypePatternLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate)) + { + emitTypeExpression(currentTypeExpression, currentTypeIndex); + emittedAny = true; + } + + int nextTypeCursor = nextHeadCursor; + if (TryConsumeCSharpPatternKeyword(preparedLine, ref nextTypeCursor, "not")) + nextTypeCursor = SkipWhitespace(preparedLine, nextTypeCursor); + + var nextMatch = CSharpTypeExpressionAtCursorRegex.Match(preparedLine, nextTypeCursor); + if (!nextMatch.Success) + return false; + + var nextTypeGroup = nextMatch.Groups["type"]; + currentTypeExpression = nextTypeGroup.Value; + currentTypeIndex = nextTypeGroup.Index; + currentContinuationIndex = SkipWhitespace(preparedLine, nextTypeGroup.Index + nextTypeGroup.Length); + } + + if (sawLogicalKeyword + && !IsCSharpNonTypePatternExpression(currentTypeExpression) + && !IsCSharpConstantPatternMemberHead( + currentTypeExpression, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate)) + { + emitTypeExpression(currentTypeExpression, currentTypeIndex); + emittedAny = true; + } + + return emittedAny; + } + + private static bool IsCSharpLogicalConstantPatternAtCursor( + string preparedLine, + string typeExpression, + int cursor, + int lineNumber, + IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, + IReadOnlyDictionary> csharpQualifiedTypePatternLookup, + IReadOnlyList csharpUsingAliases, + IReadOnlyList csharpUsingStatics, + Func hasActiveSameFileCSharpTypeCandidate) + { + int tokenCursor = cursor; + if (!TryConsumeCSharpIdentifier(preparedLine, ref tokenCursor, out var start, out var end)) + return false; + + var rawToken = preparedLine[start..end]; + if (rawToken is not ("or" or "and")) + return false; + + return IsCSharpLogicalConstantPatternHead( + preparedLine, + typeExpression, + tokenCursor, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpQualifiedTypePatternLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate); + } + + private static bool TryConsumeCSharpLogicalPatternKeyword( + string preparedLine, + int cursor, + out int nextHeadCursor) + { + nextHeadCursor = cursor; + int tokenCursor = cursor; + if (!TryConsumeCSharpIdentifier(preparedLine, ref tokenCursor, out var start, out var end)) + return false; + + var rawToken = preparedLine[start..end]; + if (rawToken is not ("or" or "and")) + return false; + + nextHeadCursor = SkipWhitespace(preparedLine, tokenCursor); + return true; + } + + private static bool IsCSharpLogicalConstantPatternHead( + string preparedLine, + string typeExpression, + int cursor, + int lineNumber, + IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, + IReadOnlyDictionary> csharpQualifiedTypePatternLookup, + IReadOnlyList csharpUsingAliases, + IReadOnlyList csharpUsingStatics, + Func hasActiveSameFileCSharpTypeCandidate) + { + if (IsCSharpConstantPatternMemberHead( + typeExpression, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases, + csharpUsingStatics, + hasActiveSameFileCSharpTypeCandidate)) + { + return true; + } + + if (IsCSharpQualifiedTypePatternHead( + typeExpression, + lineNumber, + csharpQualifiedTypePatternLookup, + csharpUsingAliases)) + { + return false; + } + + if (!TryReadCSharpQualifiedAccess(typeExpression, 0, out var currentParsed) + || !currentParsed.LastSeparatorWasDot + || currentParsed.Segments.Count < 2) + { + return false; + } + + var currentQualifier = ResolveCSharpQualifiedConstantPatternQualifier(typeExpression, currentParsed, lineNumber, csharpUsingAliases); + if (string.IsNullOrWhiteSpace(currentQualifier)) + return false; + + int nextCursor = SkipWhitespace(preparedLine, cursor); + if (TryConsumeCSharpPatternKeyword(preparedLine, ref nextCursor, "not")) + nextCursor = SkipWhitespace(preparedLine, nextCursor); + + var nextMatch = CSharpTypeExpressionAtCursorRegex.Match(preparedLine, nextCursor); + if (!nextMatch.Success) + return false; + + var nextTypeExpression = nextMatch.Groups["type"].Value; + if (IsCSharpQualifiedTypePatternHead( + nextTypeExpression, + lineNumber, + csharpQualifiedTypePatternLookup, + csharpUsingAliases)) + { + return false; + } + + if (!TryReadCSharpQualifiedAccess(nextTypeExpression, 0, out var nextParsed) + || !nextParsed.LastSeparatorWasDot + || nextParsed.Segments.Count < 2) + { + return false; + } + + var nextQualifier = ResolveCSharpQualifiedConstantPatternQualifier(nextTypeExpression, nextParsed, lineNumber, csharpUsingAliases); + return string.Equals(currentQualifier, nextQualifier, StringComparison.Ordinal); + } + + private static bool IsCSharpQualifiedTypePatternHead( + string typeExpression, + int lineNumber, + IReadOnlyDictionary> csharpQualifiedTypePatternLookup, + IReadOnlyList csharpUsingAliases) + { + if (!TryReadCSharpQualifiedAccess(typeExpression, 0, out var parsed) + || !parsed.LastSeparatorWasDot + || parsed.Segments.Count < 2) + { + return false; + } + + var member = parsed.Segments[^1]; + var memberName = typeExpression.Substring(member.Start, member.End - member.Start); + if (!csharpQualifiedTypePatternLookup.TryGetValue(memberName, out var targets)) + return false; + + var resolvedQualifier = ResolveCSharpQualifiedConstantPatternQualifier(typeExpression, parsed, lineNumber, csharpUsingAliases); + bool qualifierHasMultipleSegments = resolvedQualifier.Contains('.') || resolvedQualifier.Contains("::", StringComparison.Ordinal); + return MatchesQualifiedConstantContainer( + resolvedQualifier, + targets, + allowShortNameFallback: !parsed.HasLeadingGlobalQualifier && !qualifierHasMultipleSegments, + allowSingleSegmentQualifiedMatch: parsed.HasLeadingGlobalQualifier); + } + + private static string ResolveCSharpQualifiedConstantPatternQualifier( + string typeExpression, + (IReadOnlyList<(int Start, int End)> Segments, int NextIndex, bool LastSeparatorWasDot, bool HasLeadingGlobalQualifier) parsed, + int lineNumber, + IReadOnlyList csharpUsingAliases) + { + var qualifier = TrimLeadingCSharpGlobalQualifier(NormalizeCSharpQualifiedSegments(typeExpression, parsed.Segments, parsed.Segments.Count - 1)); + return parsed.HasLeadingGlobalQualifier + ? qualifier + : ResolveCSharpQualifiedAliasTarget(qualifier, lineNumber, csharpUsingAliases); + } + + private static bool IsCSharpQualifiedConstantPatternMemberHead( + string typeExpression, + int lineNumber, + IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, + IReadOnlyList csharpUsingAliases) + { + if (!TryReadCSharpQualifiedAccess(typeExpression, 0, out var parsed) + || !parsed.LastSeparatorWasDot + || parsed.Segments.Count < 2) + { + return false; + } + + var member = parsed.Segments[^1]; + var memberName = typeExpression.Substring(member.Start, member.End - member.Start); + if (!csharpQualifiedConstantPatternMemberLookup.TryGetValue(memberName, out var targets)) + return false; + + var resolvedQualifier = ResolveCSharpQualifiedConstantPatternQualifier(typeExpression, parsed, lineNumber, csharpUsingAliases); + bool qualifierHasMultipleSegments = resolvedQualifier.Contains('.') || resolvedQualifier.Contains("::", StringComparison.Ordinal); + return MatchesQualifiedConstantContainer( + resolvedQualifier, + targets, + allowShortNameFallback: !parsed.HasLeadingGlobalQualifier && !qualifierHasMultipleSegments, + allowSingleSegmentQualifiedMatch: parsed.HasLeadingGlobalQualifier); + } + + private static bool IsCSharpConstantPatternMemberHead( + string typeExpression, + int lineNumber, + IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, + IReadOnlyList csharpUsingAliases, + IReadOnlyList csharpUsingStatics, + Func hasActiveSameFileCSharpTypeCandidate) + { + return IsCSharpQualifiedConstantPatternMemberHead( + typeExpression, + lineNumber, + csharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases); + } + + private static bool IsCSharpNonTypePatternExpression(string typeExpression) + { + var trimmed = typeExpression.Trim(); + if (trimmed.Length == 0) + return false; + + if (trimmed[0] == '@') + return false; + + return trimmed.IndexOf('.') < 0 + && trimmed.IndexOf(':') < 0 + && trimmed.IndexOf('<') < 0 + && trimmed.IndexOf('[') < 0 + && trimmed.IndexOf('?') < 0 + && trimmed.IndexOf(' ') < 0 + && CSharpNonTypePatternTokens.Contains(trimmed); + } + + private static int SkipWhitespace(string text, int index) + { + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + return index; + } + + private static string NormalizeCSharpIdentifier(string identifier) => + !string.IsNullOrEmpty(identifier) && identifier[0] == '@' + ? identifier[1..] + : identifier; + + private static string NormalizeAtPrefixedIdentifier(string identifier) => + !string.IsNullOrEmpty(identifier) && identifier[0] == '@' + ? identifier[1..] + : identifier; + + private static string NormalizeCSharpQualifiedSegments( + string preparedLine, + IReadOnlyList<(int Start, int End)> segments, + int count) + { + var capacity = Math.Max(0, count - 1); + for (var i = 0; i < count; i++) + { + var (start, end) = segments[i]; + var length = end - start; + if (length > 0 && preparedLine[start] == '@') + length--; + capacity += length; + } + + var builder = new StringBuilder(capacity); + for (var i = 0; i < count; i++) + { + if (i > 0) + builder.Append('.'); + var (start, end) = segments[i]; + var length = end - start; + if (length > 0 && preparedLine[start] == '@') + { + start++; + length--; + } + + builder.Append(preparedLine, start, length); + } + return builder.ToString(); + } + + private static string TrimLeadingCSharpGlobalQualifier(string qualifiedName) => + qualifiedName.StartsWith("global.", StringComparison.Ordinal) + ? qualifiedName["global.".Length..] + : qualifiedName; + + private static string? TryNormalizeCSharpQualifiedName(string candidate) + { + var trimmed = candidate.Trim(); + if (trimmed.StartsWith("global::", StringComparison.Ordinal)) + trimmed = trimmed["global::".Length..]; + if (string.IsNullOrWhiteSpace(trimmed)) + return null; + if (!TryReadCSharpQualifiedAccess(trimmed, 0, out var parsed)) + return null; + if (SkipWhitespace(trimmed, parsed.NextIndex) != trimmed.Length) + return null; + return NormalizeCSharpQualifiedSegments(trimmed, parsed.Segments, parsed.Segments.Count); + } + + private static string ResolveCSharpQualifiedAliasTarget(string qualifier, int lineNumber, IReadOnlyList usingAliases) + { + if (string.IsNullOrWhiteSpace(qualifier) || usingAliases.Count == 0) + return qualifier; + + var firstSegment = GetFirstQualifiedSegment(qualifier); + string? aliasTarget = null; + for (var i = usingAliases.Count - 1; i >= 0; i--) + { + var alias = usingAliases[i]; + if (alias.Line > lineNumber) + continue; + if (lineNumber < alias.ScopeStartLine || lineNumber > alias.ScopeEndLine) + continue; + if (!string.Equals(alias.AliasName, firstSegment, StringComparison.Ordinal)) + continue; + + aliasTarget = alias.TargetQualifiedName; + break; + } + + if (aliasTarget == null) + return qualifier; + + return qualifier.Length == firstSegment.Length + ? aliasTarget + : aliasTarget + qualifier[firstSegment.Length..]; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.QuerySyntax.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.QuerySyntax.cs new file mode 100644 index 000000000..c0f3975a1 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.QuerySyntax.cs @@ -0,0 +1,759 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static bool TryGetPreviousTopLevelToken( + IReadOnlyList structuralLines, + int startLineIndex, + int startColumn, + out int tokenLineIndex, + out int tokenStartColumn, + out int tokenEndColumn, + out string identifierToken, + out char punctuationToken) + { + tokenLineIndex = -1; + tokenStartColumn = -1; + tokenEndColumn = -1; + identifierToken = string.Empty; + punctuationToken = '\0'; + + if (!TryGetPreviousTopLevelSignificantChar( + structuralLines, + startLineIndex, + startColumn, + out tokenLineIndex, + out tokenEndColumn, + out var tokenChar)) + { + return false; + } + + tokenStartColumn = tokenEndColumn; + if (IsCSharpIdentifierPart(tokenChar)) + { + var line = structuralLines[tokenLineIndex]; + while (tokenStartColumn > 0 && IsCSharpIdentifierPart(line[tokenStartColumn - 1])) + tokenStartColumn--; + + identifierToken = line.Substring(tokenStartColumn, tokenEndColumn - tokenStartColumn + 1); + } + else + { + punctuationToken = tokenChar; + } + + return true; + } + + private static bool TryGetPreviousTopLevelSignificantChar( + IReadOnlyList structuralLines, + int startLineIndex, + int startColumn, + out int lineIndex, + out int column, + out char value) + { + lineIndex = -1; + column = -1; + value = '\0'; + + if (structuralLines.Count == 0) + return false; + + var clampedLineIndex = Math.Min(startLineIndex, structuralLines.Count - 1); + for (var currentLineIndex = clampedLineIndex; currentLineIndex >= 0; currentLineIndex--) + { + var line = structuralLines[currentLineIndex]; + var currentColumn = currentLineIndex == clampedLineIndex + ? Math.Min(startColumn, line.Length - 1) + : line.Length - 1; + for (var probe = currentColumn; probe >= 0; probe--) + { + if (char.IsWhiteSpace(line[probe])) + continue; + + lineIndex = currentLineIndex; + column = probe; + value = line[probe]; + return true; + } + } + + return false; + } + + private static bool TryGetNextTopLevelSignificantChar( + IReadOnlyList structuralLines, + int startLineIndex, + int startColumn, + out int lineIndex, + out int column, + out char value) + { + lineIndex = -1; + column = -1; + value = '\0'; + + if (structuralLines.Count == 0) + return false; + + var clampedLineIndex = Math.Max(0, Math.Min(startLineIndex, structuralLines.Count - 1)); + for (var currentLineIndex = clampedLineIndex; currentLineIndex < structuralLines.Count; currentLineIndex++) + { + var line = structuralLines[currentLineIndex]; + var currentColumn = currentLineIndex == clampedLineIndex + ? Math.Max(0, startColumn) + : 0; + for (var probe = currentColumn; probe < line.Length; probe++) + { + if (char.IsWhiteSpace(line[probe])) + continue; + + lineIndex = currentLineIndex; + column = probe; + value = line[probe]; + return true; + } + } + + return false; + } + + private static bool TryFindMatchingCSharpOpenParenBackwards( + IReadOnlyList structuralLines, + int closeParenLineIndex, + int closeParenColumn, + out int openParenLineIndex, + out int openParenColumn) + { + openParenLineIndex = -1; + openParenColumn = -1; + + var depth = 1; + for (var lineIndex = closeParenLineIndex; lineIndex >= 0; lineIndex--) + { + var line = structuralLines[lineIndex]; + var columnStart = lineIndex == closeParenLineIndex ? Math.Min(closeParenColumn - 1, line.Length - 1) : line.Length - 1; + for (var column = columnStart; column >= 0; column--) + { + switch (line[column]) + { + case ')': + depth++; + break; + case '(': + depth--; + if (depth == 0) + { + openParenLineIndex = lineIndex; + openParenColumn = column; + return true; + } + + break; + } + } + } + + return false; + } + + private static string GetCSharpTextBetween( + IReadOnlyList structuralLines, + int startLineIndex, + int startColumn, + int endLineIndex, + int endColumn) + { + if (startLineIndex == endLineIndex) + { + var line = structuralLines[startLineIndex]; + var segmentStart = Math.Max(0, startColumn); + var segmentEnd = Math.Min(endColumn, line.Length); + return segmentStart < segmentEnd ? line.Substring(segmentStart, segmentEnd - segmentStart) : string.Empty; + } + + var capacity = endLineIndex - startLineIndex; + for (var lineIndex = startLineIndex; lineIndex <= endLineIndex; lineIndex++) + { + var line = structuralLines[lineIndex]; + var segmentStart = lineIndex == startLineIndex ? Math.Max(0, startColumn) : 0; + var segmentEnd = lineIndex == endLineIndex ? Math.Min(endColumn, line.Length) : line.Length; + if (segmentStart < segmentEnd) + capacity += segmentEnd - segmentStart; + } + + var builder = new System.Text.StringBuilder(capacity); + for (var lineIndex = startLineIndex; lineIndex <= endLineIndex; lineIndex++) + { + var line = structuralLines[lineIndex]; + var segmentStart = lineIndex == startLineIndex ? Math.Max(0, startColumn) : 0; + var segmentEnd = lineIndex == endLineIndex ? Math.Min(endColumn, line.Length) : line.Length; + if (segmentStart < segmentEnd) + builder.Append(line, segmentStart, segmentEnd - segmentStart); + if (lineIndex < endLineIndex) + builder.Append('\n'); + } + + return builder.ToString(); + } + + private static bool LooksLikeCSharpQueryGenericTypeArgumentClose( + IReadOnlyList structuralLines, + int bodyEndIndex, + int closeLineIndex, + int closeColumn) + { + if (closeLineIndex < 0 || closeLineIndex >= structuralLines.Count) + return false; + + var angleDepth = 1; + for (var lineIndex = closeLineIndex; lineIndex >= 0; lineIndex--) + { + var line = structuralLines[lineIndex]; + var columnStart = lineIndex == closeLineIndex ? Math.Min(closeColumn - 1, line.Length - 1) : line.Length - 1; + for (var column = columnStart; column >= 0; column--) + { + var current = line[column]; + switch (current) + { + case '>': + angleDepth++; + break; + case '<': + angleDepth--; + if (angleDepth == 0) + return LooksLikeCSharpQueryGenericTypeArgumentStart(structuralLines, bodyEndIndex, lineIndex, column); + break; + } + } + } + + return false; + } + + private static bool TryFindMatchingCSharpDelimiter( + IReadOnlyList structuralLines, + int bodyEndIndex, + int startLineIndex, + int startColumn, + char open, + char close, + out CSharpLineColumn match) + { + var depth = 0; + for (var lineIndex = startLineIndex; lineIndex <= bodyEndIndex; lineIndex++) + { + var line = structuralLines[lineIndex]; + var columnStart = lineIndex == startLineIndex ? Math.Min(startColumn, line.Length) : 0; + for (var column = columnStart; column < line.Length; column++) + { + var current = line[column]; + if (current == open) + { + depth++; + } + else if (current == close && depth > 0) + { + depth--; + if (depth == 0) + { + match = new CSharpLineColumn(lineIndex + 1, column); + return true; + } + } + } + } + + match = new CSharpLineColumn(bodyEndIndex + 1, 0); + return false; + } + + private static bool LooksLikeCSharpQueryGenericTypeArgumentStart( + IReadOnlyList structuralLines, + int bodyEndIndex, + int startLineIndex, + int startColumn) + { + var line = structuralLines[startLineIndex]; + if (startColumn < 0 || startColumn >= line.Length || line[startColumn] != '<') + return false; + if (HasCSharpQueryGenericOperatorOnRight(line, startColumn + 1)) + return false; + if (!HasCSharpQueryGenericReceiverOnLeft(line, startColumn - 1)) + return false; + + var angleDepth = 1; + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + for (var lineIndex = startLineIndex; lineIndex <= bodyEndIndex; lineIndex++) + { + var currentLine = structuralLines[lineIndex]; + var columnStart = lineIndex == startLineIndex ? startColumn + 1 : 0; + for (var column = columnStart; column < currentLine.Length; column++) + { + var current = currentLine[column]; + switch (current) + { + case '<': + angleDepth++; + break; + case '>': + angleDepth--; + if (angleDepth == 0) + return HasCSharpQueryGenericSuffix(structuralLines, bodyEndIndex, lineIndex, column + 1); + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth == 0) + return false; + parenDepth--; + break; + case '[': + bracketDepth++; + break; + case ']': + if (bracketDepth == 0) + return false; + bracketDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth == 0) + return false; + braceDepth--; + break; + case ';': + if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) + return false; + break; + } + } + } + + return false; + } + + private static bool HasCSharpQueryGenericOperatorOnRight(string line, int index) + { + while (index < line.Length && char.IsWhiteSpace(line[index])) + index++; + if (index >= line.Length) + return false; + + return line[index] is '<' or '='; + } + + private static bool HasCSharpQueryGenericReceiverOnLeft(string line, int index) + { + while (index >= 0 && char.IsWhiteSpace(line[index])) + index--; + if (index < 0) + return false; + + var current = line[index]; + return IsCSharpIdentifierPart(current) || current is '>' or ']' or ')'; + } + + private static bool HasCSharpQueryGenericSuffix( + IReadOnlyList structuralLines, + int bodyEndIndex, + int startLineIndex, + int startColumn) + { + for (var lineIndex = startLineIndex; lineIndex <= bodyEndIndex; lineIndex++) + { + var line = structuralLines[lineIndex]; + var columnStart = lineIndex == startLineIndex ? startColumn : 0; + for (var column = columnStart; column < line.Length; column++) + { + var current = line[column]; + if (char.IsWhiteSpace(current)) + continue; + + if (current is '(' or ')' or ']' or '[' or '.' or ',' or ';' or '{' or ':' or '?' + || IsCSharpIdentifierStart(current)) + { + return true; + } + + return IsCSharpQueryGenericComparisonOperator(line, column); + } + } + + return true; + } + + private static bool IsCSharpQueryGenericComparisonOperator(string line, int column) + { + if (column < 0 || column + 1 >= line.Length) + return false; + + var current = line[column]; + return (current is '!' or '=') && line[column + 1] == '='; + } + + private static CSharpLineColumn FindCSharpArrowExpressionScopeEndPosition(string bodyText, int arrowIndex, int startLineNumber, int fallbackScopeEndLine) + { + var foundContent = false; + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + for (var i = Math.Min(arrowIndex + 2, bodyText.Length); i < bodyText.Length; i++) + { + var current = bodyText[i]; + if (!foundContent) + { + if (char.IsWhiteSpace(current)) + continue; + + foundContent = true; + } + + switch (current) + { + case '(': + parenDepth++; + break; + case ')': + if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) + return GetLineColumnFromOffset(bodyText, i, startLineNumber); + if (parenDepth > 0) + parenDepth--; + break; + case '[': + bracketDepth++; + break; + case ']': + if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) + return GetLineColumnFromOffset(bodyText, i, startLineNumber); + if (bracketDepth > 0) + bracketDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth == 0 && parenDepth == 0 && bracketDepth == 0) + return GetLineColumnFromOffset(bodyText, i + 1, startLineNumber); + if (braceDepth > 0) + { + braceDepth--; + if (braceDepth == 0 && parenDepth == 0 && bracketDepth == 0) + return GetLineColumnFromOffset(bodyText, i + 1, startLineNumber); + } + break; + case ',': + case ';': + if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) + return GetLineColumnFromOffset(bodyText, i, startLineNumber); + break; + } + } + + return new CSharpLineColumn(fallbackScopeEndLine, int.MaxValue); + } + + private static bool IsCSharpConditionalOperatorQuestionMark(string bodyText, int index) + { + if (index < 0 || index >= bodyText.Length || bodyText[index] != '?') + return false; + + var previous = index > 0 ? bodyText[index - 1] : '\0'; + var next = index + 1 < bodyText.Length ? bodyText[index + 1] : '\0'; + return previous != '?' + && next is not '?' and not '.' and not '['; + } + + private static void GetCSharpDelimiterDepthsAtOffset( + string bodyText, + int offset, + out int parenDepth, + out int bracketDepth, + out int braceDepth) + { + parenDepth = 0; + bracketDepth = 0; + braceDepth = 0; + var limit = Math.Min(offset, bodyText.Length); + for (var i = 0; i < limit; i++) + { + switch (bodyText[i]) + { + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) + parenDepth--; + break; + case '[': + bracketDepth++; + break; + case ']': + if (bracketDepth > 0) + bracketDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) + braceDepth--; + break; + } + } + } + + private static int GetTextOffsetFromLineColumn(string bodyText, int startLineNumber, CSharpLineColumn position) + { + if (string.IsNullOrEmpty(bodyText)) + return 0; + + if (position.Line <= startLineNumber) + return Math.Max(0, Math.Min(position.Column, bodyText.Length)); + + var currentLineNumber = startLineNumber; + var lineStartOffset = 0; + while (lineStartOffset < bodyText.Length && currentLineNumber < position.Line) + { + var newlineIndex = bodyText.IndexOf('\n', lineStartOffset); + if (newlineIndex < 0) + return bodyText.Length; + + currentLineNumber++; + lineStartOffset = newlineIndex + 1; + } + + var lineEndOffset = bodyText.IndexOf('\n', lineStartOffset); + if (lineEndOffset < 0) + lineEndOffset = bodyText.Length; + + return Math.Min(lineStartOffset + Math.Max(position.Column, 0), lineEndOffset); + } + + private static bool IsPotentialCSharpLambdaArrow(string bodyText, int arrowIndex) + { + var leftIndex = SkipWhitespaceBackward(bodyText, arrowIndex - 1); + if (leftIndex < 0) + return false; + + if (bodyText[leftIndex] == ')') + { + if (!TryFindMatchingOpenParen(bodyText, leftIndex, out var openParenIndex)) + return false; + + var parenPrefixIndex = SkipWhitespaceBackward(bodyText, openParenIndex - 1); + if (parenPrefixIndex < 0) + return true; + + var parenPrefixChar = bodyText[parenPrefixIndex]; + if (parenPrefixChar is '.' or ']' or ')') + return false; + + if (IsCSharpIdentifierPart(parenPrefixChar)) + { + var parenIdentifierStart = parenPrefixIndex; + while (parenIdentifierStart >= 0 && IsCSharpIdentifierPart(bodyText[parenIdentifierStart])) + parenIdentifierStart--; + parenIdentifierStart++; + + var identifierPrefixIndex = SkipWhitespaceBackward(bodyText, parenIdentifierStart - 1); + if (identifierPrefixIndex < 0) + return true; + + var identifierPrefixChar = bodyText[identifierPrefixIndex]; + if (identifierPrefixChar == '.') + return false; + + if (IsCSharpIdentifierPart(identifierPrefixChar)) + { + if (!TryReadPreviousIdentifierToken(bodyText, identifierPrefixIndex, out var identifierPreviousToken)) + return false; + + var normalizedPreviousToken = NormalizeCSharpIdentifier(identifierPreviousToken); + return normalizedPreviousToken is not ("when" or "is" or "as" or "and" or "or" or "not" + or "return" or "throw" or "new" or "case" or "else" or "do"); + } + + return identifierPrefixChar is '>' or ']' or ')' or '?' or ':' or '='; + } + + return parenPrefixChar is '=' or '(' or ',' or ':'; + } + + var identifierEnd = leftIndex + 1; + var identifierStart = leftIndex; + while (identifierStart >= 0 && IsCSharpIdentifierPart(bodyText[identifierStart])) + identifierStart--; + identifierStart++; + if (identifierStart >= identifierEnd || !IsCSharpIdentifierStart(bodyText[identifierStart])) + return false; + + var prefixIndex = SkipWhitespaceBackward(bodyText, identifierStart - 1); + if (prefixIndex < 0) + return false; + + var prefixChar = bodyText[prefixIndex]; + return prefixChar is '=' or '(' or ',' or ':' + || (TryReadPreviousIdentifierToken(bodyText, prefixIndex, out var previousToken) + && (string.Equals(previousToken, "return", StringComparison.Ordinal) + || string.Equals(previousToken, "static", StringComparison.Ordinal) + || string.Equals(previousToken, "async", StringComparison.Ordinal))); + } + + private static int GetLineStartOffset(string text, int offset) + { + var lineStart = Math.Min(offset, text.Length); + while (lineStart > 0 && text[lineStart - 1] != '\n') + lineStart--; + return lineStart; + } + + private static CSharpLineColumn GetLineColumnFromOffset(string text, int offset, int startLineNumber) + { + var lineNumber = startLineNumber; + var column = 0; + var limit = Math.Min(offset, text.Length); + for (var i = 0; i < limit; i++) + { + if (text[i] == '\n') + { + lineNumber++; + column = 0; + } + else + { + column++; + } + } + + return new CSharpLineColumn(lineNumber, column); + } + + private static int SkipWhitespaceBackward(string text, int index) + { + while (index >= 0 && char.IsWhiteSpace(text[index])) + index--; + return index; + } + + private static bool TryFindMatchingOpenParen(string text, int closeParenIndex, out int openParenIndex) + { + openParenIndex = -1; + var depth = 0; + for (var i = closeParenIndex; i >= 0; i--) + { + if (text[i] == ')') + { + depth++; + } + else if (text[i] == '(') + { + depth--; + if (depth == 0) + { + openParenIndex = i; + return true; + } + } + } + + return false; + } + + private static bool TryReadPreviousIdentifierToken(string text, int index, out string token) + { + token = string.Empty; + var end = index; + while (end >= 0 && !IsCSharpIdentifierPart(text[end])) + end--; + if (end < 0) + return false; + + var start = end; + while (start >= 0 && IsCSharpIdentifierPart(text[start])) + start--; + start++; + if (start > end) + return false; + + token = text[start..(end + 1)]; + return token.Length > 0; + } + + private static bool IsStaticCSharpSymbol(SymbolRecord? symbol) => + symbol?.Signature != null && CSharpStaticModifierRegex.IsMatch(symbol.Signature); + + private static string GetFirstQualifiedSegment(string qualifiedName) + { + if (string.IsNullOrWhiteSpace(qualifiedName)) + return string.Empty; + + var firstDot = qualifiedName.IndexOf('.'); + return firstDot < 0 ? qualifiedName : qualifiedName[..firstDot]; + } + + private static bool MatchesQualifiedConstantContainer( + string qualifier, + IReadOnlyList<(string ContainerName, string? QualifiedContainerName, bool AllowShortNameFallback)> targets, + bool allowShortNameFallback = true, + bool allowSingleSegmentQualifiedMatch = false) + { + var hasMultipleQualifierSegments = qualifier.Contains('.') || qualifier.Contains("::", StringComparison.Ordinal); + foreach (var (containerName, qualifiedContainerName, targetAllowsShortNameFallback) in targets) + { + if (!string.IsNullOrWhiteSpace(qualifiedContainerName) + && ((hasMultipleQualifierSegments && QualifiedNameHasSuffix(qualifiedContainerName!, qualifier)) + || (!hasMultipleQualifierSegments + && allowSingleSegmentQualifiedMatch + && string.Equals(qualifiedContainerName, qualifier, StringComparison.Ordinal)))) + { + return true; + } + + if (allowShortNameFallback + && targetAllowsShortNameFallback + && string.Equals(GetLastQualifiedSegment(qualifier), containerName, StringComparison.Ordinal)) + return true; + } + + return false; + } + + private static bool QualifiedNameHasSuffix(string fullName, string suffix) + { + if (string.IsNullOrWhiteSpace(fullName) || string.IsNullOrWhiteSpace(suffix)) + return false; + if (string.Equals(fullName, suffix, StringComparison.Ordinal)) + return true; + if (suffix.Length >= fullName.Length) + return false; + + var start = fullName.Length - suffix.Length; + return string.Compare(fullName, start, suffix, 0, suffix.Length, StringComparison.Ordinal) == 0 + && fullName[start - 1] == '.'; + } + + private static string GetLastQualifiedSegment(string qualifiedName) + { + if (string.IsNullOrWhiteSpace(qualifiedName)) + return string.Empty; + + var lastDot = qualifiedName.LastIndexOf('.'); + var lastColon = qualifiedName.LastIndexOf("::", StringComparison.Ordinal); + var split = Math.Max(lastDot, lastColon); + return split < 0 ? qualifiedName : qualifiedName[(split + (split == lastColon ? 2 : 1))..]; + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs index ff7cf713b..fe1a5a1cb 100644 --- a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs @@ -647,1666 +647,4 @@ private static bool IsCSharpConstMemberSymbol(SymbolRecord symbol) || symbol.Signature.StartsWith("const ", StringComparison.Ordinal); } - internal static void EmitCSharpQualifiedEnumMemberReferences( - string preparedLine, - IReadOnlyDictionary> enumMemberLookup, - IReadOnlyList<(int start, int end)>? csharpAttrRangesOnLine, - IReadOnlyList usingAliases, - Func> getValueReceiverNamesByContainingType, - Func>> getValueReceiverNamesByFunctionStartLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForCall) - { - var scan = 0; - while (scan < preparedLine.Length) - { - if (!TryReadCSharpQualifiedAccess(preparedLine, scan, out var parsed)) - { - scan++; - continue; - } - - scan = Math.Max(scan + 1, parsed.NextIndex); - if (!parsed.LastSeparatorWasDot || parsed.Segments.Count < 2) - continue; - - var member = parsed.Segments[^1]; - var memberName = preparedLine.Substring(member.Start, member.End - member.Start); - if (!enumMemberLookup.TryGetValue(memberName, out var targets)) - continue; - - var callContainer = resolveContainerForCall(member.Start); - var qualifier = TrimLeadingCSharpGlobalQualifier(NormalizeCSharpQualifiedSegments(preparedLine, parsed.Segments, parsed.Segments.Count - 1)); - var resolvedQualifier = parsed.HasLeadingGlobalQualifier - ? qualifier - : ResolveCSharpQualifiedAliasTarget(qualifier, lineNumber, usingAliases); - if (!parsed.HasLeadingGlobalQualifier - && HasCSharpValueReceiverConflict( - qualifier, - resolvedQualifier, - lineNumber, - member.Start, - callContainer, - getValueReceiverNamesByContainingType(), - getValueReceiverNamesByFunctionStartLine())) - { - continue; - } - if (!MatchesQualifiedConstantContainer( - resolvedQualifier, - targets, - allowShortNameFallback: !parsed.HasLeadingGlobalQualifier, - allowSingleSegmentQualifiedMatch: parsed.HasLeadingGlobalQualifier)) - continue; - - if (IsCSharpQualifiedConstantPatternReferenceSite(preparedLine, parsed)) - continue; - - var nextTokenIndex = SkipWhitespace(preparedLine, member.End); - if (nextTokenIndex < preparedLine.Length && preparedLine[nextTokenIndex] == '(') - continue; - - var insideCSharpAttributeRange = csharpAttrRangesOnLine != null - && IsInsideCSharpAttributeRange(csharpAttrRangesOnLine, member.Start); - var referenceKind = TryClassifyMetadataReference("csharp", preparedLine, member.Start, insideCSharpAttributeRange) ?? "call"; - - AddReference( - references, - seen, - fileId, - memberName, - member.Start, - referenceKind, - context, - lineNumber, - callContainer); - } - } - - private static bool IsCSharpQualifiedConstantPatternReferenceSite( - string preparedLine, - (IReadOnlyList<(int Start, int End)> Segments, int NextIndex, bool LastSeparatorWasDot, bool HasLeadingGlobalQualifier) parsed) - { - if (!parsed.LastSeparatorWasDot || parsed.Segments.Count < 2) - return false; - - var headCursor = parsed.Segments[0].Start; - if (parsed.HasLeadingGlobalQualifier - && headCursor >= "global::".Length - && preparedLine.AsSpan(headCursor - "global::".Length, "global::".Length).Equals("global::", StringComparison.Ordinal)) - { - headCursor -= "global::".Length; - } - - return IsCSharpConstantPatternAnchor(preparedLine, ref headCursor); - } - - private static bool IsCSharpConstantPatternAnchor(string text, ref int cursor) - { - cursor = SkipCSharpTriviaBackward(text, cursor); - if (TryConsumeTrailingCSharpToken(text, ref cursor, "not")) - cursor = SkipCSharpTriviaBackward(text, cursor); - - while (true) - { - if (TryConsumeTrailingCSharpToken(text, ref cursor, "case")) - return true; - - if (TryConsumeTrailingCSharpToken(text, ref cursor, "is")) - return false; - - if (!TryConsumeTrailingCSharpToken(text, ref cursor, "or") - && !TryConsumeTrailingCSharpToken(text, ref cursor, "and")) - { - return false; - } - - cursor = SkipCSharpTriviaBackward(text, cursor); - if (!SkipCSharpPatternHeadBackward(text, ref cursor)) - return false; - cursor = SkipCSharpTriviaBackward(text, cursor); - if (TryConsumeTrailingCSharpToken(text, ref cursor, "not")) - cursor = SkipCSharpTriviaBackward(text, cursor); - } - } - - private static int SkipCSharpTriviaBackward(string text, int cursor) - { - while (cursor > 0) - { - if (char.IsWhiteSpace(text[cursor - 1])) - { - cursor--; - continue; - } - - if (cursor >= 2 - && text[cursor - 1] == '/' - && text[cursor - 2] == '*') - { - var commentStart = text.LastIndexOf("/*", cursor - 2, StringComparison.Ordinal); - if (commentStart >= 0) - { - cursor = commentStart; - continue; - } - } - - break; - } - - return cursor; - } - - internal static bool IsCSharpPatternHeadCallSite(string[] preparedLines, int lineIndex, string preparedLine, int nameIndex) - { - var whenOffset = FindTopLevelCSharpWhenKeywordOffset(preparedLine); - if (whenOffset >= 0 && nameIndex > whenOffset) - return false; - - var cursor = nameIndex; - if (IsCSharpConstantPatternAnchor(preparedLine, ref cursor)) - return true; - - cursor = nameIndex; - cursor = SkipCSharpTriviaBackward(preparedLine, cursor); - if (TryConsumeTrailingCSharpToken(preparedLine, ref cursor, "not")) - cursor = SkipCSharpTriviaBackward(preparedLine, cursor); - - if (TryConsumeTrailingCSharpToken(preparedLine, ref cursor, "is")) - return true; - - for (var previous = lineIndex - 1; previous >= 0; previous--) - { - var previousLine = preparedLines[previous]; - if (string.IsNullOrWhiteSpace(previousLine)) - continue; - - if (LineEndsWithCSharpToken(previousLine, "case") - || LineEndsWithCSharpToken(previousLine, "is") - || LineEndsWithCSharpToken(previousLine, "not")) - { - return true; - } - - break; - } - - // Switch-expression arms (`Point(...) => ...`) do not have a `case` / `is` anchor, - // so the same positional pattern suppression has to look for the trailing arrow. - if (IsCSharpSwitchExpressionPatternHead(preparedLines, lineIndex, preparedLine, nameIndex)) - return true; - - return false; - } - - private static bool IsCSharpSwitchExpressionPatternHead(string[] preparedLines, int lineIndex, string preparedLine, int nameIndex) - { - var cursor = nameIndex; - while (cursor < preparedLine.Length && IsCSharpIdentifierPart(preparedLine[cursor])) - cursor++; - - cursor = SkipCSharpTriviaForward(preparedLine, cursor); - - var openParenIndex = preparedLine.IndexOf('(', cursor); - if (openParenIndex < 0) - return false; - - var parenDepth = 0; - for (var i = openParenIndex; i < preparedLine.Length; i++) - { - switch (preparedLine[i]) - { - case '(': - parenDepth++; - break; - case ')': - parenDepth--; - if (parenDepth == 0) - { - var afterClose = SkipCSharpTriviaForward(preparedLine, i + 1); - if (afterClose + 1 < preparedLine.Length - && preparedLine[afterClose] == '=' - && preparedLine[afterClose + 1] == '>') - { - return true; - } - - for (var next = lineIndex + 1; next < preparedLines.Length; next++) - { - var nextLine = preparedLines[next]; - if (string.IsNullOrWhiteSpace(nextLine)) - continue; - - var nextCursor = SkipCSharpTriviaForward(nextLine, 0); - return nextCursor + 1 < nextLine.Length - && nextLine[nextCursor] == '=' - && nextLine[nextCursor + 1] == '>'; - } - - return false; - } - break; - } - } - - return false; - } - - private static bool LineEndsWithCSharpToken(string text, string token) - { - var cursor = text.Length; - return TryConsumeTrailingCSharpToken(text, ref cursor, token); - } - - private static bool TryConsumeTrailingCSharpToken(string text, ref int cursor, string token) - { - if (string.IsNullOrEmpty(token)) - return false; - - cursor = SkipCSharpTriviaBackward(text, cursor); - if (cursor < token.Length) - return false; - - var tokenStart = cursor - token.Length; - if (!text.AsSpan(tokenStart, token.Length).Equals(token, StringComparison.Ordinal)) - return false; - - if ((tokenStart > 0 && IsCSharpIdentifierPart(text[tokenStart - 1])) - || (cursor < text.Length && IsCSharpIdentifierPart(text[cursor]))) - { - return false; - } - - cursor = tokenStart; - return true; - } - - private static bool SkipCSharpPatternHeadBackward(string text, ref int cursor) - { - if (!TryConsumeTrailingCSharpIdentifier(text, ref cursor)) - return false; - - while (true) - { - cursor = SkipCSharpTriviaBackward(text, cursor); - if (cursor >= 2 - && text[cursor - 2] == ':' - && text[cursor - 1] == ':') - { - cursor -= 2; - } - else if (cursor > 0 && text[cursor - 1] == '.') - { - cursor--; - } - else - { - break; - } - - cursor = SkipCSharpTriviaBackward(text, cursor); - if (!TryConsumeTrailingCSharpIdentifier(text, ref cursor)) - return false; - } - - return true; - } - - private static bool TryConsumeTrailingCSharpIdentifier(string text, ref int cursor) - { - var end = cursor; - while (cursor > 0 && IsCSharpIdentifierPart(text[cursor - 1])) - cursor--; - - if (cursor == end) - return false; - - if (cursor > 0 && text[cursor - 1] == '@') - cursor--; - - return true; - } - - private static bool TryReadCSharpQualifiedAccess( - string preparedLine, - int start, - out (IReadOnlyList<(int Start, int End)> Segments, int NextIndex, bool LastSeparatorWasDot, bool HasLeadingGlobalQualifier) parsed) - { - parsed = (Array.Empty<(int Start, int End)>(), start, false, false); - - if (start > 0 && IsCSharpIdentifierPart(preparedLine[start - 1])) - return false; - if (start >= preparedLine.Length || !IsCSharpIdentifierStart(preparedLine[start])) - return false; - - var segments = new List<(int Start, int End)>(); - var cursor = start; - var lastSeparatorWasDot = false; - var hasLeadingGlobalQualifier = false; - while (true) - { - if (!TryConsumeCSharpIdentifier(preparedLine, ref cursor, out var segmentStart, out var segmentEnd)) - return false; - - segments.Add((segmentStart, segmentEnd)); - - var separatorStart = SkipWhitespace(preparedLine, cursor); - if (separatorStart + 1 < preparedLine.Length - && preparedLine[separatorStart] == ':' - && preparedLine[separatorStart + 1] == ':') - { - if (segments.Count == 1 - && segmentEnd - segmentStart == "global".Length - && string.CompareOrdinal(preparedLine, segmentStart, "global", 0, "global".Length) == 0) - { - hasLeadingGlobalQualifier = true; - } - - cursor = SkipWhitespace(preparedLine, separatorStart + 2); - lastSeparatorWasDot = false; - continue; - } - - if (separatorStart < preparedLine.Length && preparedLine[separatorStart] == '.') - { - cursor = SkipWhitespace(preparedLine, separatorStart + 1); - lastSeparatorWasDot = true; - continue; - } - - parsed = (segments, cursor, lastSeparatorWasDot, hasLeadingGlobalQualifier); - return true; - } - } - - private static bool TryConsumeCSharpIdentifier( - string preparedLine, - ref int cursor, - out int start, - out int end) - { - start = cursor; - if (cursor >= preparedLine.Length || !IsCSharpIdentifierStart(preparedLine[cursor])) - { - end = cursor; - return false; - } - - cursor++; - while (cursor < preparedLine.Length && IsCSharpIdentifierPart(preparedLine[cursor])) - cursor++; - - end = cursor; - return true; - } - - private static bool TryConsumeCSharpPatternKeyword(string preparedLine, ref int cursor, string keyword) - { - if (!preparedLine.AsSpan(cursor).StartsWith(keyword, StringComparison.Ordinal)) - return false; - - int afterKeyword = cursor + keyword.Length; - if (afterKeyword < preparedLine.Length && !char.IsWhiteSpace(preparedLine[afterKeyword])) - return false; - - cursor = afterKeyword; - return true; - } - - private static bool IsCSharpCaseTypePatternContinuation( - string preparedLine, - string typeExpression, - int cursor, - IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, - IReadOnlyDictionary> csharpQualifiedTypePatternLookup, - IReadOnlyList csharpUsingAliases, - IReadOnlyList csharpUsingStatics, - Func hasActiveSameFileCSharpTypeCandidate, - int lineNumber) - { - if (IsCSharpNonTypePatternExpression(typeExpression)) - return false; - - if (cursor >= preparedLine.Length) - return false; - - return preparedLine[cursor] switch - { - ':' => !IsCSharpConstantPatternMemberHead( - typeExpression, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate), - '{' or '(' or '[' => true, - _ => IsCSharpCaseTypePatternIdentifier( - preparedLine, - typeExpression, - cursor, - csharpQualifiedConstantPatternMemberLookup, - csharpQualifiedTypePatternLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate, - lineNumber) - }; - } - - private static bool IsCSharpCaseTypePatternIdentifier( - string preparedLine, - string typeExpression, - int cursor, - IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, - IReadOnlyDictionary> csharpQualifiedTypePatternLookup, - IReadOnlyList csharpUsingAliases, - IReadOnlyList csharpUsingStatics, - Func hasActiveSameFileCSharpTypeCandidate, - int lineNumber) - { - int tokenCursor = cursor; - if (!TryConsumeCSharpIdentifier(preparedLine, ref tokenCursor, out var start, out var end)) - return false; - - var rawToken = preparedLine[start..end]; - if (rawToken.Length > 0 && rawToken[0] == '@') - return true; - - return rawToken switch - { - "when" => !IsCSharpConstantPatternMemberHead( - typeExpression, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate), - "or" or "and" => !IsCSharpLogicalConstantPatternHead( - preparedLine, - typeExpression, - tokenCursor, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpQualifiedTypePatternLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate), - _ => true, - }; - } - - private static bool TryEmitCSharpLogicalTypePatternHeads( - string preparedLine, - string initialTypeExpression, - int initialTypeIndex, - int continuationIndex, - int lineNumber, - IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, - IReadOnlyDictionary> csharpQualifiedTypePatternLookup, - IReadOnlyList csharpUsingAliases, - IReadOnlyList csharpUsingStatics, - Func hasActiveSameFileCSharpTypeCandidate, - Action emitTypeExpression) - { - var currentTypeExpression = initialTypeExpression; - var currentTypeIndex = initialTypeIndex; - var currentContinuationIndex = continuationIndex; - var sawLogicalKeyword = false; - var emittedAny = false; - while (TryConsumeCSharpLogicalPatternKeyword(preparedLine, currentContinuationIndex, out var nextHeadCursor)) - { - sawLogicalKeyword = true; - if (!IsCSharpLogicalConstantPatternHead( - preparedLine, - currentTypeExpression, - nextHeadCursor, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpQualifiedTypePatternLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate)) - { - emitTypeExpression(currentTypeExpression, currentTypeIndex); - emittedAny = true; - } - - int nextTypeCursor = nextHeadCursor; - if (TryConsumeCSharpPatternKeyword(preparedLine, ref nextTypeCursor, "not")) - nextTypeCursor = SkipWhitespace(preparedLine, nextTypeCursor); - - var nextMatch = CSharpTypeExpressionAtCursorRegex.Match(preparedLine, nextTypeCursor); - if (!nextMatch.Success) - return false; - - var nextTypeGroup = nextMatch.Groups["type"]; - currentTypeExpression = nextTypeGroup.Value; - currentTypeIndex = nextTypeGroup.Index; - currentContinuationIndex = SkipWhitespace(preparedLine, nextTypeGroup.Index + nextTypeGroup.Length); - } - - if (sawLogicalKeyword - && !IsCSharpNonTypePatternExpression(currentTypeExpression) - && !IsCSharpConstantPatternMemberHead( - currentTypeExpression, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate)) - { - emitTypeExpression(currentTypeExpression, currentTypeIndex); - emittedAny = true; - } - - return emittedAny; - } - - private static bool IsCSharpLogicalConstantPatternAtCursor( - string preparedLine, - string typeExpression, - int cursor, - int lineNumber, - IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, - IReadOnlyDictionary> csharpQualifiedTypePatternLookup, - IReadOnlyList csharpUsingAliases, - IReadOnlyList csharpUsingStatics, - Func hasActiveSameFileCSharpTypeCandidate) - { - int tokenCursor = cursor; - if (!TryConsumeCSharpIdentifier(preparedLine, ref tokenCursor, out var start, out var end)) - return false; - - var rawToken = preparedLine[start..end]; - if (rawToken is not ("or" or "and")) - return false; - - return IsCSharpLogicalConstantPatternHead( - preparedLine, - typeExpression, - tokenCursor, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpQualifiedTypePatternLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate); - } - - private static bool TryConsumeCSharpLogicalPatternKeyword( - string preparedLine, - int cursor, - out int nextHeadCursor) - { - nextHeadCursor = cursor; - int tokenCursor = cursor; - if (!TryConsumeCSharpIdentifier(preparedLine, ref tokenCursor, out var start, out var end)) - return false; - - var rawToken = preparedLine[start..end]; - if (rawToken is not ("or" or "and")) - return false; - - nextHeadCursor = SkipWhitespace(preparedLine, tokenCursor); - return true; - } - - private static bool IsCSharpLogicalConstantPatternHead( - string preparedLine, - string typeExpression, - int cursor, - int lineNumber, - IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, - IReadOnlyDictionary> csharpQualifiedTypePatternLookup, - IReadOnlyList csharpUsingAliases, - IReadOnlyList csharpUsingStatics, - Func hasActiveSameFileCSharpTypeCandidate) - { - if (IsCSharpConstantPatternMemberHead( - typeExpression, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - hasActiveSameFileCSharpTypeCandidate)) - { - return true; - } - - if (IsCSharpQualifiedTypePatternHead( - typeExpression, - lineNumber, - csharpQualifiedTypePatternLookup, - csharpUsingAliases)) - { - return false; - } - - if (!TryReadCSharpQualifiedAccess(typeExpression, 0, out var currentParsed) - || !currentParsed.LastSeparatorWasDot - || currentParsed.Segments.Count < 2) - { - return false; - } - - var currentQualifier = ResolveCSharpQualifiedConstantPatternQualifier(typeExpression, currentParsed, lineNumber, csharpUsingAliases); - if (string.IsNullOrWhiteSpace(currentQualifier)) - return false; - - int nextCursor = SkipWhitespace(preparedLine, cursor); - if (TryConsumeCSharpPatternKeyword(preparedLine, ref nextCursor, "not")) - nextCursor = SkipWhitespace(preparedLine, nextCursor); - - var nextMatch = CSharpTypeExpressionAtCursorRegex.Match(preparedLine, nextCursor); - if (!nextMatch.Success) - return false; - - var nextTypeExpression = nextMatch.Groups["type"].Value; - if (IsCSharpQualifiedTypePatternHead( - nextTypeExpression, - lineNumber, - csharpQualifiedTypePatternLookup, - csharpUsingAliases)) - { - return false; - } - - if (!TryReadCSharpQualifiedAccess(nextTypeExpression, 0, out var nextParsed) - || !nextParsed.LastSeparatorWasDot - || nextParsed.Segments.Count < 2) - { - return false; - } - - var nextQualifier = ResolveCSharpQualifiedConstantPatternQualifier(nextTypeExpression, nextParsed, lineNumber, csharpUsingAliases); - return string.Equals(currentQualifier, nextQualifier, StringComparison.Ordinal); - } - - private static bool IsCSharpQualifiedTypePatternHead( - string typeExpression, - int lineNumber, - IReadOnlyDictionary> csharpQualifiedTypePatternLookup, - IReadOnlyList csharpUsingAliases) - { - if (!TryReadCSharpQualifiedAccess(typeExpression, 0, out var parsed) - || !parsed.LastSeparatorWasDot - || parsed.Segments.Count < 2) - { - return false; - } - - var member = parsed.Segments[^1]; - var memberName = typeExpression.Substring(member.Start, member.End - member.Start); - if (!csharpQualifiedTypePatternLookup.TryGetValue(memberName, out var targets)) - return false; - - var resolvedQualifier = ResolveCSharpQualifiedConstantPatternQualifier(typeExpression, parsed, lineNumber, csharpUsingAliases); - bool qualifierHasMultipleSegments = resolvedQualifier.Contains('.') || resolvedQualifier.Contains("::", StringComparison.Ordinal); - return MatchesQualifiedConstantContainer( - resolvedQualifier, - targets, - allowShortNameFallback: !parsed.HasLeadingGlobalQualifier && !qualifierHasMultipleSegments, - allowSingleSegmentQualifiedMatch: parsed.HasLeadingGlobalQualifier); - } - - private static string ResolveCSharpQualifiedConstantPatternQualifier( - string typeExpression, - (IReadOnlyList<(int Start, int End)> Segments, int NextIndex, bool LastSeparatorWasDot, bool HasLeadingGlobalQualifier) parsed, - int lineNumber, - IReadOnlyList csharpUsingAliases) - { - var qualifier = TrimLeadingCSharpGlobalQualifier(NormalizeCSharpQualifiedSegments(typeExpression, parsed.Segments, parsed.Segments.Count - 1)); - return parsed.HasLeadingGlobalQualifier - ? qualifier - : ResolveCSharpQualifiedAliasTarget(qualifier, lineNumber, csharpUsingAliases); - } - - private static bool IsCSharpQualifiedConstantPatternMemberHead( - string typeExpression, - int lineNumber, - IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, - IReadOnlyList csharpUsingAliases) - { - if (!TryReadCSharpQualifiedAccess(typeExpression, 0, out var parsed) - || !parsed.LastSeparatorWasDot - || parsed.Segments.Count < 2) - { - return false; - } - - var member = parsed.Segments[^1]; - var memberName = typeExpression.Substring(member.Start, member.End - member.Start); - if (!csharpQualifiedConstantPatternMemberLookup.TryGetValue(memberName, out var targets)) - return false; - - var resolvedQualifier = ResolveCSharpQualifiedConstantPatternQualifier(typeExpression, parsed, lineNumber, csharpUsingAliases); - bool qualifierHasMultipleSegments = resolvedQualifier.Contains('.') || resolvedQualifier.Contains("::", StringComparison.Ordinal); - return MatchesQualifiedConstantContainer( - resolvedQualifier, - targets, - allowShortNameFallback: !parsed.HasLeadingGlobalQualifier && !qualifierHasMultipleSegments, - allowSingleSegmentQualifiedMatch: parsed.HasLeadingGlobalQualifier); - } - - private static bool IsCSharpConstantPatternMemberHead( - string typeExpression, - int lineNumber, - IReadOnlyDictionary> csharpQualifiedConstantPatternMemberLookup, - IReadOnlyList csharpUsingAliases, - IReadOnlyList csharpUsingStatics, - Func hasActiveSameFileCSharpTypeCandidate) - { - return IsCSharpQualifiedConstantPatternMemberHead( - typeExpression, - lineNumber, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases); - } - - private static bool IsCSharpNonTypePatternExpression(string typeExpression) - { - var trimmed = typeExpression.Trim(); - if (trimmed.Length == 0) - return false; - - if (trimmed[0] == '@') - return false; - - return trimmed.IndexOf('.') < 0 - && trimmed.IndexOf(':') < 0 - && trimmed.IndexOf('<') < 0 - && trimmed.IndexOf('[') < 0 - && trimmed.IndexOf('?') < 0 - && trimmed.IndexOf(' ') < 0 - && CSharpNonTypePatternTokens.Contains(trimmed); - } - - private static int SkipWhitespace(string text, int index) - { - while (index < text.Length && char.IsWhiteSpace(text[index])) - index++; - return index; - } - - private static string NormalizeCSharpIdentifier(string identifier) => - !string.IsNullOrEmpty(identifier) && identifier[0] == '@' - ? identifier[1..] - : identifier; - - private static string NormalizeAtPrefixedIdentifier(string identifier) => - !string.IsNullOrEmpty(identifier) && identifier[0] == '@' - ? identifier[1..] - : identifier; - - private static string NormalizeCSharpQualifiedSegments( - string preparedLine, - IReadOnlyList<(int Start, int End)> segments, - int count) - { - var capacity = Math.Max(0, count - 1); - for (var i = 0; i < count; i++) - { - var (start, end) = segments[i]; - var length = end - start; - if (length > 0 && preparedLine[start] == '@') - length--; - capacity += length; - } - - var builder = new StringBuilder(capacity); - for (var i = 0; i < count; i++) - { - if (i > 0) - builder.Append('.'); - var (start, end) = segments[i]; - var length = end - start; - if (length > 0 && preparedLine[start] == '@') - { - start++; - length--; - } - - builder.Append(preparedLine, start, length); - } - return builder.ToString(); - } - - private static string TrimLeadingCSharpGlobalQualifier(string qualifiedName) => - qualifiedName.StartsWith("global.", StringComparison.Ordinal) - ? qualifiedName["global.".Length..] - : qualifiedName; - - private static string? TryNormalizeCSharpQualifiedName(string candidate) - { - var trimmed = candidate.Trim(); - if (trimmed.StartsWith("global::", StringComparison.Ordinal)) - trimmed = trimmed["global::".Length..]; - if (string.IsNullOrWhiteSpace(trimmed)) - return null; - if (!TryReadCSharpQualifiedAccess(trimmed, 0, out var parsed)) - return null; - if (SkipWhitespace(trimmed, parsed.NextIndex) != trimmed.Length) - return null; - return NormalizeCSharpQualifiedSegments(trimmed, parsed.Segments, parsed.Segments.Count); - } - - private static string ResolveCSharpQualifiedAliasTarget(string qualifier, int lineNumber, IReadOnlyList usingAliases) - { - if (string.IsNullOrWhiteSpace(qualifier) || usingAliases.Count == 0) - return qualifier; - - var firstSegment = GetFirstQualifiedSegment(qualifier); - string? aliasTarget = null; - for (var i = usingAliases.Count - 1; i >= 0; i--) - { - var alias = usingAliases[i]; - if (alias.Line > lineNumber) - continue; - if (lineNumber < alias.ScopeStartLine || lineNumber > alias.ScopeEndLine) - continue; - if (!string.Equals(alias.AliasName, firstSegment, StringComparison.Ordinal)) - continue; - - aliasTarget = alias.TargetQualifiedName; - break; - } - - if (aliasTarget == null) - return qualifier; - - return qualifier.Length == firstSegment.Length - ? aliasTarget - : aliasTarget + qualifier[firstSegment.Length..]; - } - - private static bool TryGetCSharpXmlDocCommentSpan( - string line, - bool inDelimitedDocComment, - bool inOrdinaryBlockComment, - out int commentStartIndex, - out int commentEndExclusive, - out bool nextDelimitedDocComment) - { - commentStartIndex = 0; - commentEndExclusive = 0; - nextDelimitedDocComment = inDelimitedDocComment; - if (string.IsNullOrWhiteSpace(line)) - { - commentEndExclusive = inDelimitedDocComment ? line.Length : 0; - return inDelimitedDocComment; - } - - var firstNonWhitespaceIndex = 0; - while (firstNonWhitespaceIndex < line.Length && char.IsWhiteSpace(line[firstNonWhitespaceIndex])) - firstNonWhitespaceIndex++; - - if (inDelimitedDocComment) - { - var closeIndex = line.IndexOf("*/", StringComparison.Ordinal); - nextDelimitedDocComment = closeIndex < 0; - commentStartIndex = 0; - commentEndExclusive = closeIndex < 0 ? line.Length : closeIndex; - return true; - } - - if (inOrdinaryBlockComment) - return false; - - if (line.AsSpan(firstNonWhitespaceIndex).StartsWith("///", StringComparison.Ordinal)) - { - if (line.Length != firstNonWhitespaceIndex + 3 && line[firstNonWhitespaceIndex + 3] == '/') - return false; - - commentStartIndex = firstNonWhitespaceIndex; - commentEndExclusive = line.Length; - return true; - } - - if (!line.AsSpan(firstNonWhitespaceIndex).StartsWith("/**", StringComparison.Ordinal)) - return false; - - var closeAfterOpenIndex = line.IndexOf("*/", firstNonWhitespaceIndex + 3, StringComparison.Ordinal); - nextDelimitedDocComment = closeAfterOpenIndex < 0; - commentStartIndex = firstNonWhitespaceIndex; - commentEndExclusive = closeAfterOpenIndex < 0 ? line.Length : closeAfterOpenIndex; - return true; - } - - private static bool HasCSharpValueReceiverConflict( - string qualifier, - string resolvedQualifier, - int lineNumber, - int column, - SymbolRecord? callContainer, - IReadOnlyDictionary valueReceiverNamesByContainingType, - IReadOnlyDictionary> valueReceiverNamesByFunctionStartLine) - { - if (string.IsNullOrWhiteSpace(qualifier) - || (valueReceiverNamesByContainingType.Count == 0 && valueReceiverNamesByFunctionStartLine.Count == 0)) - return false; - if (!string.Equals(qualifier, resolvedQualifier, StringComparison.Ordinal)) - return false; - - var receiverName = GetFirstQualifiedSegment(qualifier); - if (string.IsNullOrWhiteSpace(receiverName)) - return false; - - if (callContainer != null - && (callContainer.Kind == "function" || callContainer.Kind == "property") - && valueReceiverNamesByFunctionStartLine.TryGetValue(callContainer.StartLine, out var functionNames) - && HasCSharpFunctionValueReceiverName(functionNames, receiverName, lineNumber, column)) - { - return true; - } - - var containingType = GetContainingTypeQualifiedName(callContainer); - return containingType != null - && valueReceiverNamesByContainingType.TryGetValue(containingType, out var names) - && (IsStaticCSharpSymbol(callContainer) - ? names.StaticNames.Contains(receiverName) - : names.StaticNames.Contains(receiverName) || names.InstanceNames.Contains(receiverName)); - } - - private static string? GetContainingTypeQualifiedName(SymbolRecord? symbol) - { - if (symbol == null) - return null; - if (IsTypeLikeSymbolKind(symbol.Kind)) - return CombineQualifiedName(symbol.ContainerQualifiedName, symbol.Name); - return symbol.ContainerQualifiedName; - } - - private static bool IsTypeLikeSymbolKind(string? kind) => - kind is "class" or "struct" or "interface"; - - private static string? CombineQualifiedName(string? parentQualifiedName, string? name) - { - if (string.IsNullOrWhiteSpace(name)) - return null; - if (string.IsNullOrWhiteSpace(parentQualifiedName)) - return name; - return $"{parentQualifiedName}.{name}"; - } - - private static bool IsWithinCSharpScope(CSharpFunctionValueReceiverNameRecord record, int lineNumber, int column) - { - var startsBefore = lineNumber > record.ScopeStartLine - || (lineNumber == record.ScopeStartLine && column >= record.ScopeStartColumn); - if (!startsBefore) - return false; - - return lineNumber < record.ScopeEndLine - || (lineNumber == record.ScopeEndLine && column < record.ScopeEndColumn); - } - - private static void AddCSharpParameterNames( - List names, - string? signature, - int scopeStartLine, - int scopeStartColumn, - int scopeEndLine, - int scopeEndColumn, - HashSet? seenNames = null) - { - if (string.IsNullOrWhiteSpace(signature)) - return; - - var openParen = signature.IndexOf('('); - var closeParen = signature.LastIndexOf(')'); - if (openParen < 0 || closeParen <= openParen) - return; - - var parameters = signature[(openParen + 1)..closeParen]; - if (string.IsNullOrWhiteSpace(parameters)) - return; - - AddTopLevelCSharpParameterNames( - names, - parameters.AsSpan(), - scopeStartLine, - scopeStartColumn, - scopeEndLine, - scopeEndColumn, - seenNames); - } - - private static void AddTopLevelCSharpParameterNames( - List names, - ReadOnlySpan parameters, - int scopeStartLine, - int scopeStartColumn, - int scopeEndLine, - int scopeEndColumn, - HashSet? seenNames) - { - var depthAngle = 0; - var depthParen = 0; - var depthBracket = 0; - var depthBrace = 0; - var segmentStart = 0; - - for (var i = 0; i < parameters.Length; i++) - { - var ch = parameters[i]; - switch (ch) - { - case '<': - depthAngle++; - break; - case '>': - if (depthAngle > 0) - depthAngle--; - break; - case '(': - depthParen++; - break; - case ')': - if (depthParen > 0) - depthParen--; - break; - case '[': - depthBracket++; - break; - case ']': - if (depthBracket > 0) - depthBracket--; - break; - case '{': - depthBrace++; - break; - case '}': - if (depthBrace > 0) - depthBrace--; - break; - case ',': - if (depthAngle == 0 && depthParen == 0 && depthBracket == 0 && depthBrace == 0) - { - AddCSharpParameterSegmentName( - names, - parameters[segmentStart..i], - scopeStartLine, - scopeStartColumn, - scopeEndLine, - scopeEndColumn, - seenNames); - segmentStart = i + 1; - } - break; - } - } - - if (segmentStart <= parameters.Length) - AddCSharpParameterSegmentName( - names, - parameters[segmentStart..], - scopeStartLine, - scopeStartColumn, - scopeEndLine, - scopeEndColumn, - seenNames); - } - - private static void AddCSharpParameterSegmentName( - List names, - ReadOnlySpan segment, - int scopeStartLine, - int scopeStartColumn, - int scopeEndLine, - int scopeEndColumn, - HashSet? seenNames) - { - if (TryExtractTrailingCSharpParameterName(segment, out var name)) - AddCSharpFunctionValueReceiverName(names, name, scopeStartLine, scopeStartColumn, scopeEndLine, scopeEndColumn, seenNames); - } - - private static bool TryExtractTrailingCSharpParameterName(ReadOnlySpan segment, out string name) - { - name = string.Empty; - var trimmed = segment.Trim(); - if (trimmed.Length == 0 || trimmed.Equals("this".AsSpan(), StringComparison.Ordinal)) - return false; - - var end = trimmed.Length - 1; - while (end >= 0 && char.IsWhiteSpace(trimmed[end])) - end--; - while (end >= 0 && (trimmed[end] == '?' || trimmed[end] == '!')) - end--; - var start = end; - while (start >= 0 && IsCSharpIdentifierPart(trimmed[start])) - start--; - if (end < 0 || start >= end) - return false; - - name = NormalizeCSharpIdentifier(trimmed[(start + 1)..(end + 1)].ToString()); - return !string.IsNullOrWhiteSpace(name); - } - - private static void AddCSharpLambdaParameterNames( - List names, - string bodyText, - int startLineNumber, - int scopeEndLine, - HashSet? seenNames = null) - { - if (string.IsNullOrWhiteSpace(bodyText)) - return; - - var searchIndex = 0; - while (searchIndex < bodyText.Length) - { - var arrowIndex = bodyText.IndexOf("=>", searchIndex, StringComparison.Ordinal); - if (arrowIndex < 0) - break; - - var lambdaScopeEnd = FindCSharpArrowExpressionScopeEndPosition(bodyText, arrowIndex, startLineNumber, scopeEndLine); - AddCSharpLambdaParametersBeforeArrow(names, bodyText, arrowIndex, startLineNumber, lambdaScopeEnd, seenNames); - searchIndex = arrowIndex + 2; - } - } - - private static void AddCSharpRecursivePatternValueReceiverNames( - List names, - string bodyText, - IReadOnlyList structuralLines, - int bodyStartIndex, - int bodyEndIndex, - HashSet? seenNames = null) - { - if (string.IsNullOrWhiteSpace(bodyText)) - return; - - var startLineNumber = bodyStartIndex + 1; - foreach (var pattern in FindCSharpRecursivePatternValueNames(bodyText)) - { - var position = GetLineColumnFromOffset(bodyText, pattern.Offset, startLineNumber); - var declarationLineIndex = position.Line - 1; - if (pattern.ArrowIndex >= 0) - { - var scopeEnd = FindCSharpArrowExpressionScopeEndPosition(bodyText, pattern.ArrowIndex, startLineNumber, bodyEndIndex + 1); - AddCSharpFunctionValueReceiverName(names, pattern.Name, position.Line, position.Column, scopeEnd.Line, scopeEnd.Column, seenNames); - continue; - } - - if (pattern.IsCasePattern) - { - if (!TryFindCSharpSwitchCaseScopeEndPosition(structuralLines, bodyEndIndex, declarationLineIndex, position.Column, out var scopeEnd)) - continue; - - AddCSharpFunctionValueReceiverName(names, pattern.Name, position.Line, position.Column, scopeEnd.Line, scopeEnd.Column, seenNames); - continue; - } - - if (!TryFindCSharpDeclarationPatternScopeEndPosition(structuralLines, bodyStartIndex, bodyEndIndex, declarationLineIndex, position.Column, out var declarationScopeEnd)) - continue; - - AddCSharpFunctionValueReceiverName(names, pattern.Name, position.Line, position.Column, declarationScopeEnd.Line, declarationScopeEnd.Column, seenNames); - } - } - - private static IEnumerable FindCSharpRecursivePatternValueNames(string bodyText) - { - for (var index = 0; index < bodyText.Length; index++) - { - if (!IsCSharpIdentifierStart(bodyText[index])) - continue; - - var tokenStart = index; - index++; - while (index < bodyText.Length && IsCSharpIdentifierPart(bodyText[index])) - index++; - - var token = bodyText[tokenStart..index]; - if ((string.Equals(token, "is", StringComparison.Ordinal) || string.Equals(token, "case", StringComparison.Ordinal)) - && TryParseCSharpRecursivePatternDesignation(bodyText, index, string.Equals(token, "case", StringComparison.Ordinal), out var name, out var designationOffset)) - { - yield return new CSharpRecursivePatternValueNameRecord(name, designationOffset, string.Equals(token, "case", StringComparison.Ordinal)); - } - - index--; - } - - foreach (var pattern in FindCSharpSwitchExpressionPatternValueNames(bodyText)) - yield return pattern; - } - - private static IEnumerable FindCSharpSwitchExpressionPatternValueNames(string bodyText) - { - if (string.IsNullOrWhiteSpace(bodyText)) - yield break; - - for (var searchIndex = 0; searchIndex < bodyText.Length;) - { - var arrowIndex = bodyText.IndexOf("=>", searchIndex, StringComparison.Ordinal); - if (arrowIndex < 0) - yield break; - - searchIndex = arrowIndex + 2; - if (IsPotentialCSharpLambdaArrow(bodyText, arrowIndex)) - continue; - - if (!TryFindCSharpSwitchExpressionArmStartOffset(bodyText, arrowIndex, out var armStartOffset)) - continue; - - if (!TryParseCSharpSwitchExpressionArmPatternDesignation(bodyText, armStartOffset, arrowIndex, out var name, out var designationOffset)) - continue; - - yield return new CSharpRecursivePatternValueNameRecord(name, designationOffset, false, arrowIndex); - } - } - - private static bool TryFindCSharpSwitchExpressionArmStartOffset(string bodyText, int arrowIndex, out int armStartOffset) - { - armStartOffset = 0; - if (arrowIndex <= 0 || arrowIndex > bodyText.Length) - return false; - - var parenDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - for (var index = arrowIndex - 1; index >= 0; index--) - { - var current = bodyText[index]; - switch (current) - { - case ')': - parenDepth++; - break; - case '(': - if (parenDepth > 0) - parenDepth--; - break; - case ']': - bracketDepth++; - break; - case '[': - if (bracketDepth > 0) - bracketDepth--; - break; - case '}': - braceDepth++; - break; - case '{': - if (braceDepth > 0) - { - braceDepth--; - break; - } - - if (parenDepth == 0 && bracketDepth == 0) - { - armStartOffset = SkipWhitespaceForward(bodyText, index + 1); - return armStartOffset < arrowIndex; - } - - break; - case ',': - if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) - { - armStartOffset = SkipWhitespaceForward(bodyText, index + 1); - return armStartOffset < arrowIndex; - } - - break; - case ';': - if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) - return false; - break; - } - } - - return false; - } - - private static bool TryGetCSharpSwitchExpressionArmTypePatternRange( - string bodyText, - int arrowIndex, - out int bodyStartOffset, - out int armStartOffset, - out int armPatternEndOffset) - { - bodyStartOffset = 0; - armStartOffset = 0; - armPatternEndOffset = 0; - if (!TryFindCSharpSwitchExpressionBodyStartOffset(bodyText, arrowIndex, out bodyStartOffset)) - return false; - - var segmentStartOffset = bodyStartOffset + 1; - if (segmentStartOffset >= arrowIndex) - return false; - - var segmentText = bodyText[segmentStartOffset..arrowIndex]; - var lastCommaOffset = FindLastTopLevelCSharpComma(segmentText); - var relativeArmStart = lastCommaOffset >= 0 - ? SkipWhitespaceForward(segmentText, lastCommaOffset + 1) - : SkipWhitespaceForward(segmentText, 0); - if (relativeArmStart >= segmentText.Length) - return false; - - var armSegment = segmentText[relativeArmStart..]; - var whenOffset = FindTopLevelCSharpWhenKeywordOffset(armSegment); - var relativePatternEnd = whenOffset >= 0 - ? relativeArmStart + whenOffset - : segmentText.Length; - while (relativePatternEnd > relativeArmStart && char.IsWhiteSpace(segmentText[relativePatternEnd - 1])) - relativePatternEnd--; - if (relativePatternEnd <= relativeArmStart) - return false; - - armStartOffset = segmentStartOffset + relativeArmStart; - armPatternEndOffset = segmentStartOffset + relativePatternEnd; - return armStartOffset < armPatternEndOffset; - } - - private static bool TryFindCSharpSwitchExpressionBodyStartOffset(string bodyText, int arrowIndex, out int bodyStartOffset) - { - bodyStartOffset = -1; - if (arrowIndex <= 0 || arrowIndex > bodyText.Length) - return false; - - var parenDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - for (var index = arrowIndex - 1; index >= 0; index--) - { - var current = bodyText[index]; - switch (current) - { - case ')': - parenDepth++; - break; - case '(': - if (parenDepth > 0) - parenDepth--; - break; - case ']': - bracketDepth++; - break; - case '[': - if (bracketDepth > 0) - bracketDepth--; - break; - case '}': - braceDepth++; - break; - case '{': - if (braceDepth > 0) - { - braceDepth--; - break; - } - - if (parenDepth == 0 && bracketDepth == 0) - { - bodyStartOffset = index; - return true; - } - - break; - case ';': - if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) - return false; - break; - } - } - - return false; - } - - private static int FindLastTopLevelCSharpComma(string text) - { - var angleDepth = 0; - var parenDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - var lastComma = -1; - for (var i = 0; i < text.Length; i++) - { - switch (text[i]) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) - angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) - parenDepth--; - break; - case '[': - bracketDepth++; - break; - case ']': - if (bracketDepth > 0) - bracketDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) - braceDepth--; - break; - case ',': - if (angleDepth == 0 && parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) - lastComma = i; - break; - } - } - - return lastComma; - } - - private static bool TryParseCSharpSwitchExpressionArmPatternDesignation( - string bodyText, - int armStartOffset, - int arrowIndex, - out string name, - out int designationOffset) - { - name = string.Empty; - designationOffset = -1; - if (armStartOffset < 0 || armStartOffset >= arrowIndex || arrowIndex > bodyText.Length) - return false; - - var preparedArmLines = StructuralLineMasker.MaskLines( - "csharp", - SplitCSharpSwitchExpressionArmLines(bodyText, armStartOffset, arrowIndex)); - for (var i = 0; i < preparedArmLines.Length; i++) - preparedArmLines[i] = PrepareLine("csharp", preparedArmLines[i]); - - var preparedArmText = string.Join("\n", preparedArmLines); - if (!TryParseCSharpRecursivePatternDesignation(preparedArmText, 0, false, out name, out var relativeOffset) - && !TryParseCSharpSwitchExpressionArmDeclarationPatternDesignation(preparedArmText, out name, out relativeOffset)) - { - return false; - } - - designationOffset = armStartOffset + relativeOffset; - return designationOffset < arrowIndex; - } - - private static string[] SplitCSharpSwitchExpressionArmLines(string bodyText, int startOffset, int endOffset) - { - var length = endOffset - startOffset; - var firstLineBreak = bodyText.IndexOf('\n', startOffset, length); - if (firstLineBreak < 0) - return [bodyText[startOffset..endOffset]]; - - var lineCount = 2; - for (var i = firstLineBreak + 1; i < endOffset; i++) - { - if (bodyText[i] == '\n') - lineCount++; - } - - var lines = new string[lineCount]; - var lineStart = startOffset; - var lineIndex = 0; - for (var i = startOffset; i < endOffset; i++) - { - if (bodyText[i] != '\n') - continue; - - lines[lineIndex++] = bodyText[lineStart..i]; - lineStart = i + 1; - } - - lines[lineIndex] = bodyText[lineStart..endOffset]; - return lines; - } - - private static bool TryParseCSharpSwitchExpressionArmDeclarationPatternDesignation( - string armText, - out string name, - out int designationOffset) - { - name = string.Empty; - designationOffset = -1; - if (string.IsNullOrWhiteSpace(armText)) - return false; - - var whenOffset = FindTopLevelCSharpWhenKeywordOffset(armText); - var patternText = whenOffset >= 0 ? armText[..whenOffset] : armText; - var match = CSharpSwitchExpressionDeclarationPatternValueNameRegex.Match(patternText); - if (!match.Success) - return false; - - name = NormalizeCSharpIdentifier(match.Groups["name"].Value); - designationOffset = match.Groups["name"].Index; - return designationOffset >= 0; - } - - private static bool TryParseCSharpRecursivePatternDesignation( - string bodyText, - int index, - bool isCasePattern, - out string name, - out int designationOffset) - { - name = string.Empty; - designationOffset = -1; - var parenDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - var sawRecursiveClause = false; - var previousTopLevelNonWhitespaceChar = '\0'; - for (var i = index; i < bodyText.Length; i++) - { - var current = bodyText[i]; - if (char.IsWhiteSpace(current)) - continue; - - if (braceDepth == 0 && parenDepth == 0 && bracketDepth == 0 && IsCSharpIdentifierStart(current)) - { - var tokenStart = i; - i++; - while (i < bodyText.Length && IsCSharpIdentifierPart(bodyText[i])) - i++; - - var token = bodyText[tokenStart..i]; - i--; - if (sawRecursiveClause - && previousTopLevelNonWhitespaceChar is not '.' and not ':' and not '<' and not '[' and not '?' - && !IsCSharpPatternControlKeyword(token)) - { - name = NormalizeCSharpIdentifier(token); - designationOffset = tokenStart; - return true; - } - - previousTopLevelNonWhitespaceChar = token[^1]; - continue; - } - - switch (current) - { - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) - parenDepth--; - break; - case '[': - bracketDepth++; - break; - case ']': - if (bracketDepth > 0) - bracketDepth--; - break; - case '{': - braceDepth++; - sawRecursiveClause = true; - break; - case '}': - if (braceDepth > 0) - braceDepth--; - break; - } - - if (braceDepth == 0 && parenDepth == 0 && bracketDepth == 0) - previousTopLevelNonWhitespaceChar = current; - } - - return false; - } - - private static bool IsCSharpPatternControlKeyword(string token) => - token is "and" or "or" or "not" or "when" or "null" or "true" or "false"; - - private static int FindTopLevelCSharpWhenKeywordOffset(string text) - { - var parenDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - for (var i = 0; i < text.Length; i++) - { - var current = text[i]; - switch (current) - { - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) - parenDepth--; - break; - case '[': - bracketDepth++; - break; - case ']': - if (bracketDepth > 0) - bracketDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) - braceDepth--; - break; - } - - if (parenDepth == 0 - && bracketDepth == 0 - && braceDepth == 0 - && TryConsumeCSharpKeyword(text, i, "when", out _)) - { - return i; - } - } - - return -1; - } - } diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.ValueReceivers.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.ValueReceivers.cs index 187c4604f..1a1731d03 100644 --- a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.ValueReceivers.cs +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.ValueReceivers.cs @@ -932,1410 +932,4 @@ private static bool LooksLikeCSharpCastCloseParen( return previousPunctuationToken is not (')' or ']' or '}' or '"' or '\'' or '>'); } - private static bool IsCSharpCastPrefixIdentifier(string line, int tokenStartColumn, string token) - { - if (tokenStartColumn > 0 && line[tokenStartColumn - 1] == '@') - return false; - - return string.Equals(token, "return", StringComparison.Ordinal) - || string.Equals(token, "await", StringComparison.Ordinal) - || string.Equals(token, "throw", StringComparison.Ordinal) - || IsCSharpQueryClauseKeyword(token); - } - - private static bool LooksLikeCSharpCastTypeText( - string text, - int lineNumber, - int column, - IReadOnlySet csharpKnownTypeNames, - IReadOnlyList csharpUsingAliases, - IReadOnlyList csharpFunctionValueReceiverNames) - { - var trimmed = text.Trim(); - if (trimmed.Length == 0) - return false; - - var index = 0; - if (!TryConsumeCSharpCastType(trimmed, ref index)) - return false; - - SkipCSharpCastTypeWhitespace(trimmed, ref index); - if (index != trimmed.Length) - return false; - - var shape = AnalyzeCSharpCastTypeShape(trimmed); - if (shape.IdentifierSegments.Count == 0) - return shape.HasTypeOnlySyntax; - - var resolvedQualifiedName = shape.SimpleQualifiedName == null - ? null - : ResolveCSharpQualifiedAliasTarget(shape.SimpleQualifiedName, lineNumber, csharpUsingAliases); - var resolvedBareName = resolvedQualifiedName == null - ? null - : ExtractBareTypeName(resolvedQualifiedName); - - var lastSegment = shape.IdentifierSegments[^1]; - if (HasKnownNonTerminalTypeSegment(shape.IdentifierSegments, csharpKnownTypeNames) - && !IsKnownCSharpCastTypeName(lastSegment, resolvedBareName, csharpKnownTypeNames)) - { - return false; - } - - if (IsKnownCSharpCastTypeName(lastSegment, resolvedBareName, csharpKnownTypeNames) - || (!string.IsNullOrWhiteSpace(resolvedQualifiedName) && csharpKnownTypeNames.Contains(resolvedQualifiedName))) - { - return true; - } - - if (shape.SimpleQualifiedName != null - && string.Equals(shape.SimpleQualifiedName, resolvedQualifiedName, StringComparison.Ordinal) - && HasCSharpFunctionValueReceiverConflict( - GetFirstQualifiedSegment(shape.SimpleQualifiedName), - lineNumber, - column, - csharpFunctionValueReceiverNames)) - { - return false; - } - - if (shape.HasTypeOnlySyntax) - return true; - - return shape.AllIdentifiersTypeLike && shape.IdentifierSegments.Count <= 2; - } - - private static bool TryConsumeCSharpCastType(string text, ref int index) - { - if (!TryConsumeCSharpCastTypeCore(text, ref index)) - return false; - - while (true) - { - var checkpoint = index; - SkipCSharpCastTypeWhitespace(text, ref index); - if (TryConsumeCSharpCastArraySuffix(text, ref index) - || TryConsumeCSharpCastNullableSuffix(text, ref index)) - { - continue; - } - - index = checkpoint; - return true; - } - } - - private static bool TryConsumeCSharpCastTypeCore(string text, ref int index) - { - SkipCSharpCastTypeWhitespace(text, ref index); - if (index < text.Length && text[index] == '(') - return TryConsumeCSharpCastTupleType(text, ref index); - - return TryConsumeCSharpCastQualifiedType(text, ref index); - } - - private static bool TryConsumeCSharpCastQualifiedType(string text, ref int index) - { - if (!TryConsumeCSharpCastIdentifier(text, ref index, out var token)) - return false; - - if (!TryConsumeCSharpCastGenericArgumentList(text, ref index)) - return false; - - while (true) - { - var checkpoint = index; - SkipCSharpCastTypeWhitespace(text, ref index); - if (!TryConsumeCSharpCastQualifiedTypeSeparator(text, ref index)) - { - index = checkpoint; - return true; - } - - if (!TryConsumeCSharpCastIdentifier(text, ref index, out token)) - return false; - - if (!TryConsumeCSharpCastGenericArgumentList(text, ref index)) - return false; - } - } - - private static bool TryConsumeCSharpCastTupleType(string text, ref int index) - { - if (index >= text.Length || text[index] != '(') - return false; - - index++; - while (true) - { - if (!TryConsumeCSharpCastType(text, ref index)) - return false; - - var checkpoint = index; - if (TryConsumeCSharpCastIdentifier(text, ref index, out _)) - { - // Tuple element names are optional and do not affect type-likeness. - } - else - { - index = checkpoint; - } - - SkipCSharpCastTypeWhitespace(text, ref index); - if (index >= text.Length) - return false; - - if (text[index] == ')') - { - index++; - return true; - } - - if (text[index] != ',') - return false; - - index++; - } - } - - private static bool TryConsumeCSharpCastGenericArgumentList(string text, ref int index) - { - var checkpoint = index; - SkipCSharpCastTypeWhitespace(text, ref index); - if (index >= text.Length || text[index] != '<') - { - index = checkpoint; - return true; - } - - index++; - while (true) - { - if (!TryConsumeCSharpCastType(text, ref index)) - return false; - - SkipCSharpCastTypeWhitespace(text, ref index); - if (index >= text.Length) - return false; - - if (text[index] == '>') - { - index++; - return true; - } - - if (text[index] != ',') - return false; - - index++; - } - } - - private static bool TryConsumeCSharpCastArraySuffix(string text, ref int index) - { - if (index >= text.Length || text[index] != '[') - return false; - - index++; - SkipCSharpCastTypeWhitespace(text, ref index); - while (index < text.Length && text[index] == ',') - { - index++; - SkipCSharpCastTypeWhitespace(text, ref index); - } - - if (index >= text.Length || text[index] != ']') - return false; - - index++; - return true; - } - - private static bool TryConsumeCSharpCastNullableSuffix(string text, ref int index) - { - if (index >= text.Length || text[index] != '?') - return false; - - index++; - return true; - } - - private static bool TryConsumeCSharpCastQualifiedTypeSeparator(string text, ref int index) - { - if (index >= text.Length) - return false; - - if (text[index] == '.') - { - index++; - return true; - } - - if (index + 1 < text.Length && text[index] == ':' && text[index + 1] == ':') - { - index += 2; - return true; - } - - return false; - } - - private static bool TryConsumeCSharpCastIdentifier(string text, ref int index, out string token) - { - SkipCSharpCastTypeWhitespace(text, ref index); - token = string.Empty; - if (index >= text.Length) - return false; - - var start = index; - if (text[index] == '@') - { - index++; - if (index >= text.Length || !IsCSharpIdentifierStart(text[index])) - { - index = start; - return false; - } - } - else if (!IsCSharpIdentifierStart(text[index])) - { - return false; - } - - index++; - while (index < text.Length && IsCSharpIdentifierPart(text[index])) - index++; - - token = text.Substring(start, index - start); - return true; - } - - private static CSharpCastTypeShape AnalyzeCSharpCastTypeShape(string text) - { - var segments = new List(); - var simpleQualifiedName = new System.Text.StringBuilder(text.Length); - var hasTypeOnlySyntax = false; - var allIdentifiersTypeLike = true; - var simpleQualifiedCandidate = true; - - for (var index = 0; index < text.Length;) - { - var current = text[index]; - if (char.IsWhiteSpace(current)) - { - index++; - continue; - } - - if (current == '@' || IsCSharpIdentifierStart(current)) - { - var start = index; - if (current == '@') - index++; - if (index < text.Length) - index++; - while (index < text.Length && IsCSharpIdentifierPart(text[index])) - index++; - - var token = text.Substring(start, index - start); - segments.Add(token); - allIdentifiersTypeLike &= IsLikelyCSharpTypeIdentifier(token); - if (simpleQualifiedCandidate) - simpleQualifiedName.Append(token); - continue; - } - - switch (current) - { - case '.': - if (simpleQualifiedCandidate) - simpleQualifiedName.Append(current); - index++; - continue; - case ':': - if (index + 1 < text.Length && text[index + 1] == ':') - { - hasTypeOnlySyntax = true; - if (simpleQualifiedCandidate) - simpleQualifiedName.Append("::"); - index += 2; - continue; - } - - simpleQualifiedCandidate = false; - index++; - continue; - case '<': - case '[': - case '?': - case '(': - hasTypeOnlySyntax = true; - simpleQualifiedCandidate = false; - index++; - continue; - case '>': - case ']': - case ')': - case ',': - simpleQualifiedCandidate = false; - index++; - continue; - default: - simpleQualifiedCandidate = false; - index++; - continue; - } - } - - return new CSharpCastTypeShape( - segments, - simpleQualifiedCandidate && simpleQualifiedName.Length > 0 ? simpleQualifiedName.ToString() : null, - hasTypeOnlySyntax, - allIdentifiersTypeLike); - } - - private static bool HasKnownNonTerminalTypeSegment(IReadOnlyList segments, IReadOnlySet csharpKnownTypeNames) - { - for (var index = 0; index < segments.Count - 1; index++) - { - if (csharpKnownTypeNames.Contains(NormalizeCSharpIdentifier(segments[index]))) - return true; - } - - return false; - } - - private static bool IsKnownCSharpCastTypeName(string candidate, string? resolvedCandidate, IReadOnlySet csharpKnownTypeNames) - { - return csharpKnownTypeNames.Contains(NormalizeCSharpIdentifier(candidate)) - || (!string.IsNullOrWhiteSpace(resolvedCandidate) && csharpKnownTypeNames.Contains(NormalizeCSharpIdentifier(resolvedCandidate))); - } - - private static bool HasCSharpFunctionValueReceiverConflict( - string candidate, - int lineNumber, - int column, - IReadOnlyList csharpFunctionValueReceiverNames) - { - if (string.IsNullOrWhiteSpace(candidate) || csharpFunctionValueReceiverNames.Count == 0) - return false; - - var normalizedCandidate = NormalizeCSharpIdentifier(candidate); - return HasCSharpFunctionValueReceiverName(csharpFunctionValueReceiverNames, normalizedCandidate, lineNumber, column); - } - - private static bool HasCSharpFunctionValueReceiverName( - IReadOnlyList csharpFunctionValueReceiverNames, - string receiverName, - int lineNumber, - int column) - { - for (var index = 0; index < csharpFunctionValueReceiverNames.Count; index++) - { - var record = csharpFunctionValueReceiverNames[index]; - if (IsWithinCSharpScope(record, lineNumber, column) - && string.Equals(record.Name, receiverName, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - - private static bool IsLikelyCSharpTypeIdentifier(string token) - { - if (string.IsNullOrEmpty(token)) - return false; - - var normalized = token[0] == '@' ? token.Substring(1) : token; - if (normalized.Length == 0) - return false; - - return IsCSharpBuiltInTypeKeyword(normalized) - || char.IsUpper(normalized[0]); - } - - private static void SkipCSharpCastTypeWhitespace(string text, ref int index) - { - while (index < text.Length && char.IsWhiteSpace(text[index])) - index++; - } - - private static bool IsCSharpBuiltInTypeKeyword(string text) - { - return string.Equals(text, "bool", StringComparison.Ordinal) - || string.Equals(text, "byte", StringComparison.Ordinal) - || string.Equals(text, "sbyte", StringComparison.Ordinal) - || string.Equals(text, "short", StringComparison.Ordinal) - || string.Equals(text, "ushort", StringComparison.Ordinal) - || string.Equals(text, "int", StringComparison.Ordinal) - || string.Equals(text, "uint", StringComparison.Ordinal) - || string.Equals(text, "long", StringComparison.Ordinal) - || string.Equals(text, "ulong", StringComparison.Ordinal) - || string.Equals(text, "nint", StringComparison.Ordinal) - || string.Equals(text, "nuint", StringComparison.Ordinal) - || string.Equals(text, "char", StringComparison.Ordinal) - || string.Equals(text, "float", StringComparison.Ordinal) - || string.Equals(text, "double", StringComparison.Ordinal) - || string.Equals(text, "decimal", StringComparison.Ordinal) - || string.Equals(text, "string", StringComparison.Ordinal) - || string.Equals(text, "object", StringComparison.Ordinal) - || string.Equals(text, "dynamic", StringComparison.Ordinal); - } - - private static bool CanStartCSharpParenthesizedQueryClauseAfterPlusOrMinus( - IReadOnlyList structuralLines, - int bodyEndIndex, - int operatorLineIndex, - int operatorColumn, - int operatorEndColumn, - char operatorToken) - { - if (operatorLineIndex < 0 || operatorColumn < 0) - return false; - - if (!TryGetPreviousTopLevelToken( - structuralLines, - operatorLineIndex, - operatorColumn - 1, - out var previousTokenLineIndex, - out var previousTokenStartColumn, - out var previousTokenEndColumn, - out var previousIdentifierToken, - out var previousPunctuationToken)) - { - return false; - } - - if (!string.IsNullOrEmpty(previousIdentifierToken) - || previousPunctuationToken != operatorToken - || previousTokenLineIndex != operatorLineIndex - || previousTokenEndColumn != operatorEndColumn - 1) - { - return false; - } - - if (!TryGetPreviousTopLevelToken( - structuralLines, - previousTokenLineIndex, - previousTokenStartColumn - 1, - out var operandTokenLineIndex, - out var operandTokenStartColumn, - out _, - out var operandIdentifierToken, - out var operandPunctuationToken)) - { - return false; - } - - if (!string.IsNullOrEmpty(operandIdentifierToken)) - return true; - - return operandPunctuationToken switch - { - ')' or ']' or '}' or '"' or '\'' => true, - '>' => LooksLikeCSharpQueryGenericTypeArgumentClose( - structuralLines, - bodyEndIndex, - operandTokenLineIndex, - operandTokenStartColumn), - _ => false - }; - } - - private static bool CanStartCSharpParenthesizedQueryClauseAfterBang( - IReadOnlyList structuralLines, - int bodyEndIndex, - int bangLineIndex, - int bangColumn) - { - if (!TryGetPreviousTopLevelToken( - structuralLines, - bangLineIndex, - bangColumn - 1, - out var previousTokenLineIndex, - out var previousTokenStartColumn, - out _, - out var previousIdentifierToken, - out var previousPunctuationToken)) - { - return false; - } - - if (!string.IsNullOrEmpty(previousIdentifierToken)) - return !IsCSharpParenthesizedQueryClausePrefixIdentifier( - structuralLines[previousTokenLineIndex], - previousTokenStartColumn, - previousIdentifierToken); - - return previousPunctuationToken switch - { - ')' or ']' or '}' or '"' or '\'' => true, - '>' => LooksLikeCSharpQueryGenericTypeArgumentClose( - structuralLines, - bodyEndIndex, - previousTokenLineIndex, - previousTokenStartColumn), - _ => false - }; - } - - private static bool IsCSharpParenthesizedQueryClausePrefixIdentifier(string line, int tokenStartColumn, string token) - { - if (tokenStartColumn > 0 && line[tokenStartColumn - 1] == '@') - return false; - - return string.Equals(token, "await", StringComparison.Ordinal) - || string.Equals(token, "throw", StringComparison.Ordinal) - || IsCSharpQueryClauseKeyword(token); - } - - private static bool LooksLikeCSharpNullableTypeSuffixInCastOrTypeTest( - IReadOnlyList structuralLines, - int questionLineIndex, - int questionColumn) - { - var angleDepth = 0; - var bracketDepth = 0; - var parenDepth = 0; - var currentLineIndex = questionLineIndex; - var currentColumn = questionColumn - 1; - while (TryGetPreviousTopLevelToken( - structuralLines, - currentLineIndex, - currentColumn, - out var tokenLineIndex, - out var tokenStartColumn, - out _, - out var identifierToken, - out var punctuationToken)) - { - if (!string.IsNullOrEmpty(identifierToken)) - { - if (angleDepth == 0 - && bracketDepth == 0 - && parenDepth == 0 - && (string.Equals(identifierToken, "as", StringComparison.Ordinal) - || string.Equals(identifierToken, "is", StringComparison.Ordinal))) - { - return true; - } - - currentLineIndex = tokenLineIndex; - currentColumn = tokenStartColumn - 1; - continue; - } - - switch (punctuationToken) - { - case '.': - case '?': - currentLineIndex = tokenLineIndex; - currentColumn = tokenStartColumn - 1; - continue; - case ',': - if (angleDepth > 0 || bracketDepth > 0 || parenDepth > 0) - { - currentLineIndex = tokenLineIndex; - currentColumn = tokenStartColumn - 1; - continue; - } - - return false; - case '>': - angleDepth++; - currentLineIndex = tokenLineIndex; - currentColumn = tokenStartColumn - 1; - continue; - case '<': - if (angleDepth == 0) - return false; - - angleDepth--; - currentLineIndex = tokenLineIndex; - currentColumn = tokenStartColumn - 1; - continue; - case ']': - bracketDepth++; - currentLineIndex = tokenLineIndex; - currentColumn = tokenStartColumn - 1; - continue; - case '[': - if (bracketDepth == 0) - return false; - - bracketDepth--; - currentLineIndex = tokenLineIndex; - currentColumn = tokenStartColumn - 1; - continue; - case ')': - parenDepth++; - currentLineIndex = tokenLineIndex; - currentColumn = tokenStartColumn - 1; - continue; - case '(': - if (parenDepth == 0) - return false; - - parenDepth--; - currentLineIndex = tokenLineIndex; - currentColumn = tokenStartColumn - 1; - continue; - default: - return false; - } - } - - return false; - } - - private static bool TryGetPreviousTopLevelToken( - IReadOnlyList structuralLines, - int startLineIndex, - int startColumn, - out int tokenLineIndex, - out int tokenStartColumn, - out int tokenEndColumn, - out string identifierToken, - out char punctuationToken) - { - tokenLineIndex = -1; - tokenStartColumn = -1; - tokenEndColumn = -1; - identifierToken = string.Empty; - punctuationToken = '\0'; - - if (!TryGetPreviousTopLevelSignificantChar( - structuralLines, - startLineIndex, - startColumn, - out tokenLineIndex, - out tokenEndColumn, - out var tokenChar)) - { - return false; - } - - tokenStartColumn = tokenEndColumn; - if (IsCSharpIdentifierPart(tokenChar)) - { - var line = structuralLines[tokenLineIndex]; - while (tokenStartColumn > 0 && IsCSharpIdentifierPart(line[tokenStartColumn - 1])) - tokenStartColumn--; - - identifierToken = line.Substring(tokenStartColumn, tokenEndColumn - tokenStartColumn + 1); - } - else - { - punctuationToken = tokenChar; - } - - return true; - } - - private static bool TryGetPreviousTopLevelSignificantChar( - IReadOnlyList structuralLines, - int startLineIndex, - int startColumn, - out int lineIndex, - out int column, - out char value) - { - lineIndex = -1; - column = -1; - value = '\0'; - - if (structuralLines.Count == 0) - return false; - - var clampedLineIndex = Math.Min(startLineIndex, structuralLines.Count - 1); - for (var currentLineIndex = clampedLineIndex; currentLineIndex >= 0; currentLineIndex--) - { - var line = structuralLines[currentLineIndex]; - var currentColumn = currentLineIndex == clampedLineIndex - ? Math.Min(startColumn, line.Length - 1) - : line.Length - 1; - for (var probe = currentColumn; probe >= 0; probe--) - { - if (char.IsWhiteSpace(line[probe])) - continue; - - lineIndex = currentLineIndex; - column = probe; - value = line[probe]; - return true; - } - } - - return false; - } - - private static bool TryGetNextTopLevelSignificantChar( - IReadOnlyList structuralLines, - int startLineIndex, - int startColumn, - out int lineIndex, - out int column, - out char value) - { - lineIndex = -1; - column = -1; - value = '\0'; - - if (structuralLines.Count == 0) - return false; - - var clampedLineIndex = Math.Max(0, Math.Min(startLineIndex, structuralLines.Count - 1)); - for (var currentLineIndex = clampedLineIndex; currentLineIndex < structuralLines.Count; currentLineIndex++) - { - var line = structuralLines[currentLineIndex]; - var currentColumn = currentLineIndex == clampedLineIndex - ? Math.Max(0, startColumn) - : 0; - for (var probe = currentColumn; probe < line.Length; probe++) - { - if (char.IsWhiteSpace(line[probe])) - continue; - - lineIndex = currentLineIndex; - column = probe; - value = line[probe]; - return true; - } - } - - return false; - } - - private static bool TryFindMatchingCSharpOpenParenBackwards( - IReadOnlyList structuralLines, - int closeParenLineIndex, - int closeParenColumn, - out int openParenLineIndex, - out int openParenColumn) - { - openParenLineIndex = -1; - openParenColumn = -1; - - var depth = 1; - for (var lineIndex = closeParenLineIndex; lineIndex >= 0; lineIndex--) - { - var line = structuralLines[lineIndex]; - var columnStart = lineIndex == closeParenLineIndex ? Math.Min(closeParenColumn - 1, line.Length - 1) : line.Length - 1; - for (var column = columnStart; column >= 0; column--) - { - switch (line[column]) - { - case ')': - depth++; - break; - case '(': - depth--; - if (depth == 0) - { - openParenLineIndex = lineIndex; - openParenColumn = column; - return true; - } - - break; - } - } - } - - return false; - } - - private static string GetCSharpTextBetween( - IReadOnlyList structuralLines, - int startLineIndex, - int startColumn, - int endLineIndex, - int endColumn) - { - if (startLineIndex == endLineIndex) - { - var line = structuralLines[startLineIndex]; - var segmentStart = Math.Max(0, startColumn); - var segmentEnd = Math.Min(endColumn, line.Length); - return segmentStart < segmentEnd ? line.Substring(segmentStart, segmentEnd - segmentStart) : string.Empty; - } - - var capacity = endLineIndex - startLineIndex; - for (var lineIndex = startLineIndex; lineIndex <= endLineIndex; lineIndex++) - { - var line = structuralLines[lineIndex]; - var segmentStart = lineIndex == startLineIndex ? Math.Max(0, startColumn) : 0; - var segmentEnd = lineIndex == endLineIndex ? Math.Min(endColumn, line.Length) : line.Length; - if (segmentStart < segmentEnd) - capacity += segmentEnd - segmentStart; - } - - var builder = new System.Text.StringBuilder(capacity); - for (var lineIndex = startLineIndex; lineIndex <= endLineIndex; lineIndex++) - { - var line = structuralLines[lineIndex]; - var segmentStart = lineIndex == startLineIndex ? Math.Max(0, startColumn) : 0; - var segmentEnd = lineIndex == endLineIndex ? Math.Min(endColumn, line.Length) : line.Length; - if (segmentStart < segmentEnd) - builder.Append(line, segmentStart, segmentEnd - segmentStart); - if (lineIndex < endLineIndex) - builder.Append('\n'); - } - - return builder.ToString(); - } - - private static bool LooksLikeCSharpQueryGenericTypeArgumentClose( - IReadOnlyList structuralLines, - int bodyEndIndex, - int closeLineIndex, - int closeColumn) - { - if (closeLineIndex < 0 || closeLineIndex >= structuralLines.Count) - return false; - - var angleDepth = 1; - for (var lineIndex = closeLineIndex; lineIndex >= 0; lineIndex--) - { - var line = structuralLines[lineIndex]; - var columnStart = lineIndex == closeLineIndex ? Math.Min(closeColumn - 1, line.Length - 1) : line.Length - 1; - for (var column = columnStart; column >= 0; column--) - { - var current = line[column]; - switch (current) - { - case '>': - angleDepth++; - break; - case '<': - angleDepth--; - if (angleDepth == 0) - return LooksLikeCSharpQueryGenericTypeArgumentStart(structuralLines, bodyEndIndex, lineIndex, column); - break; - } - } - } - - return false; - } - - private static bool TryFindMatchingCSharpDelimiter( - IReadOnlyList structuralLines, - int bodyEndIndex, - int startLineIndex, - int startColumn, - char open, - char close, - out CSharpLineColumn match) - { - var depth = 0; - for (var lineIndex = startLineIndex; lineIndex <= bodyEndIndex; lineIndex++) - { - var line = structuralLines[lineIndex]; - var columnStart = lineIndex == startLineIndex ? Math.Min(startColumn, line.Length) : 0; - for (var column = columnStart; column < line.Length; column++) - { - var current = line[column]; - if (current == open) - { - depth++; - } - else if (current == close && depth > 0) - { - depth--; - if (depth == 0) - { - match = new CSharpLineColumn(lineIndex + 1, column); - return true; - } - } - } - } - - match = new CSharpLineColumn(bodyEndIndex + 1, 0); - return false; - } - - private static bool LooksLikeCSharpQueryGenericTypeArgumentStart( - IReadOnlyList structuralLines, - int bodyEndIndex, - int startLineIndex, - int startColumn) - { - var line = structuralLines[startLineIndex]; - if (startColumn < 0 || startColumn >= line.Length || line[startColumn] != '<') - return false; - if (HasCSharpQueryGenericOperatorOnRight(line, startColumn + 1)) - return false; - if (!HasCSharpQueryGenericReceiverOnLeft(line, startColumn - 1)) - return false; - - var angleDepth = 1; - var parenDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - for (var lineIndex = startLineIndex; lineIndex <= bodyEndIndex; lineIndex++) - { - var currentLine = structuralLines[lineIndex]; - var columnStart = lineIndex == startLineIndex ? startColumn + 1 : 0; - for (var column = columnStart; column < currentLine.Length; column++) - { - var current = currentLine[column]; - switch (current) - { - case '<': - angleDepth++; - break; - case '>': - angleDepth--; - if (angleDepth == 0) - return HasCSharpQueryGenericSuffix(structuralLines, bodyEndIndex, lineIndex, column + 1); - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth == 0) - return false; - parenDepth--; - break; - case '[': - bracketDepth++; - break; - case ']': - if (bracketDepth == 0) - return false; - bracketDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth == 0) - return false; - braceDepth--; - break; - case ';': - if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) - return false; - break; - } - } - } - - return false; - } - - private static bool HasCSharpQueryGenericOperatorOnRight(string line, int index) - { - while (index < line.Length && char.IsWhiteSpace(line[index])) - index++; - if (index >= line.Length) - return false; - - return line[index] is '<' or '='; - } - - private static bool HasCSharpQueryGenericReceiverOnLeft(string line, int index) - { - while (index >= 0 && char.IsWhiteSpace(line[index])) - index--; - if (index < 0) - return false; - - var current = line[index]; - return IsCSharpIdentifierPart(current) || current is '>' or ']' or ')'; - } - - private static bool HasCSharpQueryGenericSuffix( - IReadOnlyList structuralLines, - int bodyEndIndex, - int startLineIndex, - int startColumn) - { - for (var lineIndex = startLineIndex; lineIndex <= bodyEndIndex; lineIndex++) - { - var line = structuralLines[lineIndex]; - var columnStart = lineIndex == startLineIndex ? startColumn : 0; - for (var column = columnStart; column < line.Length; column++) - { - var current = line[column]; - if (char.IsWhiteSpace(current)) - continue; - - if (current is '(' or ')' or ']' or '[' or '.' or ',' or ';' or '{' or ':' or '?' - || IsCSharpIdentifierStart(current)) - { - return true; - } - - return IsCSharpQueryGenericComparisonOperator(line, column); - } - } - - return true; - } - - private static bool IsCSharpQueryGenericComparisonOperator(string line, int column) - { - if (column < 0 || column + 1 >= line.Length) - return false; - - var current = line[column]; - return (current is '!' or '=') && line[column + 1] == '='; - } - - private static CSharpLineColumn FindCSharpArrowExpressionScopeEndPosition(string bodyText, int arrowIndex, int startLineNumber, int fallbackScopeEndLine) - { - var foundContent = false; - var parenDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - for (var i = Math.Min(arrowIndex + 2, bodyText.Length); i < bodyText.Length; i++) - { - var current = bodyText[i]; - if (!foundContent) - { - if (char.IsWhiteSpace(current)) - continue; - - foundContent = true; - } - - switch (current) - { - case '(': - parenDepth++; - break; - case ')': - if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) - return GetLineColumnFromOffset(bodyText, i, startLineNumber); - if (parenDepth > 0) - parenDepth--; - break; - case '[': - bracketDepth++; - break; - case ']': - if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) - return GetLineColumnFromOffset(bodyText, i, startLineNumber); - if (bracketDepth > 0) - bracketDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth == 0 && parenDepth == 0 && bracketDepth == 0) - return GetLineColumnFromOffset(bodyText, i + 1, startLineNumber); - if (braceDepth > 0) - { - braceDepth--; - if (braceDepth == 0 && parenDepth == 0 && bracketDepth == 0) - return GetLineColumnFromOffset(bodyText, i + 1, startLineNumber); - } - break; - case ',': - case ';': - if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) - return GetLineColumnFromOffset(bodyText, i, startLineNumber); - break; - } - } - - return new CSharpLineColumn(fallbackScopeEndLine, int.MaxValue); - } - - private static bool IsCSharpConditionalOperatorQuestionMark(string bodyText, int index) - { - if (index < 0 || index >= bodyText.Length || bodyText[index] != '?') - return false; - - var previous = index > 0 ? bodyText[index - 1] : '\0'; - var next = index + 1 < bodyText.Length ? bodyText[index + 1] : '\0'; - return previous != '?' - && next is not '?' and not '.' and not '['; - } - - private static void GetCSharpDelimiterDepthsAtOffset( - string bodyText, - int offset, - out int parenDepth, - out int bracketDepth, - out int braceDepth) - { - parenDepth = 0; - bracketDepth = 0; - braceDepth = 0; - var limit = Math.Min(offset, bodyText.Length); - for (var i = 0; i < limit; i++) - { - switch (bodyText[i]) - { - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) - parenDepth--; - break; - case '[': - bracketDepth++; - break; - case ']': - if (bracketDepth > 0) - bracketDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) - braceDepth--; - break; - } - } - } - - private static int GetTextOffsetFromLineColumn(string bodyText, int startLineNumber, CSharpLineColumn position) - { - if (string.IsNullOrEmpty(bodyText)) - return 0; - - if (position.Line <= startLineNumber) - return Math.Max(0, Math.Min(position.Column, bodyText.Length)); - - var currentLineNumber = startLineNumber; - var lineStartOffset = 0; - while (lineStartOffset < bodyText.Length && currentLineNumber < position.Line) - { - var newlineIndex = bodyText.IndexOf('\n', lineStartOffset); - if (newlineIndex < 0) - return bodyText.Length; - - currentLineNumber++; - lineStartOffset = newlineIndex + 1; - } - - var lineEndOffset = bodyText.IndexOf('\n', lineStartOffset); - if (lineEndOffset < 0) - lineEndOffset = bodyText.Length; - - return Math.Min(lineStartOffset + Math.Max(position.Column, 0), lineEndOffset); - } - - private static bool IsPotentialCSharpLambdaArrow(string bodyText, int arrowIndex) - { - var leftIndex = SkipWhitespaceBackward(bodyText, arrowIndex - 1); - if (leftIndex < 0) - return false; - - if (bodyText[leftIndex] == ')') - { - if (!TryFindMatchingOpenParen(bodyText, leftIndex, out var openParenIndex)) - return false; - - var parenPrefixIndex = SkipWhitespaceBackward(bodyText, openParenIndex - 1); - if (parenPrefixIndex < 0) - return true; - - var parenPrefixChar = bodyText[parenPrefixIndex]; - if (parenPrefixChar is '.' or ']' or ')') - return false; - - if (IsCSharpIdentifierPart(parenPrefixChar)) - { - var parenIdentifierStart = parenPrefixIndex; - while (parenIdentifierStart >= 0 && IsCSharpIdentifierPart(bodyText[parenIdentifierStart])) - parenIdentifierStart--; - parenIdentifierStart++; - - var identifierPrefixIndex = SkipWhitespaceBackward(bodyText, parenIdentifierStart - 1); - if (identifierPrefixIndex < 0) - return true; - - var identifierPrefixChar = bodyText[identifierPrefixIndex]; - if (identifierPrefixChar == '.') - return false; - - if (IsCSharpIdentifierPart(identifierPrefixChar)) - { - if (!TryReadPreviousIdentifierToken(bodyText, identifierPrefixIndex, out var identifierPreviousToken)) - return false; - - var normalizedPreviousToken = NormalizeCSharpIdentifier(identifierPreviousToken); - return normalizedPreviousToken is not ("when" or "is" or "as" or "and" or "or" or "not" - or "return" or "throw" or "new" or "case" or "else" or "do"); - } - - return identifierPrefixChar is '>' or ']' or ')' or '?' or ':' or '='; - } - - return parenPrefixChar is '=' or '(' or ',' or ':'; - } - - var identifierEnd = leftIndex + 1; - var identifierStart = leftIndex; - while (identifierStart >= 0 && IsCSharpIdentifierPart(bodyText[identifierStart])) - identifierStart--; - identifierStart++; - if (identifierStart >= identifierEnd || !IsCSharpIdentifierStart(bodyText[identifierStart])) - return false; - - var prefixIndex = SkipWhitespaceBackward(bodyText, identifierStart - 1); - if (prefixIndex < 0) - return false; - - var prefixChar = bodyText[prefixIndex]; - return prefixChar is '=' or '(' or ',' or ':' - || (TryReadPreviousIdentifierToken(bodyText, prefixIndex, out var previousToken) - && (string.Equals(previousToken, "return", StringComparison.Ordinal) - || string.Equals(previousToken, "static", StringComparison.Ordinal) - || string.Equals(previousToken, "async", StringComparison.Ordinal))); - } - - private static int GetLineStartOffset(string text, int offset) - { - var lineStart = Math.Min(offset, text.Length); - while (lineStart > 0 && text[lineStart - 1] != '\n') - lineStart--; - return lineStart; - } - - private static CSharpLineColumn GetLineColumnFromOffset(string text, int offset, int startLineNumber) - { - var lineNumber = startLineNumber; - var column = 0; - var limit = Math.Min(offset, text.Length); - for (var i = 0; i < limit; i++) - { - if (text[i] == '\n') - { - lineNumber++; - column = 0; - } - else - { - column++; - } - } - - return new CSharpLineColumn(lineNumber, column); - } - - private static int SkipWhitespaceBackward(string text, int index) - { - while (index >= 0 && char.IsWhiteSpace(text[index])) - index--; - return index; - } - - private static bool TryFindMatchingOpenParen(string text, int closeParenIndex, out int openParenIndex) - { - openParenIndex = -1; - var depth = 0; - for (var i = closeParenIndex; i >= 0; i--) - { - if (text[i] == ')') - { - depth++; - } - else if (text[i] == '(') - { - depth--; - if (depth == 0) - { - openParenIndex = i; - return true; - } - } - } - - return false; - } - - private static bool TryReadPreviousIdentifierToken(string text, int index, out string token) - { - token = string.Empty; - var end = index; - while (end >= 0 && !IsCSharpIdentifierPart(text[end])) - end--; - if (end < 0) - return false; - - var start = end; - while (start >= 0 && IsCSharpIdentifierPart(text[start])) - start--; - start++; - if (start > end) - return false; - - token = text[start..(end + 1)]; - return token.Length > 0; - } - - private static bool IsStaticCSharpSymbol(SymbolRecord? symbol) => - symbol?.Signature != null && CSharpStaticModifierRegex.IsMatch(symbol.Signature); - - private static string GetFirstQualifiedSegment(string qualifiedName) - { - if (string.IsNullOrWhiteSpace(qualifiedName)) - return string.Empty; - - var firstDot = qualifiedName.IndexOf('.'); - return firstDot < 0 ? qualifiedName : qualifiedName[..firstDot]; - } - - private static bool MatchesQualifiedConstantContainer( - string qualifier, - IReadOnlyList<(string ContainerName, string? QualifiedContainerName, bool AllowShortNameFallback)> targets, - bool allowShortNameFallback = true, - bool allowSingleSegmentQualifiedMatch = false) - { - var hasMultipleQualifierSegments = qualifier.Contains('.') || qualifier.Contains("::", StringComparison.Ordinal); - foreach (var (containerName, qualifiedContainerName, targetAllowsShortNameFallback) in targets) - { - if (!string.IsNullOrWhiteSpace(qualifiedContainerName) - && ((hasMultipleQualifierSegments && QualifiedNameHasSuffix(qualifiedContainerName!, qualifier)) - || (!hasMultipleQualifierSegments - && allowSingleSegmentQualifiedMatch - && string.Equals(qualifiedContainerName, qualifier, StringComparison.Ordinal)))) - { - return true; - } - - if (allowShortNameFallback - && targetAllowsShortNameFallback - && string.Equals(GetLastQualifiedSegment(qualifier), containerName, StringComparison.Ordinal)) - return true; - } - - return false; - } - - private static bool QualifiedNameHasSuffix(string fullName, string suffix) - { - if (string.IsNullOrWhiteSpace(fullName) || string.IsNullOrWhiteSpace(suffix)) - return false; - if (string.Equals(fullName, suffix, StringComparison.Ordinal)) - return true; - if (suffix.Length >= fullName.Length) - return false; - - var start = fullName.Length - suffix.Length; - return string.Compare(fullName, start, suffix, 0, suffix.Length, StringComparison.Ordinal) == 0 - && fullName[start - 1] == '.'; - } - - private static string GetLastQualifiedSegment(string qualifiedName) - { - if (string.IsNullOrWhiteSpace(qualifiedName)) - return string.Empty; - - var lastDot = qualifiedName.LastIndexOf('.'); - var lastColon = qualifiedName.LastIndexOf("::", StringComparison.Ordinal); - var split = Math.Max(lastDot, lastColon); - return split < 0 ? qualifiedName : qualifiedName[(split + (split == lastColon ? 2 : 1))..]; - } } From e962c46a6f2c1b6c3a71a590f17ff50b6117511e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:27:46 +0900 Subject: [PATCH 046/101] Split structural line masking strategies --- .../Support/StructuralLineMasker.JsForOf.cs | 441 ++++ .../StructuralLineMasker.JsTaggedTemplates.cs | 302 +++ .../StructuralLineMasker.JsTemplates.cs | 475 ++++ ...StructuralLineMasker.JvmAndSwiftStrings.cs | 974 ++++++++ .../Support/StructuralLineMasker.cs | 2172 ----------------- 5 files changed, 2192 insertions(+), 2172 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JsForOf.cs create mode 100644 src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JsTaggedTemplates.cs create mode 100644 src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JsTemplates.cs create mode 100644 src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JvmAndSwiftStrings.cs diff --git a/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JsForOf.cs b/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JsForOf.cs new file mode 100644 index 000000000..78568be90 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JsForOf.cs @@ -0,0 +1,441 @@ +namespace CodeIndex.Indexer; + +internal static partial class StructuralLineMasker +{ + private static void FilterJsForOfHeaderHits(string[] lines, List hits) + { + // Build a scan buffer that additionally blanks string literals, regex literals, + // and line comments. The outer masker already blanked template bodies and block + // comments, but string / regex / `//` content survives, so a literal `)` inside + // `":"` or `/)/` or `// for (a;b;c)` would corrupt paren and `;` counting in the + // for-of header probe. Blanking them here keeps the structural walk structural. + // paren と `;` のカウントが文字列 / regex / 行コメント内の `)` や `;` に引きずられ + // ないよう、外側 masker が空白化していない要素も追加で空白化したスキャンバッファを + // 作る。template 本体と block コメントは外側で既に空白化済みのためここでは触らない。 + var scanBuffer = BuildJsForOfScanBuffer(lines); + for (int h = hits.Count - 1; h >= 0; h--) + { + var hit = hits[h]; + if (hit.Name != "of") + continue; + if (IsJsForOfHeaderContext(scanBuffer, hit.Line - 1, hit.Column - 1)) + hits.RemoveAt(h); + } + } + + // Returns the masker output with single/double-quoted string spans, regex + // literals, and `//` line-comment tails blanked out. Template literal bodies and + // block comments are already blanked by the outer masker, so we only need to + // handle the three remaining kinds. Unchanged lines are reused; any returned + // replacement keeps identical column offsets so hit coordinates (Line, Column) + // remain valid. + // 外側の masker の出力に対し、文字列リテラル・regex リテラル・`//` 行コメント末尾を + // 追加で空白化して返す。template 本体と block コメントは既に空白化済みなので、残る + // 3 種類だけを処理する。未変更行は再利用し、置換行も列オフセットは元の buffer と + // 一致するため Hit 座標は + // そのまま利用できる。 + private static string[] BuildJsForOfScanBuffer(string[] lines) + { + string[]? result = null; + var lexState = default(JsLexState); + var activeJsStringQuote = '\0'; + lexState.Reset(); + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (line.Length == 0) + { + if (result != null) + result[i] = line; + continue; + } + char[]? buf = null; + char[] GetBuffer() => buf ??= line.ToCharArray(); + int pos = 0; + while (pos < line.Length) + { + if (activeJsStringQuote != '\0') + { + pos = MaskJsTemplateHoleString(line, pos, GetBuffer(), activeJsStringQuote, startsInsideString: true, out var continuesOnNextLine); + if (continuesOnNextLine) + break; + + activeJsStringQuote = '\0'; + lexState.SetKind(JsPrevTokenKind.Literal); + continue; + } + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') + { + var buffer = GetBuffer(); + for (int k = pos; k < line.Length; k++) + buffer[k] = ' '; + pos = line.Length; + break; + } + char ch = line[pos]; + if (ch == '"' || ch == '\'') + { + var quote = ch; + pos = MaskJsTemplateHoleString(line, pos, GetBuffer(), quote, startsInsideString: false, out var continuesOnNextLine); + if (continuesOnNextLine) + { + activeJsStringQuote = quote; + break; + } + + lexState.SetKind(JsPrevTokenKind.Literal); + continue; + } + if (ch == '/' && CanStartJsRegexLiteral(lexState)) + { + int end = SkipJsRegexLiteral(line, pos); + var buffer = GetBuffer(); + for (int k = pos; k < end; k++) + buffer[k] = ' '; + pos = end; + lexState.SetKind(JsPrevTokenKind.Literal); + continue; + } + pos = AdvanceJsToken(line, pos, ref lexState); + } + var outputLine = buf is null ? line : new string(buf); + if (result != null) + { + result[i] = outputLine; + } + else if (!ReferenceEquals(outputLine, line)) + { + result = (string[])lines.Clone(); + result[i] = outputLine; + } + } + return result ?? lines; + } + + // From (lineIdx, colIdx) pointing at the start of the `of` token, decide whether `of` + // is the iterator keyword of a for-of / for-await-of header. Classic `for (init; cond; + // step)` keeps `of` visible as a real tagged-template call. + // `of` トークン先頭 (lineIdx, colIdx) を起点に、その `of` が for-of / for-await-of の + // 反復子キーワードかを判定する。古典形 `for (init; cond; step)` 内の `of` はタグとして + // 残す。 + private static bool IsJsForOfHeaderContext(string[] lines, int lineIdx, int colIdx) + { + if (lineIdx < 0 || lineIdx >= lines.Length) + return false; + + if (!TryFindEnclosingOpenParen(lines, lineIdx, colIdx, out var openLine, out var openCol)) + return false; + + if (!PrecedingTokenIsForKeyword(lines, openLine, openCol)) + return false; + + return HasNoTopLevelSemicolonInParenGroup(lines, openLine, openCol); + } + + // Walk backward from just before (startLine, startCol) through masked lines to find the + // nearest unmatched `(`. Balanced `()` / `[]` / `{}` groups are skipped. Escaping an + // unmatched `[` or `{` means `of` is not inside a paren-group at all; return false. + // (startLine, startCol) の直前から masked lines を後方に走査し、釣り合っていない最 + // 近傍の `(` を探す。釣り合いのとれた `()` / `[]` / `{}` は飛ばす。未対応の `[` / `{` + // を抜ける場合は paren-group 内にないため false を返す。 + private static bool TryFindEnclosingOpenParen(string[] lines, int startLine, int startCol, out int openLine, out int openCol) + { + openLine = -1; + openCol = -1; + int parenDepth = 0; + int bracketDepth = 0; + int braceDepth = 0; + int curCol = startCol - 1; + for (int li = startLine; li >= 0; li--) + { + var line = lines[li]; + if (li != startLine) + curCol = line.Length - 1; + for (int c = curCol; c >= 0; c--) + { + char ch = line[c]; + if (ch == ')') { parenDepth++; continue; } + if (ch == ']') { bracketDepth++; continue; } + if (ch == '}') { braceDepth++; continue; } + if (ch == '[') + { + if (bracketDepth > 0) { bracketDepth--; continue; } + return false; + } + if (ch == '{') + { + if (braceDepth > 0) { braceDepth--; continue; } + return false; + } + if (ch == '(') + { + if (parenDepth > 0) { parenDepth--; continue; } + openLine = li; + openCol = c; + return true; + } + } + } + return false; + } + + // Check whether the token immediately before the `(` at (openLine, openCol) is `for` + // (optionally followed by an `await` token between `for` and `(`). Whitespace and + // line breaks between the keyword and `(` are tolerated. + // (openLine, openCol) の `(` 直前トークンが `for`(`for` と `(` の間に `await` が入る + // 形も許容)であるかを判定する。キーワードと `(` の間の空白・改行は許容する。 + private static bool PrecedingTokenIsForKeyword(string[] lines, int openLine, int openCol) + { + int li = openLine; + int c = openCol - 1; + if (!SkipWhitespaceBackward(lines, ref li, ref c)) + return false; + if (!TryReadIdentifierBackward(lines, ref li, ref c, out var token1)) + return false; + if (token1 == "for") + return true; + if (token1 != "await") + return false; + if (!SkipWhitespaceBackward(lines, ref li, ref c)) + return false; + if (!TryReadIdentifierBackward(lines, ref li, ref c, out var token2)) + return false; + return token2 == "for"; + } + + // Starting from `(` at (openLine, openCol), walk forward to the matching `)` and + // report whether the paren group contains zero top-level `;`. Zero means for-of / + // for-await-of shape; any top-level `;` means classic `for (init; cond; step)`. + // (openLine, openCol) の `(` から対応する `)` までを前方走査し、トップレベルの `;` が + // 1 つも無ければ for-of / for-await-of 形、1 つ以上あれば古典形 `for (init; cond; + // step)` と判断する。 + private static bool HasNoTopLevelSemicolonInParenGroup(string[] lines, int openLine, int openCol) + { + int parenDepth = 1; + int bracketDepth = 0; + int braceDepth = 0; + for (int li = openLine; li < lines.Length; li++) + { + var line = lines[li]; + int startCol = (li == openLine) ? openCol + 1 : 0; + for (int c = startCol; c < line.Length; c++) + { + char ch = line[c]; + if (ch == '(') { parenDepth++; continue; } + if (ch == ')') + { + parenDepth--; + if (parenDepth == 0) + return true; + continue; + } + if (ch == '[') { bracketDepth++; continue; } + if (ch == ']') { if (bracketDepth > 0) bracketDepth--; continue; } + if (ch == '{') { braceDepth++; continue; } + if (ch == '}') { if (braceDepth > 0) braceDepth--; continue; } + if (ch == ';' && parenDepth == 1 && bracketDepth == 0 && braceDepth == 0) + return false; + } + } + return false; + } + + private static bool SkipWhitespaceBackward(string[] lines, ref int li, ref int c) + { + while (true) + { + while (c < 0) + { + li--; + if (li < 0) + return false; + c = lines[li].Length - 1; + } + char ch = lines[li][c]; + if (IsJsInterTokenWhitespace(ch)) + { + c--; + continue; + } + return true; + } + } + + // ECMAScript treats inter-token whitespace as any WhiteSpace (TAB / VT / FF / SP, NBSP + // `U+00A0`, BOM `U+FEFF`, every `Zs` category codepoint) or LineTerminator. Our per-line + // buffer is already split on `\r` / `\n`, but non-ASCII whitespace such as NBSP and + // U+3000 survives inside the line and must still be recognised when backing up between + // tokens. `char.IsWhiteSpace` matches `Zs` plus common ASCII controls, but in .NET 8 + // `char.IsWhiteSpace('\uFEFF')` is `false` (BOM is categorised as `Cf`/Format), so BOM + // must be added explicitly. ZWSP `U+200B` is deliberately excluded — ECMAScript does + // not treat it as WhiteSpace and `char.IsWhiteSpace` already returns false for it. + // ECMAScript のトークン間スペースは WhiteSpace(TAB / VT / FF / SP、NBSP `U+00A0`、BOM + // `U+FEFF`、`Zs` 全域)および LineTerminator。行バッファは既に `\r` / `\n` で分割済み + // だが、NBSP や U+3000 のような非 ASCII 空白は行内に残るため、トークン間の後方走査でも + // 取り扱う必要がある。.NET 8 では `char.IsWhiteSpace('\uFEFF')` は `false`(BOM は + // `Cf`/Format 扱い)なので BOM は明示的に足す必要がある。ZWSP `U+200B` は ECMAScript + // の WhiteSpace ではなく、`char.IsWhiteSpace` も false を返すため意図通りに除外される。 + private static bool IsJsInterTokenWhitespace(char c) => c == '\uFEFF' || char.IsWhiteSpace(c); + + private static bool TryReadIdentifierBackward(string[] lines, ref int li, ref int c, out string token) + { + token = string.Empty; + if (li < 0 || li >= lines.Length || c < 0) + return false; + var line = lines[li]; + if (c >= line.Length || !IsJsIdentifierPart(line[c])) + return false; + int end = c + 1; + while (c >= 0 && IsJsIdentifierPart(line[c])) + c--; + int start = c + 1; + if (!IsJsIdentifierStart(line[start])) + return false; + token = line.Substring(start, end - start); + return true; + } + + // Advance past one JS/TS token (identifier run, numeric run, single non-string/regex char) + // and update lexer state so the next `/` can be classified as regex-start or division. + // 識別子の連続や数値、単一文字を 1 token として進め、次の `/` を regex / division に + // 振り分けられるよう lex state を更新する。 + private static int AdvanceJsToken(string line, int pos, ref JsLexState lexState) + { + var c = line[pos]; + if (char.IsWhiteSpace(c)) + return pos + 1; + + if (IsJsIdentifierStart(c)) + { + int start = pos; + pos++; + while (pos < line.Length && IsJsIdentifierPart(line[pos])) + pos++; + lexState.SetIdentifier(line.Substring(start, pos - start)); + return pos; + } + + if (char.IsDigit(c)) + { + while (pos < line.Length && (char.IsLetterOrDigit(line[pos]) || line[pos] == '.' || line[pos] == '_')) + pos++; + lexState.SetKind(JsPrevTokenKind.Numeric); + return pos; + } + + // Postfix / prefix `++` and `--` both produce a numeric-typed expression, + // so the following `/` must be division, not a regex start. Consume as one + // 2-char token to stop the second `+` / `-` from being classified as `Other`. + // postfix / prefix の `++` と `--` は数値を生むため、続く `/` は division と + // 扱う必要がある。2 文字 token として消費し、2 文字目が `Other` に落ちて + // 直後の `/` を regex と誤判定するのを防ぐ。 + if ((c == '+' || c == '-') && pos + 1 < line.Length && line[pos + 1] == c) + { + lexState.SetKind(JsPrevTokenKind.Numeric); + return pos + 2; + } + + switch (c) + { + case '(': + // Remember whether this `(` opens a statement-head control-flow + // clause. Its matching `)` will need to keep the following `/` + // regex-legal rather than flipping to division. + // この `(` が statement-head control-flow(`if (x)` など)を + // 開いているかを stack に記録し、対応する `)` の直後の `/` を + // division ではなく regex literal として扱えるようにする。 + var openIsStmtHead = lexState.PrevTokenKind == JsPrevTokenKind.Identifier + && IsJsStatementHeadKeyword(lexState.PrevIdentifier); + lexState.ParenStatementHead?.Push(openIsStmtHead); + lexState.SetKind(JsPrevTokenKind.Other); + break; + case ')': + var closeIsStmtHead = lexState.ParenStatementHead is { Count: > 0 } + && lexState.ParenStatementHead.Pop(); + // Statement-head `)` tags the following `/` as regex-legal and the + // following `{` as a statement block; other `)` flips `/` to division + // and `{` to an object-literal-style expression brace. + // statement-head の `)` は続く `/` を regex、続く `{` を block と扱う。 + // それ以外の `)` は `/` を division、`{` を object literal 的な + // expression brace と扱う。 + lexState.SetKind(closeIsStmtHead ? JsPrevTokenKind.StatementHeadCloseParen : JsPrevTokenKind.CloseParen); + break; + case ']': + lexState.SetKind(JsPrevTokenKind.CloseBracket); + break; + case ':': + // `case expr :` / `default :` — the case-label colon. Treat it + // as such only when the paren depth is back to what it was at + // the `case` / `default` keyword, so object-key, ternary, and + // type-annotation colons inside the case expression do not + // consume the hint. + // `case expr :` / `default :` の case ラベル終端 `:`。paren + // 深さが `case` / `default` 時点と同じに戻ったときだけ使い、 + // case 式内の object-key / ternary / type annotation の `:` + // でヒントを消費しないようにする。 + if (lexState.CaseLabelPending + && (lexState.ParenStatementHead?.Count ?? 0) == lexState.CaseLabelBaseParenDepth) + { + lexState.CaseLabelPending = false; + lexState.CaseColonBlockPending = true; + } + lexState.SetKind(JsPrevTokenKind.Other); + break; + case ';': + // `;` terminates any in-progress case-label tracking. + // `;` で case ラベル追跡を打ち切る。 + lexState.CaseLabelPending = false; + lexState.CaseColonBlockPending = false; + lexState.SetKind(JsPrevTokenKind.Other); + break; + case '>': + // `=>` is the only 2-char JS token we need to distinguish here: `{` + // following `=>` opens an arrow-function body (a statement block, so + // the next `/` inside is regex), while `{` following most other tokens + // opens an object literal / expression brace. + // `=>` は 2 文字 token のうち本マスカーで必要な唯一のケース。続く `{` + // が arrow body(statement block)か object literal / expression + // brace かを分けるフラグとして使う。 + if (pos > 0 && line[pos - 1] == '=') + lexState.SetKind(JsPrevTokenKind.Arrow); + else + lexState.SetKind(JsPrevTokenKind.Other); + break; + // `}` in normal JS / TS code is context-dependent: after a statement block + // (`if (x) {}`) a `/` legitimately starts a regex; after an object literal + // in expression position a `/` is division. We classify as `Other` so the + // regex scanner still runs — that lets us correctly skip `/regex/` literals + // that may contain backticks or braces which would otherwise open a phantom + // template literal. Inside template-literal holes the closing brace is + // handled separately (see `JsPrevTokenKind.CloseBrace` path below). + // 通常コードの `}` は文脈依存で、`if (x) {}` のあとは regex、object literal + // のあとは division。ここでは `Other` として regex scanner に任せ、中に + // backtick や brace を含む `/regex/` を取りこぼして phantom template を + // 開かないようにする。テンプレート hole 内のブレース close は別扱い。 + default: + lexState.SetKind(JsPrevTokenKind.Other); + break; + } + + return pos + 1; + } + + private static bool IsJsIdentifierStart(char c) => + c == '_' || c == '$' || char.IsLetter(c); + + private static bool IsJsIdentifierPart(char c) => + c == '_' || c == '$' || char.IsLetterOrDigit(c); + + // Backward-scan the masked buffer at a template-literal opener backtick for a tag + // identifier such as `gql`, `styled.div` (last segment), or `html` (generics are + // skipped). Whitespace between the identifier and the backtick is tolerated so + // `html \`...\`` still matches. `IsIgnoredCallName` downstream filters out keywords + // like `return` / `throw` / `await` / `typeof` that can legally precede a plain + // template literal. + // マスク済みバッファを opener バッククォート位置から後方スキャンし、`gql` や + // `styled.div`(最後のセグメント)、`html`(ジェネリクスを読み飛ばす)の + // タグ識別子を取り出す。識別子とバッククォートの間の空白は許容し、 + // `return` / `throw` / `await` / `typeof` のようなプレーンテンプレートの前に + // 立ちうるキーワードは呼び出し側の `IsIgnoredCallName` で除外する。 +} diff --git a/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JsTaggedTemplates.cs b/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JsTaggedTemplates.cs new file mode 100644 index 000000000..28156bed9 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JsTaggedTemplates.cs @@ -0,0 +1,302 @@ +namespace CodeIndex.Indexer; + +internal static partial class StructuralLineMasker +{ + private static void TryRecordJsTaggedTemplateHit( + string[] lines, char[] masked, int lineIndex, int backtickPos, ref List? hits, bool allowGenericTag) + { + // Skip inter-token whitespace backward, crossing line boundaries when the tag + // identifier lives on a prior line (multi-line forms like `tag\n\`hello\``). + // Prior lines are already fully masked by the outer loop, so we can safely read + // `lines[i]` for `i < lineIndex`. + // トークン間空白を後方に辿る。`tag\n\`hello\`` のようにタグが前行にある形も扱うため、 + // 行境界を越えて走査する。先行行は外側ループで既にマスク済みなので `lines[i]` を + // そのまま参照できる。 + int curLine = lineIndex; + int k = backtickPos - 1; + while (true) + { + if (curLine == lineIndex) + { + while (k >= 0 && IsJsInterTokenWhitespace(masked[k])) + k--; + if (k >= 0) break; + } + else + { + var l = lines[curLine]; + while (k >= 0 && IsJsInterTokenWhitespace(l[k])) + k--; + if (k >= 0) break; + } + curLine--; + if (curLine < 0) return; + k = (curLine == lineIndex ? masked.Length : lines[curLine].Length) - 1; + } + + char CharAt(int li, int col) + => li == lineIndex ? masked[col] : lines[li][col]; + int LineLen(int li) + => li == lineIndex ? masked.Length : lines[li].Length; + + // Skip a balanced `<...>` (TypeScript generics) so `html\`...\`` still sees `html`. + // The generic-strip is TypeScript-only (`allowGenericTag`) because plain JavaScript has + // no generics: `foo\`x\`` is always the chained comparison `(foo\`x\``. Even + // inside TypeScript we still require the `<` to directly abut an identifier so + // whitespace-bearing comparison expressions like `foo < bar > \`plain\`` are rejected, + // and we ignore `>` from `=>` (arrow-function type inside the generic range). The + // generic-strip is same-line only; a generic argument list spanning line breaks is + // extremely rare in practice. + // `html\`...\`` のジェネリクスを読み飛ばすため、同一行内で `<...>` が釣り合っている + // 場合のみ括弧を剥がす。ジェネリクスは TypeScript 限定(`allowGenericTag`)。JavaScript + // では `foo\`x\`` は常に連鎖比較式なので generic とは扱わない。TypeScript 側でも + // `foo < bar > \`plain\`` のような比較式と区別するため `<` が識別子に隣接していることを + // 要求し、`=>` 由来の `>` は関数型なので閉じ記号として数えない。ジェネリクス走査は + // 同一行限定。行をまたぐジェネリクス引数リストは実運用で極めて稀。 + if (CharAt(curLine, k) == '>' && allowGenericTag) + { + int probe = k - 1; + int depth = 1; + while (probe >= 0 && depth > 0) + { + var ch = CharAt(curLine, probe); + if (ch == '>' && probe > 0 && CharAt(curLine, probe - 1) == '=') + { + probe -= 2; + continue; + } + if (ch == '>') depth++; + else if (ch == '<') depth--; + probe--; + } + if (depth != 0) + return; + if (probe < 0 || !IsJsIdentifierPart(CharAt(curLine, probe))) + return; + k = probe; + } + + if (!IsJsIdentifierPart(CharAt(curLine, k))) + return; + + // Identifier read stays within the current line — JS identifiers do not cross lines. + // 識別子は行をまたがないため同一行内で読み切る。 + int end = k + 1; + while (k >= 0 && IsJsIdentifierPart(CharAt(curLine, k))) + k--; + int start = k + 1; + + if (!IsJsIdentifierStart(CharAt(curLine, start))) + return; + + string name = curLine == lineIndex + ? new string(masked, start, end - start) + : lines[curLine].Substring(start, end - start); + + // Member-access detection: look for a `.` (possibly after inter-token whitespace, + // possibly across line breaks like `obj\n.default\`x\``) before the tag identifier. + // Member-access tags bypass the keyword denylist downstream because any reserved + // word — including `default`, `finally`, `in`, `instanceof`, `delete`, `void`, + // `case` — is a legal property name in JavaScript/TypeScript. + // メンバーアクセス判定: タグ識別子の前に空白(行境界含む)を挟んで `.` があれば + // メンバーアクセス。JS/TS ではすべての予約語が property 名になりうるので、 + // メンバーアクセス扱いのタグは下流のキーワード除外リスト(`default` / `finally` / + // `in` / `instanceof` / `delete` / `void` / `case`)の対象外にする。 + bool isMemberAccess = false; + int mLine = curLine; + int mk = start - 1; + while (true) + { + if (mk < 0) + { + mLine--; + if (mLine < 0) break; + mk = LineLen(mLine) - 1; + continue; + } + char pc = CharAt(mLine, mk); + if (IsJsInterTokenWhitespace(pc)) + { + mk--; + continue; + } + if (pc == '.') isMemberAccess = true; + break; + } + + (hits ??= []).Add(new JsTaggedTemplateHit(curLine + 1, start + 1, name, isMemberAccess)); + } + + // Decide whether `/` at the current scan position starts a regex literal rather + // than a division operator. Division follows numeric / string / regex / template literals, + // `)`, `]`, and non-keyword identifiers. Everything else (operators, `{`, `(`, `[`, `,`, + // `;`, `=`, `?`, `:`, leading None) puts us in an expression-prefix context where `/` + // begins a regex. Regex-prefix keywords such as `return`, `throw`, `typeof` re-enable + // regex mode even though they are identifier-shaped. + // `/` が division ではなく regex literal の開始かを判定する。数値 / 文字列 / regex / + // template 等のリテラル、`)`、`]`、および非 keyword な識別子の後は division。 + // それ以外(演算子、`(` / `[` / `=` / `?` / `:` / `,` / `;` や行頭 None)は式の + // 先頭コンテキストで `/` は regex。`return` / `throw` / `typeof` など regex-prefix + // keyword は識別子形でも regex を許す。 + private static bool CanStartJsRegexLiteral(JsLexState lexState) + { + switch (lexState.PrevTokenKind) + { + case JsPrevTokenKind.None: + return true; + case JsPrevTokenKind.CloseParen: + case JsPrevTokenKind.CloseBracket: + case JsPrevTokenKind.CloseBrace: + case JsPrevTokenKind.Numeric: + case JsPrevTokenKind.Literal: + return false; + case JsPrevTokenKind.Identifier: + return IsJsRegexPrefixKeyword(lexState.PrevIdentifier); + case JsPrevTokenKind.StatementHeadCloseParen: + case JsPrevTokenKind.Arrow: + case JsPrevTokenKind.Other: + default: + return true; + } + } + + // Classify a nested `{` opened inside a template-literal hole as an expression + // brace (object literal or `() => ({})` body, follows `=`, `(`, `[`, `,`, `:`, + // `?`, operator, regex-prefix keyword) vs. a statement block (arrow-function + // body, `if`/`while`/`for`/`function` block body — typically follows `)` or + // `=>`). Expression braces classify the matching `}` as division-context; block + // braces keep regex-legal classification so `{} /regex/` still parses. + // テンプレートホール内でネストした `{` が expression brace(object literal / + // `() => ({})` 本体)か statement block(arrow body / `if/while/for/function` + // ブロック)かを判定する。expression は `=`、`(`、`[`、`,`、`:`、`?`、演算子、 + // regex-prefix keyword の直後。block は `)` や `=>` の直後。 + private static bool IsJsExpressionBraceContext(JsLexState lexState) + { + switch (lexState.PrevTokenKind) + { + case JsPrevTokenKind.CloseParen: + case JsPrevTokenKind.StatementHeadCloseParen: + case JsPrevTokenKind.Arrow: + return false; + case JsPrevTokenKind.Identifier: + // Keywords that open a statement block follow the same rule as `)`. + // `else { ... }`, `do { ... }`, `try { ... }`, `finally { ... }`, + // and the optional-binding `catch { ... }` (ES2019). + // block を開く keyword は `)` と同じ扱い。ES2019 の optional + // binding 付き `catch { ... }` も block として扱う。 + return lexState.PrevIdentifier is not ("else" or "do" or "try" or "finally" or "catch"); + default: + return true; + } + } + + private static bool IsJsRegexPrefixKeyword(string word) => + word is "return" or "throw" or "case" or "delete" or "typeof" or "void" + or "new" or "in" or "of" or "instanceof" or "yield" or "await" + or "else" or "do" or "finally"; + + private static int SkipJsRegexLiteral(string line, int startIndex) + { + var p = startIndex + 1; + var inCharClass = false; + + while (p < line.Length) + { + var ch = line[p]; + if (ch == '\\') + { + if (p + 1 < line.Length) + { + p += 2; + continue; + } + + return line.Length; + } + + if (ch == '[') + { + inCharClass = true; + p++; + continue; + } + + if (ch == ']' && inCharClass) + { + inCharClass = false; + p++; + continue; + } + + if (ch == '/' && !inCharClass) + { + p++; + while (p < line.Length && char.IsLetter(line[p])) + p++; + return p; + } + + p++; + } + + return line.Length; + } + + private static int MaskJsTemplateHoleString(string line, int startIndex, char[] masked, char quote, bool startsInsideString, out bool continuesOnNextLine) + { + var p = startIndex; + if (!startsInsideString) + { + masked[p] = ' '; + p++; + } + + while (p < line.Length) + { + var ch = line[p]; + masked[p] = ' '; + + if (ch == '\\') + { + if (p + 1 == line.Length) + { + continuesOnNextLine = true; + return p + 1; + } + + if (p + 2 == line.Length && line[p + 1] == '\r') + { + masked[p + 1] = ' '; + continuesOnNextLine = true; + return line.Length; + } + + if (p + 1 < line.Length) + { + masked[p + 1] = ' '; + p += 2; + continue; + } + } + + p++; + if (ch == quote) + { + continuesOnNextLine = false; + return p; + } + } + + continuesOnNextLine = false; + return p; + } + + // Mask a single-line Swift extended raw string `#"..."#` while preserving + // any matching `\#(...)` interpolation hole bodies so real call edges inside + // the holes still reach the reference graph. Returns the position immediately + // after the closing delimiter (or end of line if the source is malformed). + // Callers must have already verified that `line[startIndex .. startIndex + hashCount]` + // is `#"`. Closes #1001. + // Swift の単行 `#"..."#` 拡張 raw 文字列をマスクしつつ、内側の hash 数一致 `\#(...)` + // 補間ホール本文だけは残し、ホール内の本物の call が reference graph に届くようにする。 +} diff --git a/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JsTemplates.cs b/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JsTemplates.cs new file mode 100644 index 000000000..bcadc6c0d --- /dev/null +++ b/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JsTemplates.cs @@ -0,0 +1,475 @@ +namespace CodeIndex.Indexer; + +internal static partial class StructuralLineMasker +{ + private enum JsPrevTokenKind { None, Identifier, Numeric, Literal, CloseParen, StatementHeadCloseParen, CloseBracket, CloseBrace, Arrow, Other } + + private struct JsLexState + { + public JsPrevTokenKind PrevTokenKind; + public string PrevIdentifier; + // Tracks whether each open `(` was preceded by a statement-head keyword + // (`if`/`while`/`for`/`switch`/`catch`/`with`). After the matching `)`, + // a following `/` begins a regex literal, not division. + // 各 `(` の直前が statement-head キーワード(`if` / `while` / `for` / + // `switch` / `catch` / `with`)だったかを追跡し、対応する `)` の直後に + // 続く `/` を division ではなく regex literal として扱えるようにする。 + public Stack ParenStatementHead; + // True after a declaration keyword (`class`, TypeScript `enum` / + // `interface` / `namespace` / `module`) until the next `{` opens its + // body. Forces `class Foo {}`, `enum Local {}`, `interface Local {}`, + // `namespace Local {}`, and `module Local {}` to be classified as a + // statement block instead of an object-literal expression brace, so + // the matching `}` stays regex-legal and a following `/regex/` does + // not flip to division and swallow backticks as a phantom template + // opener. + // `class` や TypeScript の `enum` / `interface` / `namespace` / + // `module` キーワードの後から次の `{` で body が開くまで true。 + // `class Foo {}` / `enum Local {}` / `interface Local {}` / + // `namespace Local {}` / `module Local {}` の `{` を object literal + // ではなく statement block として扱わせ、対応する `}` を regex-legal + // に保つことで、続く `/regex/` が division に倒れて regex 本文の + // backtick を phantom template 開始として読んでしまうのを防ぐ。 + public bool ClassHeaderPending; + // True after `case` / `default` keyword and cleared at the first `:` at + // paren depth 0. Used to recognize the following `:` as a case-label + // colon (not object-key / ternary / type-annotation colon). + // `case` / `default` キーワード直後に true、paren 深さ 0 の `:` で解除。 + // 以降の `:` が case ラベル終端の `:` か(object key / ternary / type + // annotation の `:` でないか)を区別するために使う。 + public bool CaseLabelPending; + // Paren depth captured when `case` / `default` was seen. The matching + // case-label `:` appears at the same depth; a `:` at a deeper paren + // level belongs to an object-literal key, ternary, or type annotation + // inside the case expression and must not flip CaseColonBlockPending. + // Capturing the base depth (instead of requiring depth 0) is required + // because the enclosing template-hole expression may itself be wrapped + // in one or more `(` — e.g. `${(() => { switch (x) { case 1: ... }})()}`. + // `case` / `default` を読んだ時点の paren 深さ。case ラベル終端の `:` + // は同じ深さに現れ、それより深い `:` は case 式内の object key / + // ternary / type annotation の `:` で、case label 扱いにしてはならない。 + // テンプレートホール全体が `(() => { ... })()` 等でラップされている + // 場合に count==0 を要求すると case 内の `:` をスキップできないため、 + // `case` 時点の深さを基準値として保存する。 + public int CaseLabelBaseParenDepth; + // True after a case-label `:` is consumed; the next `{` opens a + // statement block (`case 1: {}`, `default: {}`), so the matching `}` + // must keep `/regex/` regex-legal. Consumed by the next `{`. + // case ラベル終端の `:` 消費直後に true。次の `{` は statement block + // (`case 1: {}` / `default: {}`)として扱い、対応する `}` 後の + // `/regex/` を regex-legal に保つ。次の `{` で消費。 + public bool CaseColonBlockPending; + + public void Reset() + { + PrevTokenKind = JsPrevTokenKind.None; + PrevIdentifier = string.Empty; + ClassHeaderPending = false; + CaseLabelPending = false; + CaseLabelBaseParenDepth = 0; + CaseColonBlockPending = false; + if (ParenStatementHead is null) + ParenStatementHead = new Stack(); + else + ParenStatementHead.Clear(); + } + + public void SetKind(JsPrevTokenKind kind) + { + PrevTokenKind = kind; + PrevIdentifier = string.Empty; + } + + public void SetIdentifier(string word) + { + PrevTokenKind = JsPrevTokenKind.Identifier; + PrevIdentifier = word; + if (IsJsDeclarationBodyKeyword(word)) + ClassHeaderPending = true; + if (word == "case" || word == "default") + { + CaseLabelPending = true; + CaseLabelBaseParenDepth = ParenStatementHead?.Count ?? 0; + } + } + } + + private static bool IsJsStatementHeadKeyword(string word) => + word is "if" or "while" or "for" or "switch" or "catch" or "with"; + + // Keywords whose body is a statement block, not an object-literal expression brace. + // `class` is JS/TS; `enum`, `interface`, `namespace`, and `module` are TypeScript + // declarations whose `{...}` body must also keep a following `/regex/` regex-legal. + // body が statement block になる宣言キーワード。`class` は JS/TS、 + // `enum` / `interface` / `namespace` / `module` は TypeScript の宣言で、 + // 対応する `}` の直後の `/regex/` を regex-legal に保つ必要がある。 + private static bool IsJsDeclarationBodyKeyword(string word) => + word is "class" or "enum" or "interface" or "namespace" or "module"; + + // JavaScript/TypeScript template literals: `...` with ${expr} interpolation holes. + // Interpolation hole contents are preserved (not masked) so the call-graph keeps real call edges. + // Regex literals are skipped at the outer and hole scopes so a backtick inside a regex + // does not start a phantom template and a `}` inside a regex does not close a hole early. + // JavaScript/TypeScript のテンプレートリテラル `...` と ${expr} 補間ホール。 + // ホール内の本物のコードは参照抽出に見せるためマスクしない。 + // regex literal は外側と hole 内の両方でスキップし、regex 中の backtick が template を + // 誤って開始したり `}` が hole を早く閉じたりするのを避ける。 + private static void MaskJsTsTemplateLiteralContents( + string[] lines, + bool collectTaggedTemplateHits, + ref List? taggedTemplateHits, + string? lang = null) + { + // `<...>` before a backtick is a TypeScript-only generic type-argument form. In plain + // JavaScript the same character sequence is always a comparison chain (`foo\`x\`` + // is `(foo\`x\``), so never strip the bracketed range when indexing JS. + // `<...>` 付きのタグ付きテンプレートは TypeScript 限定のジェネリクス構文。プレーン + // な JavaScript では同じ並びが常に比較式になるため、JS を索引するときは剥がさない。 + var allowGenericTag = string.Equals(lang, "typescript", StringComparison.Ordinal); + var frames = new Stack(); + // `lexState` must persist across lines so that multi-line expressions in + // template-literal holes keep the preceding token context. For example, + // `${(() => value\n / 2 + runTask())()}` continues on a new line: the `/` + // at the start of line 2 is division (prev token `value`), not a regex + // opener. Resetting state per line caused `lexState.PrevTokenKind` to be + // `None`, flipping `/` into regex mode and swallowing the closing `}` and + // backtick. + // `lexState` はホール内の複数行式で直前トークンを保持するため、行をまたいで + // 維持する必要がある。行頭で Reset すると継続行の `/` が常に regex 扱いに + // なり、hole を閉じる `}` やバッククォートを巻き込んでしまう。 + var lexState = default(JsLexState); + // Active quote for a JS/TS single- or double-quoted string that started + // inside the current top-most template hole and continued past a physical + // line boundary via trailing `\`. The continuation can only belong to the + // current top frame at the start of the next line, so a single scanner-wide + // state slot is enough. + // テンプレートホール内で始まり、行末 `\` により次行へ継続した JS/TS 単/二重 + // 引用符文字列の active quote。行境界で継続可能なのは次行開始時の最上位 hole + // だけなので、scanner 全体で 1 スロット持てば十分。 + var activeJsHoleStringQuote = '\0'; + // Top-level JS/TS single- or double-quoted string that continues across a + // physical line boundary. The next line must resume inside the string before + // any brace/comment/template logic runs. + // 行をまたいで継続する top-level の JS/TS 単/二重引用符文字列。 + // 次行は brace/comment/template の前に string 内として再開しなければならない。 + var activeJsTopLevelStringQuote = '\0'; + lexState.Reset(); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (line.Length == 0) + continue; + + char[]? masked = null; + char[] GetMaskedLine() => masked ??= line.ToCharArray(); + var pos = 0; + + while (pos < line.Length) + { + if (frames.TryPeek(out var active)) + { + if (active is BlockCommentFrame) + { + if (pos + 1 < line.Length && line[pos] == '*' && line[pos + 1] == '/') + { + // Blank the `*/` closer together with the body so + // template-hole block comments like `${/* f(); */ g()}` + // never leak `f` as a phantom reference. + // テンプレートホール内の `${/* f(); */ g()}` のような + // block comment で `f` が疑似参照として残らないよう、 + // `*/` 自体も本文と同様に空白化する。 + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + frames.Pop(); + pos += 2; + continue; + } + + // Blank the body so identifiers inside a template-hole + // block comment do not survive into reference extraction. + // ホール内 block comment 本文は空白化し、内部の識別子が + // 参照抽出まで残らないようにする。 + GetMaskedLine()[pos] = ' '; + pos++; + continue; + } + + if (active is JsTemplateLiteralFrame tplFrame) + { + if (line[pos] == '\\') + { + ReplaceWithSpaces(GetMaskedLine(), pos, Math.Min(2, line.Length - pos)); + pos += Math.Min(2, line.Length - pos); + continue; + } + + if (pos + 1 < line.Length && line[pos] == '$' && line[pos + 1] == '{') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + pos += 2; + frames.Push(new JsTemplateHoleFrame()); + lexState = default; + lexState.Reset(); + continue; + } + + if (line[pos] == '`') + { + // Restore the lex state captured when this template opened so the + // paren stack, class-header hint, case-label hint, etc. carry + // through to the token after the closing backtick. + // テンプレート開始時に退避した lex state を復元し、閉じ backtick + // の後ろに paren stack や class header hint、case label hint を + // 引き継ぐ。 + GetMaskedLine()[pos] = ' '; + pos++; + lexState = tplFrame.SavedLexState; + lexState.SetKind(JsPrevTokenKind.Literal); + frames.Pop(); + continue; + } + + GetMaskedLine()[pos] = ' '; + pos++; + continue; + } + + if (active is JsTemplateHoleFrame holeFrame) + { + if (activeJsHoleStringQuote != '\0') + { + pos = MaskJsTemplateHoleString(line, pos, GetMaskedLine(), activeJsHoleStringQuote, startsInsideString: true, out var continuesOnNextLine); + if (continuesOnNextLine) + break; + + activeJsHoleStringQuote = '\0'; + lexState.SetKind(JsPrevTokenKind.Literal); + continue; + } + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') + { + // Blank the `//` comment tail so later passes (including the + // multi-line tagged-template backward scan that reads prior + // `lines[li]`) cannot mistake a comment identifier for code. + // `//` コメント以降を空白化し、後続処理 — とくに前行の + // `lines[li]` を読む複数行タグ走査 — がコメント内の識別子を + // コードと誤認しないようにする。 + ReplaceWithSpaces(GetMaskedLine(), pos, line.Length - pos); + break; + } + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') + { + // Blank the `/*` opener so the hole's block comment + // span is fully whitespace for downstream extraction. + // ホールの block comment 開始 `/*` を空白化し、 + // 下流抽出から見えるスパン全体を空白化する。 + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + frames.Push(new BlockCommentFrame()); + pos += 2; + continue; + } + + if (line[pos] == '/' && CanStartJsRegexLiteral(lexState)) + { + pos = SkipJsRegexLiteral(line, pos); + lexState.SetKind(JsPrevTokenKind.Literal); + continue; + } + + if (line[pos] == '`') + { + // Save the hole's lex state so the closing backtick can + // restore paren/state context for the token that follows. + // hole 側の lex state を退避し、閉じ backtick 後に paren + // などの context を元に戻せるようにする。 + if (collectTaggedTemplateHits) + TryRecordJsTaggedTemplateHit(lines, GetMaskedLine(), i, pos, ref taggedTemplateHits, allowGenericTag); + pos++; + frames.Push(new JsTemplateLiteralFrame { SavedLexState = lexState }); + lexState = default; + lexState.Reset(); + continue; + } + + if (line[pos] == '"' || line[pos] == '\'') + { + var quote = line[pos]; + pos = MaskJsTemplateHoleString(line, pos, GetMaskedLine(), quote, startsInsideString: false, out var continuesOnNextLine); + if (continuesOnNextLine) + { + activeJsHoleStringQuote = quote; + break; + } + + lexState.SetKind(JsPrevTokenKind.Literal); + continue; + } + + if (line[pos] == '{') + { + holeFrame.NestedBraceDepth++; + // Classify the nested `{` as expression brace (object literal + // or `() => ({})` body) vs. statement block (arrow body + // `=> {...}`, `if (x) {...}`, or `class Foo {...}`). Block + // braces preserve `}`→regex behavior; expression braces set + // `}`→division so `${({a:1} / 2)}` stays parseable. A pending + // class header always opens a class body, regardless of what + // identifier or `extends` clause token came last. + // ネスト `{` を expression brace(object literal / `() => ({})`) + // と statement block(arrow body / `if (x) {}` / `class Foo {}`) + // に分類する。block は `}` の次の `/` を regex にし、expression + // は division にする。これで `${({a:1} / 2)}` が壊れない。 + // class header pending 中は直前トークンが何であっても class + // body とみなす。 + var isExpressionBrace = !lexState.ClassHeaderPending + && !lexState.CaseColonBlockPending + && IsJsExpressionBraceContext(lexState); + lexState.ClassHeaderPending = false; + lexState.CaseColonBlockPending = false; + // A new block scope starts; clear any half-complete case + // label tracking so a stray `case` keyword seen earlier + // does not tag the wrong `:` in the inner scope. + // 新しい block scope の開始。半端な case ラベル追跡を + // クリアし、外側の `case` が内側の無関係な `:` を + // case-label colon と誤判定しないようにする。 + lexState.CaseLabelPending = false; + holeFrame.InnerBraceIsExpression.Push(isExpressionBrace); + pos++; + lexState.SetKind(JsPrevTokenKind.Other); + continue; + } + + if (line[pos] == '}') + { + if (holeFrame.NestedBraceDepth == 0) + { + // Mask the hole's closing `}` to keep brace balance intact + // for downstream symbol-body brace counting. + // ホールを閉じる `}` もマスクし、後段の symbol 本体の + // brace 数え上げで brace バランスを崩さないようにする。 + GetMaskedLine()[pos] = ' '; + frames.Pop(); + pos++; + lexState.SetKind(JsPrevTokenKind.Other); + continue; + } + + holeFrame.NestedBraceDepth--; + pos++; + var wasExpression = holeFrame.InnerBraceIsExpression.Count > 0 + && holeFrame.InnerBraceIsExpression.Pop(); + // Expression brace close → division context (CloseBrace). + // Block brace close → preserve regex-legal state (Other) so + // `if (x) {} /regex/` inside an arrow body is still skipped + // correctly and does not consume backticks as division noise. + // expression brace の閉じは CloseBrace で division 優先。 + // block brace の閉じは Other に戻し、arrow body 内の + // `if (x) {} /regex/` でも regex を正しく取り込めるようにする。 + lexState.SetKind(wasExpression ? JsPrevTokenKind.CloseBrace : JsPrevTokenKind.Other); + continue; + } + + pos = AdvanceJsToken(line, pos, ref lexState); + continue; + } + } + + if (activeJsTopLevelStringQuote != '\0') + { + pos = MaskJsTemplateHoleString(line, pos, GetMaskedLine(), activeJsTopLevelStringQuote, startsInsideString: true, out var continuesOnNextLine); + if (continuesOnNextLine) + break; + + activeJsTopLevelStringQuote = '\0'; + lexState.SetKind(JsPrevTokenKind.Literal); + continue; + } + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') + { + // Blank the `//` comment tail so the multi-line tagged-template + // backward scan (which reads prior `lines[li]` directly) cannot + // mistake a comment identifier like `comment` in + // `return tag // trailing comment` for the tag itself. + // `//` コメント以降を空白化し、前行の `lines[li]` を直接読む複数行 + // タグ走査が `return tag // trailing comment` の `comment` のような + // コメント内識別子をタグと誤認しないようにする。 + ReplaceWithSpaces(GetMaskedLine(), pos, line.Length - pos); + break; + } + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') + { + // Blank the top-level `/*` opener to match the hole-side + // behavior and keep downstream extraction consistent. + // 先頭レベルでも `/*` 開始を空白化し、ホール側と挙動を揃える。 + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + frames.Push(new BlockCommentFrame()); + pos += 2; + continue; + } + + if (line[pos] == '/' && CanStartJsRegexLiteral(lexState)) + { + pos = SkipJsRegexLiteral(line, pos); + lexState.SetKind(JsPrevTokenKind.Literal); + continue; + } + + if (line[pos] == '`') + { + // Save the top-level lex state so the closing backtick can restore + // the paren stack / statement-head hints that preceded the template. + // テンプレート直前の lex state を退避し、閉じ backtick で paren + // stack や statement-head hint を復元できるようにする。 + if (collectTaggedTemplateHits) + TryRecordJsTaggedTemplateHit(lines, GetMaskedLine(), i, pos, ref taggedTemplateHits, allowGenericTag); + GetMaskedLine()[pos] = ' '; + pos++; + frames.Push(new JsTemplateLiteralFrame { SavedLexState = lexState }); + lexState = default; + lexState.Reset(); + continue; + } + + if (line[pos] == '"' || line[pos] == '\'') + { + var quote = line[pos]; + var start = pos; + pos = SkipJsSingleLineStringContinuation(line, pos, out var continuesOnNextLine); + if (continuesOnNextLine) + { + ReplaceWithSpaces(GetMaskedLine(), start, pos - start); + activeJsTopLevelStringQuote = quote; + break; + } + + lexState.SetKind(JsPrevTokenKind.Literal); + continue; + } + + pos = AdvanceJsToken(line, pos, ref lexState); + } + + if (masked is not null) + lines[i] = new string(masked); + } + + // Post-pass: drop `of` hits whose enclosing `for (...)` header is a for-of or + // for-await-of loop. `of` is not a reserved word in ECMAScript, so `const of = + // ...; of\`x\`` must stay visible — only the loop-header form should be silenced. + // The check is done against the fully masked buffer so the template body cannot + // inject false tokens, and it walks across line boundaries to cover multi-line + // headers like `for (\n const ch of \`abc\`\n)`. + // 後段パス: 囲む `for (...)` ヘッダが for-of / for-await-of の場合のみ `of` ヒット + // を除外する。`of` は ECMAScript の予約語ではなく `const of = ...; of\`x\`` は正当 + // なので、ループヘッダ形だけを静かにする必要がある。マスク後バッファに対して + // 検査するため template 本体が誤トークンを混入させることがなく、 + // `for (\n const ch of \`abc\`\n)` のような複数行ヘッダも行境界を越えて処理する。 + if (collectTaggedTemplateHits && taggedTemplateHits != null && taggedTemplateHits.Count > 0) + FilterJsForOfHeaderHits(lines, taggedTemplateHits); + } + +} diff --git a/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JvmAndSwiftStrings.cs b/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JvmAndSwiftStrings.cs new file mode 100644 index 000000000..2564a95e9 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.JvmAndSwiftStrings.cs @@ -0,0 +1,974 @@ +namespace CodeIndex.Indexer; + +internal static partial class StructuralLineMasker +{ + private static int MaskSwiftSingleLineRawString(string line, int startIndex, int hashCount, char[] masked) + { + // Mask leading `#"` (hashCount + 1 chars). + ReplaceWithSpaces(masked, startIndex, hashCount + 1); + var q = startIndex + hashCount + 1; + while (q < line.Length) + { + // Closing `"#` with matching hash count. + // 一致 hash 数の閉じ `"#`。 + if (line[q] == '"' && HasHashRun(line, q + 1, hashCount)) + { + ReplaceWithSpaces(masked, q, 1 + hashCount); + return q + 1 + hashCount; + } + // Interpolation hole opener `\#(` with matching hash run. Mask the + // `\#(` opener but preserve the body until the matching `)` so the + // real call inside the hole survives masking. + // 一致 hash 数の補間ホール `\#(`。`\#(` 自体はマスクし、本文は本物の + // call を残すために保存し、対応する `)` で閉じる。 + if (line[q] == '\\' + && HasHashRun(line, q + 1, hashCount) + && q + 1 + hashCount < line.Length + && line[q + 1 + hashCount] == '(') + { + ReplaceWithSpaces(masked, q, 2 + hashCount); + q += 2 + hashCount; + var holeDepth = 0; + while (q < line.Length) + { + // Nested single-line raw string inside the hole. Recurse so the + // nested `\(...)` bodies remain visible too. + // ホール内に入れ子の単行 raw 文字列があれば再帰処理し、 + // 内側の `\(...)` 本文も見えるままにする。 + var nestedHashCount = CountRun(line, q, '#'); + if (nestedHashCount > 0 + && q + nestedHashCount < line.Length + && line[q + nestedHashCount] == '"') + { + q = MaskSwiftSingleLineRawString(line, q, nestedHashCount, masked); + continue; + } + + if (line[q] == '"' || line[q] == '\'') + { + q = SkipJsSingleLineString(line, q); + continue; + } + if (line[q] == '(') + { + holeDepth++; + q++; + continue; + } + if (line[q] == ')') + { + if (holeDepth == 0) + { + masked[q] = ' '; + q++; + break; + } + holeDepth--; + q++; + continue; + } + q++; + } + continue; + } + masked[q] = ' '; + q++; + } + return q; + } + + private static int SkipJsSingleLineString(string line, int startIndex) + { + var quote = line[startIndex]; + var p = startIndex + 1; + while (p < line.Length && line[p] != quote) + { + if (line[p] == '\\' && p + 1 < line.Length) + p += 2; + else + p++; + } + if (p < line.Length) + p++; + return p; + } + + private static int SkipJsSingleLineStringContinuation(string line, int startIndex, out bool continuesOnNextLine) + { + var quote = line[startIndex]; + var p = startIndex + 1; + while (p < line.Length && line[p] != quote) + { + if (line[p] == '\\') + { + if (p + 1 == line.Length) + { + continuesOnNextLine = true; + return p + 1; + } + + if (p + 2 == line.Length && line[p + 1] == '\r') + { + continuesOnNextLine = true; + return line.Length; + } + + p += 2; + continue; + } + + p++; + } + + if (p < line.Length) + p++; + + continuesOnNextLine = false; + return p; + } + + // Kotlin multi-line raw string literals: """...""". + // Body is raw (no backslash escape processing). Interpolation: $identifier and + // ${expression}. Only ${expr} hole contents are preserved so downstream reference + // extraction still sees real call edges; $ident is a bare identifier that cannot + // be a call by itself, so masking the surrounding body is safe. + // Regression target: issue #385. + // Kotlin の複数行 raw 文字列 """...""" を扱う。本文は raw(\ エスケープなし)。 + // 補間は $identifier と ${expression}。${expr} ホール内の本物の呼び出しを + // 参照抽出に残すため、ホール内は保存する。$ident は単独識別子で call にならないため + // 周囲本体と一緒にマスクしてよい。回帰対象: issue #385。 + private static void MaskKotlinTripleStringContents(string[] lines) + { + var insideTriple = false; + var blockCommentDepth = 0; + // Hole state persists across lines so multi-line ${ ... } bodies keep real + // call edges and do not accidentally close at the wrong `}`. + // -1 when outside a hole, >=0 = nested `{` depth inside the hole (0 = top). + // ホール状態は行をまたいで保持する。ホール外は -1、ホール内は `{` 深さ(0 が最上位)。 + var holeBraceDepth = -1; + // Persistent across lines: a nested `"""..."""` literal opened inside the + // current `${ ... }` hole. While true, the nested literal acts like its own + // mini triple body — `${...}` holes inside it still preserve real call + // edges (closes #996), but body chars between holes are masked through to + // the next `"""` closer so call-shaped identifiers cannot leak (closes #992). + // ホール内に開いた nested triple-quoted string の状態。nested literal 内も + // 自身の `${...}` ホールでは本物の call を残しつつ、本文は次の `"""` まで + // 空白化して phantom call の漏れを防ぐ。 + var nestedTripleOpen = false; + // -1 when not inside a nested-triple ${...} hole, >=0 = brace depth of that + // inner hole. The inner hole preserves real call edges inside the nested + // triple-quoted literal. + // nested triple 内 `${...}` ホールの brace 深さ。-1 はホール外。 + var nestedHoleBraceDepth = -1; + // Defensive depth tracking for triple-quoted literals opened 3+ levels deep + // (i.e. inside the nested triple's own `${...}` hole). >0 = current 3+ deep + // body. While >0, every char is masked and `"""` toggles depth so phantom + // calls cannot leak. Real calls 4+ levels deep are not preserved — full + // stack tracking would be needed for that — but masking soundness is. + // 3 段以上のネスト triple に対する防御的な深さ追跡。> 0 の間は本文をマスクし、 + // 4 段以上の本物の call は保持しないが、phantom の漏れは防ぐ。 + var deepNestedTripleDepth = 0; + var deepNestedTripleHashCounts = new Stack(); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (line.Length == 0) + continue; + + char[]? masked = null; + char[] GetMaskedLine() => masked ??= line.ToCharArray(); + var pos = 0; + + while (pos < line.Length) + { + if (blockCommentDepth > 0) + { + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + blockCommentDepth++; + pos += 2; + continue; + } + if (pos + 1 < line.Length && line[pos] == '*' && line[pos + 1] == '/') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + blockCommentDepth--; + pos += 2; + continue; + } + GetMaskedLine()[pos] = ' '; + pos++; + continue; + } + + if (insideTriple) + { + if (holeBraceDepth >= 0) + { + // Inside ${expr} hole: preserve body. Block comments and line + // comments must be recognized first so a legal `/* } */` inside + // the hole does not close the hole at the comment body's `}`. + // Nested single-line strings and char literals are also skipped + // so their `}` does not close the hole, and nested `{` / `}` + // are tracked for lambdas / object literals. + // ${expr} ホール内: 本文を保存。block / line コメントを先に + // 認識して `/* } */` のようなコメント内 `}` でホールを早閉じ + // しないようにする。単行文字列・char リテラルも同様にスキップし、 + // lambda / object literal 用のネスト `{` / `}` を追跡する。 + if (nestedTripleOpen) + { + if (nestedHoleBraceDepth >= 0) + { + // Inside the nested triple's own ${expr} hole: preserve + // body chars so real call edges land in the reference + // graph. Closes #996. + // nested triple 内の `${expr}` ホール内: 本文を保存し、 + // 本物の call が reference graph に届くようにする。 + if (deepNestedTripleDepth > 0) + { + // 3+ level deep triple body: keep masking through + // nested open/close pairs so a 4th opener cannot + // unwind the 3-deep frame early. + // 3 段以上深い triple 本文: ネスト open/close を + // 追跡し、4 段目の opener で 3 段深い frame が + // 早抜けしないようにする。 + if (pos + 2 < line.Length + && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"') + { + var looksLikeNestedOpen = LooksLikeDeepTripleOpenerContext(lines, i, pos, 3); + if (looksLikeNestedOpen) + { + ReplaceWithSpaces(GetMaskedLine(), pos, 3); + pos += 3; + deepNestedTripleDepth++; + deepNestedTripleHashCounts.Push(0); + continue; + } + + ReplaceWithSpaces(GetMaskedLine(), pos, 3); + pos += 3; + deepNestedTripleDepth--; + if (deepNestedTripleHashCounts.Count > 0) + deepNestedTripleHashCounts.Pop(); + continue; + } + GetMaskedLine()[pos] = ' '; + pos++; + continue; + } + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') + { + ReplaceWithSpaces(GetMaskedLine(), pos, line.Length - pos); + pos = line.Length; + continue; + } + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + blockCommentDepth = 1; + pos += 2; + continue; + } + // 3rd-level triple opener inside the inner hole. + // Detect before the single-line-string skipper so the + // leading `"` does not advance us into the literal + // body via SkipJsSingleLineString and break paren / brace + // counting. + // 3 段目の triple opener。先頭 `"` が単行スキッパーへ + // 渡って literal 本体に進まないよう先に検知する。 + if (pos + 2 < line.Length + && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 3); + pos += 3; + deepNestedTripleDepth = 1; + deepNestedTripleHashCounts.Push(0); + continue; + } + if (line[pos] == '"' || line[pos] == '\'') + { + pos = SkipJsSingleLineString(line, pos); + continue; + } + if (line[pos] == '{') + { + nestedHoleBraceDepth++; + pos++; + continue; + } + if (line[pos] == '}') + { + if (nestedHoleBraceDepth == 0) + { + GetMaskedLine()[pos] = ' '; + nestedHoleBraceDepth = -1; + pos++; + continue; + } + nestedHoleBraceDepth--; + pos++; + continue; + } + pos++; + continue; + } + + // Inside a nested `"""..."""` literal opened earlier in this + // outer hole. Recognize a closing `"""`, an opening `${...}` + // hole inside the nested literal (so real calls inside it + // still reach the reference graph), and otherwise mask. + // 外側ホール内で開いた nested triple 本体。閉じ `"""`、内側 + // `${...}` ホール、それ以外は body としてマスク。 + if (pos + 2 < line.Length + && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 3); + pos += 3; + nestedTripleOpen = false; + nestedHoleBraceDepth = -1; + deepNestedTripleDepth = 0; + deepNestedTripleHashCounts.Clear(); + continue; + } + if (pos + 1 < line.Length && line[pos] == '$' && line[pos + 1] == '{') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + nestedHoleBraceDepth = 0; + pos += 2; + continue; + } + GetMaskedLine()[pos] = ' '; + pos++; + continue; + } + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') + { + ReplaceWithSpaces(GetMaskedLine(), pos, line.Length - pos); + pos = line.Length; + continue; + } + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + blockCommentDepth = 1; + pos += 2; + continue; + } + + // Nested `"""..."""` literal opener inside the hole. Detect + // before the single-line-string skipper so the first `"` does + // not advance us into the literal body via `SkipJsSingleLineString`. + // ホール内で開く nested `"""..."""` の opener。先頭 `"` が単行 + // 文字列スキッパーに渡って literal 本体へ進まないよう先に検知する。 + if (pos + 2 < line.Length + && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 3); + pos += 3; + nestedTripleOpen = true; + nestedHoleBraceDepth = -1; + continue; + } + + if (line[pos] == '"' || line[pos] == '\'') + { + pos = SkipJsSingleLineString(line, pos); + continue; + } + + if (line[pos] == '{') + { + holeBraceDepth++; + pos++; + continue; + } + + if (line[pos] == '}') + { + if (holeBraceDepth == 0) + { + GetMaskedLine()[pos] = ' '; + holeBraceDepth = -1; + pos++; + continue; + } + + holeBraceDepth--; + pos++; + continue; + } + + pos++; + continue; + } + + if (pos + 2 < line.Length + && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 3); + pos += 3; + insideTriple = false; + // Defensive: any open nested-triple state is owned by the just- + // closed outer triple, so reset it as well. + // 防御的に、外側 triple を閉じた時点で nested-triple 状態も解除する。 + nestedTripleOpen = false; + nestedHoleBraceDepth = -1; + deepNestedTripleDepth = 0; + deepNestedTripleHashCounts.Clear(); + continue; + } + + if (pos + 1 < line.Length && line[pos] == '$' && line[pos + 1] == '{') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + holeBraceDepth = 0; + pos += 2; + continue; + } + + GetMaskedLine()[pos] = ' '; + pos++; + continue; + } + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') + break; + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + blockCommentDepth = 1; + pos += 2; + continue; + } + + if (pos + 2 < line.Length + && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 3); + pos += 3; + insideTriple = true; + continue; + } + + if (line[pos] == '"' || line[pos] == '\'') + { + pos = SkipJsSingleLineString(line, pos); + continue; + } + + pos++; + } + + if (masked is not null) + lines[i] = new string(masked); + } + } + + // Swift multi-line string literals: """...""" and extended """#"""..."""# forms. + // Plain form supports \(expr) interpolation; N-hash extended form needs \#(expr) + // (matching hash count). Interpolation hole contents are preserved so downstream + // reference extraction keeps real call edges inside \(...). + // Regression target: issue #385. + // Swift の複数行文字列 """...""" と拡張 #"""..."""# 系を扱う。通常形の補間は + // \(expr)、N 個の # 付き拡張形は \#(expr)(個数一致)。\(...) ホール内は保存し、 + // 本物の call を参照抽出に見せる。回帰対象: issue #385。 + private static void MaskSwiftMultilineStringContents(string[] lines) + { + var insideTriple = false; + // 0 for plain """...""", N for the extended """#"""..."""# variant. + // 通常 """...""" は 0、拡張形は一致させる # 個数 N。 + var tripleHashCount = 0; + var blockCommentDepth = 0; + // -1 when outside a \(...) interpolation hole, >=0 = nested `(` depth. + // \(...) ホール外は -1、ホール内は `(` 深さ。 + var holeParenDepth = -1; + // Persistent across lines: a nested `"""..."""` or `#"""..."""#` literal + // opened inside the current `\(...)` hole. -1 when no nested triple is + // open; >=0 = leading `#` count required at the matching close. While set, + // the nested literal acts like its own mini triple body — its own + // `\(...)` (or `\#(...)` / `\##(...)` etc.) interpolation holes still + // preserve real call edges (closes #996), and body chars between holes + // are masked through to the close so phantom calls cannot leak (closes #992). + // ホール内に開いた nested `"""..."""` / `#"""..."""#` の状態。-1 は未オープン、 + // 0 以上は閉じに必要な `#` 個数。set 中は内部 `\(...)` ホールでも本物の call を残す。 + var nestedTripleHashCount = -1; + // -1 when not inside the nested triple's own `\(...)` hole, >=0 = paren + // depth of that inner hole. Preserves real call edges inside the nested + // literal. + // nested triple 内 `\(...)` ホールの paren 深さ。-1 はホール外。 + var nestedHoleParenDepth = -1; + // Defensive depth tracking for triple-quoted literals opened 3+ levels deep + // (i.e. inside the nested triple's own `\(...)` hole). >0 = current 3+ deep + // body. While >0, every char is masked and the close requires the same + // hash count as the deep open so phantom calls cannot leak even when the + // deep triple is hash-delimited (`#"""..."""#` etc.). Closes #1000 — the + // earlier version only matched plain `"""` for the close and could exit + // the deep state at the wrong delimiter when the deep triple was raw. + // Real calls 4+ levels deep are not preserved — full stack tracking would + // be needed for that — but masking soundness is. + // 3 段以上のネスト triple に対する防御的な深さ追跡。 + var deepNestedTripleDepth = 0; + // Hash count required at each deep triple's matching close. Stack top + // tracks the currently-open deep frame. + // 各 deep triple の閉じに必要な hash 個数。スタック頂点が現在の deep frame。 + var deepNestedTripleHashCounts = new Stack(); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (line.Length == 0) + continue; + + char[]? masked = null; + char[] GetMaskedLine() => masked ??= line.ToCharArray(); + var pos = 0; + + while (pos < line.Length) + { + if (blockCommentDepth > 0) + { + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + blockCommentDepth++; + pos += 2; + continue; + } + if (pos + 1 < line.Length && line[pos] == '*' && line[pos + 1] == '/') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + blockCommentDepth--; + pos += 2; + continue; + } + GetMaskedLine()[pos] = ' '; + pos++; + continue; + } + + if (insideTriple) + { + if (holeParenDepth >= 0) + { + // Inside \(expr) hole: preserve body. Block comments and line + // comments must be recognized first so a legal `/* ) */` inside + // the hole does not close the hole at the comment body's `)`. + // Nested single-line strings are also skipped so their `)` does + // not close the hole, and nested `(` / `)` are tracked. + // \(expr) ホール内: 本文を保存。block / line コメントを先に + // 認識して `/* ) */` のようなコメント内 `)` でホールを早閉じ + // しないようにする。単行文字列もスキップし、ネスト `(` / `)` も追跡する。 + if (nestedTripleHashCount >= 0) + { + if (nestedHoleParenDepth >= 0) + { + // Inside the nested triple's own `\(...)` hole: preserve + // body chars so real call edges land in the reference + // graph. Closes #996. + // nested triple 内の `\(...)` ホール内: 本物の call を残す。 + if (deepNestedTripleDepth > 0) + { + // 3+ level deep triple body: mask through nested + // opener/close pairs so a 4th opener cannot unwind + // the 3-deep frame early. + // 3 段以上深い triple 本文: ネスト open/close を + // 追跡し、4 段目の opener で 3 段深い frame が + // 早抜けしないようにする。 + var deepBodyHashes = CountRun(line, pos, '#'); + if (pos + 2 < line.Length + && line[pos] == '"' + && line[pos + 1] == '"' + && line[pos + 2] == '"') + { + var closeHashCount = CountRun(line, pos + 3, '#'); + if (closeHashCount > 0 + && !LooksLikeDeepTripleOpenerContext(lines, i, pos, 3 + closeHashCount)) + { + ReplaceWithSpaces(GetMaskedLine(), pos, 3 + closeHashCount); + pos += 3 + closeHashCount; + deepNestedTripleDepth--; + if (deepNestedTripleHashCounts.Count > 0) + deepNestedTripleHashCounts.Pop(); + continue; + } + var currentDeepHashCount = deepNestedTripleHashCounts.Count > 0 + ? deepNestedTripleHashCounts.Peek() + : 0; + if (closeHashCount == 0 + && currentDeepHashCount == 0 + && !LooksLikeDeepTripleOpenerContext(lines, i, pos, 3)) + { + ReplaceWithSpaces(GetMaskedLine(), pos, 3); + pos += 3; + deepNestedTripleDepth--; + if (deepNestedTripleHashCounts.Count > 0) + deepNestedTripleHashCounts.Pop(); + continue; + } + } + if (pos + 2 < line.Length + && line[pos] == '"' + && line[pos + 1] == '"' + && line[pos + 2] == '"' + && LooksLikeDeepTripleOpenerContext(lines, i, pos, 3)) + { + ReplaceWithSpaces(GetMaskedLine(), pos, 3); + pos += 3; + deepNestedTripleDepth++; + deepNestedTripleHashCounts.Push(0); + continue; + } + if (deepBodyHashes > 0 + && pos + deepBodyHashes + 2 < line.Length + && line[pos + deepBodyHashes] == '"' + && line[pos + deepBodyHashes + 1] == '"' + && line[pos + deepBodyHashes + 2] == '"') + { + var looksLikeNestedOpen = LooksLikeDeepTripleOpenerContext(lines, i, pos, deepBodyHashes + 3); + if (looksLikeNestedOpen) + { + ReplaceWithSpaces(GetMaskedLine(), pos, deepBodyHashes + 3); + pos += deepBodyHashes + 3; + deepNestedTripleDepth++; + deepNestedTripleHashCounts.Push(deepBodyHashes); + continue; + } + + } + + GetMaskedLine()[pos] = ' '; + pos++; + continue; + } + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') + { + ReplaceWithSpaces(GetMaskedLine(), pos, line.Length - pos); + pos = line.Length; + continue; + } + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + blockCommentDepth = 1; + pos += 2; + continue; + } + // 3rd-level triple opener (optionally with leading `#`) + // inside the inner hole. Detect before the single-line + // string skipper so the leading `"` does not advance into + // the literal body via SkipJsSingleLineString and break + // paren counting. + // 3 段目の triple opener。先頭 `"` が単行スキッパーへ + // 渡って literal 本体に進まないよう先に検知する。 + var deepHashes = CountRun(line, pos, '#'); + if (pos + deepHashes + 2 < line.Length + && line[pos + deepHashes] == '"' + && line[pos + deepHashes + 1] == '"' + && line[pos + deepHashes + 2] == '"') + { + ReplaceWithSpaces(GetMaskedLine(), pos, deepHashes + 3); + pos += deepHashes + 3; + deepNestedTripleDepth = 1; + deepNestedTripleHashCounts.Push(deepHashes); + continue; + } + // Single-line `#"..."#` raw string inside the inner hole. + // Preserve any matching `\#(...)` interpolation hole bodies + // so real call edges inside the raw string still reach the + // reference graph. Closes #1001. + // 単行 `#"..."#` 拡張 raw 文字列。内側の `\#(...)` ホール本文は + // 残し、本物の call を reference graph に届ける。 + if (deepHashes > 0 + && pos + deepHashes < line.Length + && line[pos + deepHashes] == '"') + { + pos = MaskSwiftSingleLineRawString(line, pos, deepHashes, GetMaskedLine()); + continue; + } + if (line[pos] == '"' || line[pos] == '\'') + { + pos = SkipJsSingleLineString(line, pos); + continue; + } + if (line[pos] == '(') + { + nestedHoleParenDepth++; + pos++; + continue; + } + if (line[pos] == ')') + { + if (nestedHoleParenDepth == 0) + { + GetMaskedLine()[pos] = ' '; + nestedHoleParenDepth = -1; + pos++; + continue; + } + nestedHoleParenDepth--; + pos++; + continue; + } + pos++; + continue; + } + + // Inside a nested `"""..."""` (optionally hash-delimited) literal + // opened earlier in this outer hole. Recognize the matching close, + // a `\(...)` (or `\#(...)` / `\##(...)` etc.) interpolation hole + // opener inside the nested literal so real calls inside it still + // reach the reference graph, and otherwise mask the body. + // 外側ホール内で開いた nested triple 本体。一致 hash 数の `"""` + // クローザ、内側 `\(...)` ホール(hash 数一致)、それ以外は body + // としてマスク。 + if (pos + 2 < line.Length + && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"' + && HasHashRun(line, pos + 3, nestedTripleHashCount)) + { + ReplaceWithSpaces(GetMaskedLine(), pos, 3 + nestedTripleHashCount); + pos += 3 + nestedTripleHashCount; + nestedTripleHashCount = -1; + nestedHoleParenDepth = -1; + deepNestedTripleDepth = 0; + deepNestedTripleHashCounts.Clear(); + continue; + } + if (line[pos] == '\\' + && HasHashRun(line, pos + 1, nestedTripleHashCount) + && pos + 1 + nestedTripleHashCount < line.Length + && line[pos + 1 + nestedTripleHashCount] == '(') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2 + nestedTripleHashCount); + pos += 2 + nestedTripleHashCount; + nestedHoleParenDepth = 0; + continue; + } + // Plain (non-raw) nested triple: `\\` is a literal backslash. + // 通常 nested triple 内: `\\` は literal backslash。 + if (nestedTripleHashCount == 0 && line[pos] == '\\' && pos + 1 < line.Length) + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + pos += 2; + continue; + } + GetMaskedLine()[pos] = ' '; + pos++; + continue; + } + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') + { + ReplaceWithSpaces(GetMaskedLine(), pos, line.Length - pos); + pos = line.Length; + continue; + } + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + blockCommentDepth = 1; + pos += 2; + continue; + } + + // Nested triple-quoted string opener inside the hole: optional + // leading `#` run then `"""`. Detect before the single-line-string + // skipper so the first `"` of `"""` does not advance into the body. + // ホール内で開く nested triple の opener。先頭 `"` が単行文字列 + // スキッパーに渡って literal 本体へ進まないよう先に検知する。 + var holeNestedHashes = CountRun(line, pos, '#'); + if (pos + holeNestedHashes + 2 < line.Length + && line[pos + holeNestedHashes] == '"' + && line[pos + holeNestedHashes + 1] == '"' + && line[pos + holeNestedHashes + 2] == '"') + { + ReplaceWithSpaces(GetMaskedLine(), pos, holeNestedHashes + 3); + pos += holeNestedHashes + 3; + nestedTripleHashCount = holeNestedHashes; + continue; + } + + // Single-line `#"..."#` extended raw string inside the outer + // hole. The body may contain unescaped `"`, `(`, and `)`, so + // the generic single-line skipper would stop at the first `"` + // and leave the remainder visible — breaking the outer hole's + // paren counting. Use the shared raw-string helper to mask + // through to the matching `"` close while preserving + // any `\(...)` interpolation hole bodies. Closes #1001. + // ホール内の単行 `#"..."#` 拡張 raw 文字列。body に `"` / `(` / `)` + // を含むため通常スキッパーは早すぎて止まる。共有ヘルパーで + // `"` クローザまでマスクし、`\(...)` ホール本文は残す。 + if (holeNestedHashes > 0 + && pos + holeNestedHashes < line.Length + && line[pos + holeNestedHashes] == '"') + { + pos = MaskSwiftSingleLineRawString(line, pos, holeNestedHashes, GetMaskedLine()); + continue; + } + + if (line[pos] == '"' || line[pos] == '\'') + { + pos = SkipJsSingleLineString(line, pos); + continue; + } + + if (line[pos] == '(') + { + holeParenDepth++; + pos++; + continue; + } + + if (line[pos] == ')') + { + if (holeParenDepth == 0) + { + GetMaskedLine()[pos] = ' '; + holeParenDepth = -1; + pos++; + continue; + } + + holeParenDepth--; + pos++; + continue; + } + + pos++; + continue; + } + + // Closing """[#...] with matching hash count. + // 閉じ """[#...](hash 数一致)。 + if (pos + 2 < line.Length + && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"' + && HasHashRun(line, pos + 3, tripleHashCount)) + { + ReplaceWithSpaces(GetMaskedLine(), pos, 3 + tripleHashCount); + pos += 3 + tripleHashCount; + insideTriple = false; + tripleHashCount = 0; + // Defensive: outer triple owns any nested-triple state from a + // hole, so reset it as well when the outer literal closes. + // 防御的に、外側 triple が閉じた時点で nested-triple 状態も解除する。 + nestedTripleHashCount = -1; + nestedHoleParenDepth = -1; + deepNestedTripleDepth = 0; + deepNestedTripleHashCounts.Clear(); + continue; + } + + if (line[pos] == '\\') + { + // \(expr) interpolation opener (for raw forms, needs matching + // `#` run: \#(, \##(, ...). + // \(expr) 補間の開始。拡張形では hash 数一致が必要: \#(、\##( など。 + if (HasHashRun(line, pos + 1, tripleHashCount) + && pos + 1 + tripleHashCount < line.Length + && line[pos + 1 + tripleHashCount] == '(') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2 + tripleHashCount); + pos += 2 + tripleHashCount; + holeParenDepth = 0; + continue; + } + + // Plain `"""..."""`: `\\` is a literal backslash — consume both + // so the second char cannot accidentally start a triple close or + // escape parser. + // 通常 `"""..."""`: `\\` は literal backslash。2 文字まとめて + // 消費し、2 文字目が triple close の一部と誤検出されないようにする。 + if (tripleHashCount == 0 && pos + 1 < line.Length) + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + pos += 2; + continue; + } + + // Extended form `#"""..."""#` (or more hashes): without a + // matching `\#` run the backslash is literal; advance one char. + // 拡張形 `#"""..."""#` など: hash 数が一致しない `\` は literal。 + GetMaskedLine()[pos] = ' '; + pos++; + continue; + } + + GetMaskedLine()[pos] = ' '; + pos++; + continue; + } + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') + break; + + if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') + { + ReplaceWithSpaces(GetMaskedLine(), pos, 2); + blockCommentDepth = 1; + pos += 2; + continue; + } + + // Extended / plain triple-quoted opener: optional leading `#` run then `"""`. + // 拡張または通常の triple 開始: 任意の `#` 列 + `"""`。 + var leadingHashes = CountRun(line, pos, '#'); + if (pos + leadingHashes + 2 < line.Length + && line[pos + leadingHashes] == '"' + && line[pos + leadingHashes + 1] == '"' + && line[pos + leadingHashes + 2] == '"') + { + ReplaceWithSpaces(GetMaskedLine(), pos, leadingHashes + 3); + pos += leadingHashes + 3; + insideTriple = true; + tripleHashCount = leadingHashes; + continue; + } + + // Single-line extended raw string `#"..."#` with matching `#` run. + // The body may contain unescaped `"`, so the generic single-quote + // skipper would stop too early. Use the shared helper to mask through + // to the matching `"` close while preserving any matching + // `\(...)` interpolation hole bodies (closes #1001). + // 単行の `#"..."#` 拡張 raw 文字列。共有ヘルパーで `"` まで + // マスクし、内側の `\(...)` ホール本文は残す。 + if (leadingHashes > 0 + && pos + leadingHashes < line.Length + && line[pos + leadingHashes] == '"') + { + pos = MaskSwiftSingleLineRawString(line, pos, leadingHashes, GetMaskedLine()); + continue; + } + + if (line[pos] == '"') + { + pos = SkipJsSingleLineString(line, pos); + continue; + } + + pos++; + } + + if (masked is not null) + lines[i] = new string(masked); + } + } + + // Scala multi-line string literals: """...""". Only interpolator-prefixed forms + // (s""", f""", raw""", or any identifier-prefixed form) interpret $ident / ${expr} + // holes; plain """...""" is a raw literal with no interpolation. ${expr} hole + // contents are preserved so downstream reference extraction keeps real call + // edges inside ${...}; bare $ident is not a call and is masked with the body. + // Regression target: issue #385. + // Scala の複数行文字列 """..."""。補間は interpolator prefix(`s"""` / `f"""` / + // `raw"""`、または任意の識別子 prefix)のときだけ有効。プレーン """...""" は + // 補間なしの raw。${expr} ホール内は本物の call を参照抽出に残すため保存、 + // `$ident` は単独識別子で call にならないため本体とともにマスクする。 + // 回帰対象: issue #385。 + private static bool IsIdentifierPart(char c) => + c == '_' || char.IsLetterOrDigit(c); +} diff --git a/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.cs b/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.cs index 9d632ed31..e8a855df3 100644 --- a/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.cs +++ b/src/CodeIndex/Indexer/References/Support/StructuralLineMasker.cs @@ -890,2176 +890,4 @@ private static int SkipRustSingleLineString(string line, int startIndex) // flip the following `/` from division to regex literal. // 1 行内の JS/TS regex 判定用 state。直前の識別子語も保持し、`return` / `throw` / // `typeof` など regex-prefix keyword の後の `/` を division ではなく regex として扱う。 - private enum JsPrevTokenKind { None, Identifier, Numeric, Literal, CloseParen, StatementHeadCloseParen, CloseBracket, CloseBrace, Arrow, Other } - - private struct JsLexState - { - public JsPrevTokenKind PrevTokenKind; - public string PrevIdentifier; - // Tracks whether each open `(` was preceded by a statement-head keyword - // (`if`/`while`/`for`/`switch`/`catch`/`with`). After the matching `)`, - // a following `/` begins a regex literal, not division. - // 各 `(` の直前が statement-head キーワード(`if` / `while` / `for` / - // `switch` / `catch` / `with`)だったかを追跡し、対応する `)` の直後に - // 続く `/` を division ではなく regex literal として扱えるようにする。 - public Stack ParenStatementHead; - // True after a declaration keyword (`class`, TypeScript `enum` / - // `interface` / `namespace` / `module`) until the next `{` opens its - // body. Forces `class Foo {}`, `enum Local {}`, `interface Local {}`, - // `namespace Local {}`, and `module Local {}` to be classified as a - // statement block instead of an object-literal expression brace, so - // the matching `}` stays regex-legal and a following `/regex/` does - // not flip to division and swallow backticks as a phantom template - // opener. - // `class` や TypeScript の `enum` / `interface` / `namespace` / - // `module` キーワードの後から次の `{` で body が開くまで true。 - // `class Foo {}` / `enum Local {}` / `interface Local {}` / - // `namespace Local {}` / `module Local {}` の `{` を object literal - // ではなく statement block として扱わせ、対応する `}` を regex-legal - // に保つことで、続く `/regex/` が division に倒れて regex 本文の - // backtick を phantom template 開始として読んでしまうのを防ぐ。 - public bool ClassHeaderPending; - // True after `case` / `default` keyword and cleared at the first `:` at - // paren depth 0. Used to recognize the following `:` as a case-label - // colon (not object-key / ternary / type-annotation colon). - // `case` / `default` キーワード直後に true、paren 深さ 0 の `:` で解除。 - // 以降の `:` が case ラベル終端の `:` か(object key / ternary / type - // annotation の `:` でないか)を区別するために使う。 - public bool CaseLabelPending; - // Paren depth captured when `case` / `default` was seen. The matching - // case-label `:` appears at the same depth; a `:` at a deeper paren - // level belongs to an object-literal key, ternary, or type annotation - // inside the case expression and must not flip CaseColonBlockPending. - // Capturing the base depth (instead of requiring depth 0) is required - // because the enclosing template-hole expression may itself be wrapped - // in one or more `(` — e.g. `${(() => { switch (x) { case 1: ... }})()}`. - // `case` / `default` を読んだ時点の paren 深さ。case ラベル終端の `:` - // は同じ深さに現れ、それより深い `:` は case 式内の object key / - // ternary / type annotation の `:` で、case label 扱いにしてはならない。 - // テンプレートホール全体が `(() => { ... })()` 等でラップされている - // 場合に count==0 を要求すると case 内の `:` をスキップできないため、 - // `case` 時点の深さを基準値として保存する。 - public int CaseLabelBaseParenDepth; - // True after a case-label `:` is consumed; the next `{` opens a - // statement block (`case 1: {}`, `default: {}`), so the matching `}` - // must keep `/regex/` regex-legal. Consumed by the next `{`. - // case ラベル終端の `:` 消費直後に true。次の `{` は statement block - // (`case 1: {}` / `default: {}`)として扱い、対応する `}` 後の - // `/regex/` を regex-legal に保つ。次の `{` で消費。 - public bool CaseColonBlockPending; - - public void Reset() - { - PrevTokenKind = JsPrevTokenKind.None; - PrevIdentifier = string.Empty; - ClassHeaderPending = false; - CaseLabelPending = false; - CaseLabelBaseParenDepth = 0; - CaseColonBlockPending = false; - if (ParenStatementHead is null) - ParenStatementHead = new Stack(); - else - ParenStatementHead.Clear(); - } - - public void SetKind(JsPrevTokenKind kind) - { - PrevTokenKind = kind; - PrevIdentifier = string.Empty; - } - - public void SetIdentifier(string word) - { - PrevTokenKind = JsPrevTokenKind.Identifier; - PrevIdentifier = word; - if (IsJsDeclarationBodyKeyword(word)) - ClassHeaderPending = true; - if (word == "case" || word == "default") - { - CaseLabelPending = true; - CaseLabelBaseParenDepth = ParenStatementHead?.Count ?? 0; - } - } - } - - private static bool IsJsStatementHeadKeyword(string word) => - word is "if" or "while" or "for" or "switch" or "catch" or "with"; - - // Keywords whose body is a statement block, not an object-literal expression brace. - // `class` is JS/TS; `enum`, `interface`, `namespace`, and `module` are TypeScript - // declarations whose `{...}` body must also keep a following `/regex/` regex-legal. - // body が statement block になる宣言キーワード。`class` は JS/TS、 - // `enum` / `interface` / `namespace` / `module` は TypeScript の宣言で、 - // 対応する `}` の直後の `/regex/` を regex-legal に保つ必要がある。 - private static bool IsJsDeclarationBodyKeyword(string word) => - word is "class" or "enum" or "interface" or "namespace" or "module"; - - // JavaScript/TypeScript template literals: `...` with ${expr} interpolation holes. - // Interpolation hole contents are preserved (not masked) so the call-graph keeps real call edges. - // Regex literals are skipped at the outer and hole scopes so a backtick inside a regex - // does not start a phantom template and a `}` inside a regex does not close a hole early. - // JavaScript/TypeScript のテンプレートリテラル `...` と ${expr} 補間ホール。 - // ホール内の本物のコードは参照抽出に見せるためマスクしない。 - // regex literal は外側と hole 内の両方でスキップし、regex 中の backtick が template を - // 誤って開始したり `}` が hole を早く閉じたりするのを避ける。 - private static void MaskJsTsTemplateLiteralContents( - string[] lines, - bool collectTaggedTemplateHits, - ref List? taggedTemplateHits, - string? lang = null) - { - // `<...>` before a backtick is a TypeScript-only generic type-argument form. In plain - // JavaScript the same character sequence is always a comparison chain (`foo\`x\`` - // is `(foo\`x\``), so never strip the bracketed range when indexing JS. - // `<...>` 付きのタグ付きテンプレートは TypeScript 限定のジェネリクス構文。プレーン - // な JavaScript では同じ並びが常に比較式になるため、JS を索引するときは剥がさない。 - var allowGenericTag = string.Equals(lang, "typescript", StringComparison.Ordinal); - var frames = new Stack(); - // `lexState` must persist across lines so that multi-line expressions in - // template-literal holes keep the preceding token context. For example, - // `${(() => value\n / 2 + runTask())()}` continues on a new line: the `/` - // at the start of line 2 is division (prev token `value`), not a regex - // opener. Resetting state per line caused `lexState.PrevTokenKind` to be - // `None`, flipping `/` into regex mode and swallowing the closing `}` and - // backtick. - // `lexState` はホール内の複数行式で直前トークンを保持するため、行をまたいで - // 維持する必要がある。行頭で Reset すると継続行の `/` が常に regex 扱いに - // なり、hole を閉じる `}` やバッククォートを巻き込んでしまう。 - var lexState = default(JsLexState); - // Active quote for a JS/TS single- or double-quoted string that started - // inside the current top-most template hole and continued past a physical - // line boundary via trailing `\`. The continuation can only belong to the - // current top frame at the start of the next line, so a single scanner-wide - // state slot is enough. - // テンプレートホール内で始まり、行末 `\` により次行へ継続した JS/TS 単/二重 - // 引用符文字列の active quote。行境界で継続可能なのは次行開始時の最上位 hole - // だけなので、scanner 全体で 1 スロット持てば十分。 - var activeJsHoleStringQuote = '\0'; - // Top-level JS/TS single- or double-quoted string that continues across a - // physical line boundary. The next line must resume inside the string before - // any brace/comment/template logic runs. - // 行をまたいで継続する top-level の JS/TS 単/二重引用符文字列。 - // 次行は brace/comment/template の前に string 内として再開しなければならない。 - var activeJsTopLevelStringQuote = '\0'; - lexState.Reset(); - - for (int i = 0; i < lines.Length; i++) - { - var line = lines[i]; - if (line.Length == 0) - continue; - - char[]? masked = null; - char[] GetMaskedLine() => masked ??= line.ToCharArray(); - var pos = 0; - - while (pos < line.Length) - { - if (frames.TryPeek(out var active)) - { - if (active is BlockCommentFrame) - { - if (pos + 1 < line.Length && line[pos] == '*' && line[pos + 1] == '/') - { - // Blank the `*/` closer together with the body so - // template-hole block comments like `${/* f(); */ g()}` - // never leak `f` as a phantom reference. - // テンプレートホール内の `${/* f(); */ g()}` のような - // block comment で `f` が疑似参照として残らないよう、 - // `*/` 自体も本文と同様に空白化する。 - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - frames.Pop(); - pos += 2; - continue; - } - - // Blank the body so identifiers inside a template-hole - // block comment do not survive into reference extraction. - // ホール内 block comment 本文は空白化し、内部の識別子が - // 参照抽出まで残らないようにする。 - GetMaskedLine()[pos] = ' '; - pos++; - continue; - } - - if (active is JsTemplateLiteralFrame tplFrame) - { - if (line[pos] == '\\') - { - ReplaceWithSpaces(GetMaskedLine(), pos, Math.Min(2, line.Length - pos)); - pos += Math.Min(2, line.Length - pos); - continue; - } - - if (pos + 1 < line.Length && line[pos] == '$' && line[pos + 1] == '{') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - pos += 2; - frames.Push(new JsTemplateHoleFrame()); - lexState = default; - lexState.Reset(); - continue; - } - - if (line[pos] == '`') - { - // Restore the lex state captured when this template opened so the - // paren stack, class-header hint, case-label hint, etc. carry - // through to the token after the closing backtick. - // テンプレート開始時に退避した lex state を復元し、閉じ backtick - // の後ろに paren stack や class header hint、case label hint を - // 引き継ぐ。 - GetMaskedLine()[pos] = ' '; - pos++; - lexState = tplFrame.SavedLexState; - lexState.SetKind(JsPrevTokenKind.Literal); - frames.Pop(); - continue; - } - - GetMaskedLine()[pos] = ' '; - pos++; - continue; - } - - if (active is JsTemplateHoleFrame holeFrame) - { - if (activeJsHoleStringQuote != '\0') - { - pos = MaskJsTemplateHoleString(line, pos, GetMaskedLine(), activeJsHoleStringQuote, startsInsideString: true, out var continuesOnNextLine); - if (continuesOnNextLine) - break; - - activeJsHoleStringQuote = '\0'; - lexState.SetKind(JsPrevTokenKind.Literal); - continue; - } - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') - { - // Blank the `//` comment tail so later passes (including the - // multi-line tagged-template backward scan that reads prior - // `lines[li]`) cannot mistake a comment identifier for code. - // `//` コメント以降を空白化し、後続処理 — とくに前行の - // `lines[li]` を読む複数行タグ走査 — がコメント内の識別子を - // コードと誤認しないようにする。 - ReplaceWithSpaces(GetMaskedLine(), pos, line.Length - pos); - break; - } - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') - { - // Blank the `/*` opener so the hole's block comment - // span is fully whitespace for downstream extraction. - // ホールの block comment 開始 `/*` を空白化し、 - // 下流抽出から見えるスパン全体を空白化する。 - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - frames.Push(new BlockCommentFrame()); - pos += 2; - continue; - } - - if (line[pos] == '/' && CanStartJsRegexLiteral(lexState)) - { - pos = SkipJsRegexLiteral(line, pos); - lexState.SetKind(JsPrevTokenKind.Literal); - continue; - } - - if (line[pos] == '`') - { - // Save the hole's lex state so the closing backtick can - // restore paren/state context for the token that follows. - // hole 側の lex state を退避し、閉じ backtick 後に paren - // などの context を元に戻せるようにする。 - if (collectTaggedTemplateHits) - TryRecordJsTaggedTemplateHit(lines, GetMaskedLine(), i, pos, ref taggedTemplateHits, allowGenericTag); - pos++; - frames.Push(new JsTemplateLiteralFrame { SavedLexState = lexState }); - lexState = default; - lexState.Reset(); - continue; - } - - if (line[pos] == '"' || line[pos] == '\'') - { - var quote = line[pos]; - pos = MaskJsTemplateHoleString(line, pos, GetMaskedLine(), quote, startsInsideString: false, out var continuesOnNextLine); - if (continuesOnNextLine) - { - activeJsHoleStringQuote = quote; - break; - } - - lexState.SetKind(JsPrevTokenKind.Literal); - continue; - } - - if (line[pos] == '{') - { - holeFrame.NestedBraceDepth++; - // Classify the nested `{` as expression brace (object literal - // or `() => ({})` body) vs. statement block (arrow body - // `=> {...}`, `if (x) {...}`, or `class Foo {...}`). Block - // braces preserve `}`→regex behavior; expression braces set - // `}`→division so `${({a:1} / 2)}` stays parseable. A pending - // class header always opens a class body, regardless of what - // identifier or `extends` clause token came last. - // ネスト `{` を expression brace(object literal / `() => ({})`) - // と statement block(arrow body / `if (x) {}` / `class Foo {}`) - // に分類する。block は `}` の次の `/` を regex にし、expression - // は division にする。これで `${({a:1} / 2)}` が壊れない。 - // class header pending 中は直前トークンが何であっても class - // body とみなす。 - var isExpressionBrace = !lexState.ClassHeaderPending - && !lexState.CaseColonBlockPending - && IsJsExpressionBraceContext(lexState); - lexState.ClassHeaderPending = false; - lexState.CaseColonBlockPending = false; - // A new block scope starts; clear any half-complete case - // label tracking so a stray `case` keyword seen earlier - // does not tag the wrong `:` in the inner scope. - // 新しい block scope の開始。半端な case ラベル追跡を - // クリアし、外側の `case` が内側の無関係な `:` を - // case-label colon と誤判定しないようにする。 - lexState.CaseLabelPending = false; - holeFrame.InnerBraceIsExpression.Push(isExpressionBrace); - pos++; - lexState.SetKind(JsPrevTokenKind.Other); - continue; - } - - if (line[pos] == '}') - { - if (holeFrame.NestedBraceDepth == 0) - { - // Mask the hole's closing `}` to keep brace balance intact - // for downstream symbol-body brace counting. - // ホールを閉じる `}` もマスクし、後段の symbol 本体の - // brace 数え上げで brace バランスを崩さないようにする。 - GetMaskedLine()[pos] = ' '; - frames.Pop(); - pos++; - lexState.SetKind(JsPrevTokenKind.Other); - continue; - } - - holeFrame.NestedBraceDepth--; - pos++; - var wasExpression = holeFrame.InnerBraceIsExpression.Count > 0 - && holeFrame.InnerBraceIsExpression.Pop(); - // Expression brace close → division context (CloseBrace). - // Block brace close → preserve regex-legal state (Other) so - // `if (x) {} /regex/` inside an arrow body is still skipped - // correctly and does not consume backticks as division noise. - // expression brace の閉じは CloseBrace で division 優先。 - // block brace の閉じは Other に戻し、arrow body 内の - // `if (x) {} /regex/` でも regex を正しく取り込めるようにする。 - lexState.SetKind(wasExpression ? JsPrevTokenKind.CloseBrace : JsPrevTokenKind.Other); - continue; - } - - pos = AdvanceJsToken(line, pos, ref lexState); - continue; - } - } - - if (activeJsTopLevelStringQuote != '\0') - { - pos = MaskJsTemplateHoleString(line, pos, GetMaskedLine(), activeJsTopLevelStringQuote, startsInsideString: true, out var continuesOnNextLine); - if (continuesOnNextLine) - break; - - activeJsTopLevelStringQuote = '\0'; - lexState.SetKind(JsPrevTokenKind.Literal); - continue; - } - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') - { - // Blank the `//` comment tail so the multi-line tagged-template - // backward scan (which reads prior `lines[li]` directly) cannot - // mistake a comment identifier like `comment` in - // `return tag // trailing comment` for the tag itself. - // `//` コメント以降を空白化し、前行の `lines[li]` を直接読む複数行 - // タグ走査が `return tag // trailing comment` の `comment` のような - // コメント内識別子をタグと誤認しないようにする。 - ReplaceWithSpaces(GetMaskedLine(), pos, line.Length - pos); - break; - } - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') - { - // Blank the top-level `/*` opener to match the hole-side - // behavior and keep downstream extraction consistent. - // 先頭レベルでも `/*` 開始を空白化し、ホール側と挙動を揃える。 - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - frames.Push(new BlockCommentFrame()); - pos += 2; - continue; - } - - if (line[pos] == '/' && CanStartJsRegexLiteral(lexState)) - { - pos = SkipJsRegexLiteral(line, pos); - lexState.SetKind(JsPrevTokenKind.Literal); - continue; - } - - if (line[pos] == '`') - { - // Save the top-level lex state so the closing backtick can restore - // the paren stack / statement-head hints that preceded the template. - // テンプレート直前の lex state を退避し、閉じ backtick で paren - // stack や statement-head hint を復元できるようにする。 - if (collectTaggedTemplateHits) - TryRecordJsTaggedTemplateHit(lines, GetMaskedLine(), i, pos, ref taggedTemplateHits, allowGenericTag); - GetMaskedLine()[pos] = ' '; - pos++; - frames.Push(new JsTemplateLiteralFrame { SavedLexState = lexState }); - lexState = default; - lexState.Reset(); - continue; - } - - if (line[pos] == '"' || line[pos] == '\'') - { - var quote = line[pos]; - var start = pos; - pos = SkipJsSingleLineStringContinuation(line, pos, out var continuesOnNextLine); - if (continuesOnNextLine) - { - ReplaceWithSpaces(GetMaskedLine(), start, pos - start); - activeJsTopLevelStringQuote = quote; - break; - } - - lexState.SetKind(JsPrevTokenKind.Literal); - continue; - } - - pos = AdvanceJsToken(line, pos, ref lexState); - } - - if (masked is not null) - lines[i] = new string(masked); - } - - // Post-pass: drop `of` hits whose enclosing `for (...)` header is a for-of or - // for-await-of loop. `of` is not a reserved word in ECMAScript, so `const of = - // ...; of\`x\`` must stay visible — only the loop-header form should be silenced. - // The check is done against the fully masked buffer so the template body cannot - // inject false tokens, and it walks across line boundaries to cover multi-line - // headers like `for (\n const ch of \`abc\`\n)`. - // 後段パス: 囲む `for (...)` ヘッダが for-of / for-await-of の場合のみ `of` ヒット - // を除外する。`of` は ECMAScript の予約語ではなく `const of = ...; of\`x\`` は正当 - // なので、ループヘッダ形だけを静かにする必要がある。マスク後バッファに対して - // 検査するため template 本体が誤トークンを混入させることがなく、 - // `for (\n const ch of \`abc\`\n)` のような複数行ヘッダも行境界を越えて処理する。 - if (collectTaggedTemplateHits && taggedTemplateHits != null && taggedTemplateHits.Count > 0) - FilterJsForOfHeaderHits(lines, taggedTemplateHits); - } - - private static void FilterJsForOfHeaderHits(string[] lines, List hits) - { - // Build a scan buffer that additionally blanks string literals, regex literals, - // and line comments. The outer masker already blanked template bodies and block - // comments, but string / regex / `//` content survives, so a literal `)` inside - // `":"` or `/)/` or `// for (a;b;c)` would corrupt paren and `;` counting in the - // for-of header probe. Blanking them here keeps the structural walk structural. - // paren と `;` のカウントが文字列 / regex / 行コメント内の `)` や `;` に引きずられ - // ないよう、外側 masker が空白化していない要素も追加で空白化したスキャンバッファを - // 作る。template 本体と block コメントは外側で既に空白化済みのためここでは触らない。 - var scanBuffer = BuildJsForOfScanBuffer(lines); - for (int h = hits.Count - 1; h >= 0; h--) - { - var hit = hits[h]; - if (hit.Name != "of") - continue; - if (IsJsForOfHeaderContext(scanBuffer, hit.Line - 1, hit.Column - 1)) - hits.RemoveAt(h); - } - } - - // Returns the masker output with single/double-quoted string spans, regex - // literals, and `//` line-comment tails blanked out. Template literal bodies and - // block comments are already blanked by the outer masker, so we only need to - // handle the three remaining kinds. Unchanged lines are reused; any returned - // replacement keeps identical column offsets so hit coordinates (Line, Column) - // remain valid. - // 外側の masker の出力に対し、文字列リテラル・regex リテラル・`//` 行コメント末尾を - // 追加で空白化して返す。template 本体と block コメントは既に空白化済みなので、残る - // 3 種類だけを処理する。未変更行は再利用し、置換行も列オフセットは元の buffer と - // 一致するため Hit 座標は - // そのまま利用できる。 - private static string[] BuildJsForOfScanBuffer(string[] lines) - { - string[]? result = null; - var lexState = default(JsLexState); - var activeJsStringQuote = '\0'; - lexState.Reset(); - for (int i = 0; i < lines.Length; i++) - { - var line = lines[i]; - if (line.Length == 0) - { - if (result != null) - result[i] = line; - continue; - } - char[]? buf = null; - char[] GetBuffer() => buf ??= line.ToCharArray(); - int pos = 0; - while (pos < line.Length) - { - if (activeJsStringQuote != '\0') - { - pos = MaskJsTemplateHoleString(line, pos, GetBuffer(), activeJsStringQuote, startsInsideString: true, out var continuesOnNextLine); - if (continuesOnNextLine) - break; - - activeJsStringQuote = '\0'; - lexState.SetKind(JsPrevTokenKind.Literal); - continue; - } - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') - { - var buffer = GetBuffer(); - for (int k = pos; k < line.Length; k++) - buffer[k] = ' '; - pos = line.Length; - break; - } - char ch = line[pos]; - if (ch == '"' || ch == '\'') - { - var quote = ch; - pos = MaskJsTemplateHoleString(line, pos, GetBuffer(), quote, startsInsideString: false, out var continuesOnNextLine); - if (continuesOnNextLine) - { - activeJsStringQuote = quote; - break; - } - - lexState.SetKind(JsPrevTokenKind.Literal); - continue; - } - if (ch == '/' && CanStartJsRegexLiteral(lexState)) - { - int end = SkipJsRegexLiteral(line, pos); - var buffer = GetBuffer(); - for (int k = pos; k < end; k++) - buffer[k] = ' '; - pos = end; - lexState.SetKind(JsPrevTokenKind.Literal); - continue; - } - pos = AdvanceJsToken(line, pos, ref lexState); - } - var outputLine = buf is null ? line : new string(buf); - if (result != null) - { - result[i] = outputLine; - } - else if (!ReferenceEquals(outputLine, line)) - { - result = (string[])lines.Clone(); - result[i] = outputLine; - } - } - return result ?? lines; - } - - // From (lineIdx, colIdx) pointing at the start of the `of` token, decide whether `of` - // is the iterator keyword of a for-of / for-await-of header. Classic `for (init; cond; - // step)` keeps `of` visible as a real tagged-template call. - // `of` トークン先頭 (lineIdx, colIdx) を起点に、その `of` が for-of / for-await-of の - // 反復子キーワードかを判定する。古典形 `for (init; cond; step)` 内の `of` はタグとして - // 残す。 - private static bool IsJsForOfHeaderContext(string[] lines, int lineIdx, int colIdx) - { - if (lineIdx < 0 || lineIdx >= lines.Length) - return false; - - if (!TryFindEnclosingOpenParen(lines, lineIdx, colIdx, out var openLine, out var openCol)) - return false; - - if (!PrecedingTokenIsForKeyword(lines, openLine, openCol)) - return false; - - return HasNoTopLevelSemicolonInParenGroup(lines, openLine, openCol); - } - - // Walk backward from just before (startLine, startCol) through masked lines to find the - // nearest unmatched `(`. Balanced `()` / `[]` / `{}` groups are skipped. Escaping an - // unmatched `[` or `{` means `of` is not inside a paren-group at all; return false. - // (startLine, startCol) の直前から masked lines を後方に走査し、釣り合っていない最 - // 近傍の `(` を探す。釣り合いのとれた `()` / `[]` / `{}` は飛ばす。未対応の `[` / `{` - // を抜ける場合は paren-group 内にないため false を返す。 - private static bool TryFindEnclosingOpenParen(string[] lines, int startLine, int startCol, out int openLine, out int openCol) - { - openLine = -1; - openCol = -1; - int parenDepth = 0; - int bracketDepth = 0; - int braceDepth = 0; - int curCol = startCol - 1; - for (int li = startLine; li >= 0; li--) - { - var line = lines[li]; - if (li != startLine) - curCol = line.Length - 1; - for (int c = curCol; c >= 0; c--) - { - char ch = line[c]; - if (ch == ')') { parenDepth++; continue; } - if (ch == ']') { bracketDepth++; continue; } - if (ch == '}') { braceDepth++; continue; } - if (ch == '[') - { - if (bracketDepth > 0) { bracketDepth--; continue; } - return false; - } - if (ch == '{') - { - if (braceDepth > 0) { braceDepth--; continue; } - return false; - } - if (ch == '(') - { - if (parenDepth > 0) { parenDepth--; continue; } - openLine = li; - openCol = c; - return true; - } - } - } - return false; - } - - // Check whether the token immediately before the `(` at (openLine, openCol) is `for` - // (optionally followed by an `await` token between `for` and `(`). Whitespace and - // line breaks between the keyword and `(` are tolerated. - // (openLine, openCol) の `(` 直前トークンが `for`(`for` と `(` の間に `await` が入る - // 形も許容)であるかを判定する。キーワードと `(` の間の空白・改行は許容する。 - private static bool PrecedingTokenIsForKeyword(string[] lines, int openLine, int openCol) - { - int li = openLine; - int c = openCol - 1; - if (!SkipWhitespaceBackward(lines, ref li, ref c)) - return false; - if (!TryReadIdentifierBackward(lines, ref li, ref c, out var token1)) - return false; - if (token1 == "for") - return true; - if (token1 != "await") - return false; - if (!SkipWhitespaceBackward(lines, ref li, ref c)) - return false; - if (!TryReadIdentifierBackward(lines, ref li, ref c, out var token2)) - return false; - return token2 == "for"; - } - - // Starting from `(` at (openLine, openCol), walk forward to the matching `)` and - // report whether the paren group contains zero top-level `;`. Zero means for-of / - // for-await-of shape; any top-level `;` means classic `for (init; cond; step)`. - // (openLine, openCol) の `(` から対応する `)` までを前方走査し、トップレベルの `;` が - // 1 つも無ければ for-of / for-await-of 形、1 つ以上あれば古典形 `for (init; cond; - // step)` と判断する。 - private static bool HasNoTopLevelSemicolonInParenGroup(string[] lines, int openLine, int openCol) - { - int parenDepth = 1; - int bracketDepth = 0; - int braceDepth = 0; - for (int li = openLine; li < lines.Length; li++) - { - var line = lines[li]; - int startCol = (li == openLine) ? openCol + 1 : 0; - for (int c = startCol; c < line.Length; c++) - { - char ch = line[c]; - if (ch == '(') { parenDepth++; continue; } - if (ch == ')') - { - parenDepth--; - if (parenDepth == 0) - return true; - continue; - } - if (ch == '[') { bracketDepth++; continue; } - if (ch == ']') { if (bracketDepth > 0) bracketDepth--; continue; } - if (ch == '{') { braceDepth++; continue; } - if (ch == '}') { if (braceDepth > 0) braceDepth--; continue; } - if (ch == ';' && parenDepth == 1 && bracketDepth == 0 && braceDepth == 0) - return false; - } - } - return false; - } - - private static bool SkipWhitespaceBackward(string[] lines, ref int li, ref int c) - { - while (true) - { - while (c < 0) - { - li--; - if (li < 0) - return false; - c = lines[li].Length - 1; - } - char ch = lines[li][c]; - if (IsJsInterTokenWhitespace(ch)) - { - c--; - continue; - } - return true; - } - } - - // ECMAScript treats inter-token whitespace as any WhiteSpace (TAB / VT / FF / SP, NBSP - // `U+00A0`, BOM `U+FEFF`, every `Zs` category codepoint) or LineTerminator. Our per-line - // buffer is already split on `\r` / `\n`, but non-ASCII whitespace such as NBSP and - // U+3000 survives inside the line and must still be recognised when backing up between - // tokens. `char.IsWhiteSpace` matches `Zs` plus common ASCII controls, but in .NET 8 - // `char.IsWhiteSpace('\uFEFF')` is `false` (BOM is categorised as `Cf`/Format), so BOM - // must be added explicitly. ZWSP `U+200B` is deliberately excluded — ECMAScript does - // not treat it as WhiteSpace and `char.IsWhiteSpace` already returns false for it. - // ECMAScript のトークン間スペースは WhiteSpace(TAB / VT / FF / SP、NBSP `U+00A0`、BOM - // `U+FEFF`、`Zs` 全域)および LineTerminator。行バッファは既に `\r` / `\n` で分割済み - // だが、NBSP や U+3000 のような非 ASCII 空白は行内に残るため、トークン間の後方走査でも - // 取り扱う必要がある。.NET 8 では `char.IsWhiteSpace('\uFEFF')` は `false`(BOM は - // `Cf`/Format 扱い)なので BOM は明示的に足す必要がある。ZWSP `U+200B` は ECMAScript - // の WhiteSpace ではなく、`char.IsWhiteSpace` も false を返すため意図通りに除外される。 - private static bool IsJsInterTokenWhitespace(char c) => c == '\uFEFF' || char.IsWhiteSpace(c); - - private static bool TryReadIdentifierBackward(string[] lines, ref int li, ref int c, out string token) - { - token = string.Empty; - if (li < 0 || li >= lines.Length || c < 0) - return false; - var line = lines[li]; - if (c >= line.Length || !IsJsIdentifierPart(line[c])) - return false; - int end = c + 1; - while (c >= 0 && IsJsIdentifierPart(line[c])) - c--; - int start = c + 1; - if (!IsJsIdentifierStart(line[start])) - return false; - token = line.Substring(start, end - start); - return true; - } - - // Advance past one JS/TS token (identifier run, numeric run, single non-string/regex char) - // and update lexer state so the next `/` can be classified as regex-start or division. - // 識別子の連続や数値、単一文字を 1 token として進め、次の `/` を regex / division に - // 振り分けられるよう lex state を更新する。 - private static int AdvanceJsToken(string line, int pos, ref JsLexState lexState) - { - var c = line[pos]; - if (char.IsWhiteSpace(c)) - return pos + 1; - - if (IsJsIdentifierStart(c)) - { - int start = pos; - pos++; - while (pos < line.Length && IsJsIdentifierPart(line[pos])) - pos++; - lexState.SetIdentifier(line.Substring(start, pos - start)); - return pos; - } - - if (char.IsDigit(c)) - { - while (pos < line.Length && (char.IsLetterOrDigit(line[pos]) || line[pos] == '.' || line[pos] == '_')) - pos++; - lexState.SetKind(JsPrevTokenKind.Numeric); - return pos; - } - - // Postfix / prefix `++` and `--` both produce a numeric-typed expression, - // so the following `/` must be division, not a regex start. Consume as one - // 2-char token to stop the second `+` / `-` from being classified as `Other`. - // postfix / prefix の `++` と `--` は数値を生むため、続く `/` は division と - // 扱う必要がある。2 文字 token として消費し、2 文字目が `Other` に落ちて - // 直後の `/` を regex と誤判定するのを防ぐ。 - if ((c == '+' || c == '-') && pos + 1 < line.Length && line[pos + 1] == c) - { - lexState.SetKind(JsPrevTokenKind.Numeric); - return pos + 2; - } - - switch (c) - { - case '(': - // Remember whether this `(` opens a statement-head control-flow - // clause. Its matching `)` will need to keep the following `/` - // regex-legal rather than flipping to division. - // この `(` が statement-head control-flow(`if (x)` など)を - // 開いているかを stack に記録し、対応する `)` の直後の `/` を - // division ではなく regex literal として扱えるようにする。 - var openIsStmtHead = lexState.PrevTokenKind == JsPrevTokenKind.Identifier - && IsJsStatementHeadKeyword(lexState.PrevIdentifier); - lexState.ParenStatementHead?.Push(openIsStmtHead); - lexState.SetKind(JsPrevTokenKind.Other); - break; - case ')': - var closeIsStmtHead = lexState.ParenStatementHead is { Count: > 0 } - && lexState.ParenStatementHead.Pop(); - // Statement-head `)` tags the following `/` as regex-legal and the - // following `{` as a statement block; other `)` flips `/` to division - // and `{` to an object-literal-style expression brace. - // statement-head の `)` は続く `/` を regex、続く `{` を block と扱う。 - // それ以外の `)` は `/` を division、`{` を object literal 的な - // expression brace と扱う。 - lexState.SetKind(closeIsStmtHead ? JsPrevTokenKind.StatementHeadCloseParen : JsPrevTokenKind.CloseParen); - break; - case ']': - lexState.SetKind(JsPrevTokenKind.CloseBracket); - break; - case ':': - // `case expr :` / `default :` — the case-label colon. Treat it - // as such only when the paren depth is back to what it was at - // the `case` / `default` keyword, so object-key, ternary, and - // type-annotation colons inside the case expression do not - // consume the hint. - // `case expr :` / `default :` の case ラベル終端 `:`。paren - // 深さが `case` / `default` 時点と同じに戻ったときだけ使い、 - // case 式内の object-key / ternary / type annotation の `:` - // でヒントを消費しないようにする。 - if (lexState.CaseLabelPending - && (lexState.ParenStatementHead?.Count ?? 0) == lexState.CaseLabelBaseParenDepth) - { - lexState.CaseLabelPending = false; - lexState.CaseColonBlockPending = true; - } - lexState.SetKind(JsPrevTokenKind.Other); - break; - case ';': - // `;` terminates any in-progress case-label tracking. - // `;` で case ラベル追跡を打ち切る。 - lexState.CaseLabelPending = false; - lexState.CaseColonBlockPending = false; - lexState.SetKind(JsPrevTokenKind.Other); - break; - case '>': - // `=>` is the only 2-char JS token we need to distinguish here: `{` - // following `=>` opens an arrow-function body (a statement block, so - // the next `/` inside is regex), while `{` following most other tokens - // opens an object literal / expression brace. - // `=>` は 2 文字 token のうち本マスカーで必要な唯一のケース。続く `{` - // が arrow body(statement block)か object literal / expression - // brace かを分けるフラグとして使う。 - if (pos > 0 && line[pos - 1] == '=') - lexState.SetKind(JsPrevTokenKind.Arrow); - else - lexState.SetKind(JsPrevTokenKind.Other); - break; - // `}` in normal JS / TS code is context-dependent: after a statement block - // (`if (x) {}`) a `/` legitimately starts a regex; after an object literal - // in expression position a `/` is division. We classify as `Other` so the - // regex scanner still runs — that lets us correctly skip `/regex/` literals - // that may contain backticks or braces which would otherwise open a phantom - // template literal. Inside template-literal holes the closing brace is - // handled separately (see `JsPrevTokenKind.CloseBrace` path below). - // 通常コードの `}` は文脈依存で、`if (x) {}` のあとは regex、object literal - // のあとは division。ここでは `Other` として regex scanner に任せ、中に - // backtick や brace を含む `/regex/` を取りこぼして phantom template を - // 開かないようにする。テンプレート hole 内のブレース close は別扱い。 - default: - lexState.SetKind(JsPrevTokenKind.Other); - break; - } - - return pos + 1; - } - - private static bool IsJsIdentifierStart(char c) => - c == '_' || c == '$' || char.IsLetter(c); - - private static bool IsJsIdentifierPart(char c) => - c == '_' || c == '$' || char.IsLetterOrDigit(c); - - // Backward-scan the masked buffer at a template-literal opener backtick for a tag - // identifier such as `gql`, `styled.div` (last segment), or `html` (generics are - // skipped). Whitespace between the identifier and the backtick is tolerated so - // `html \`...\`` still matches. `IsIgnoredCallName` downstream filters out keywords - // like `return` / `throw` / `await` / `typeof` that can legally precede a plain - // template literal. - // マスク済みバッファを opener バッククォート位置から後方スキャンし、`gql` や - // `styled.div`(最後のセグメント)、`html`(ジェネリクスを読み飛ばす)の - // タグ識別子を取り出す。識別子とバッククォートの間の空白は許容し、 - // `return` / `throw` / `await` / `typeof` のようなプレーンテンプレートの前に - // 立ちうるキーワードは呼び出し側の `IsIgnoredCallName` で除外する。 - private static void TryRecordJsTaggedTemplateHit( - string[] lines, char[] masked, int lineIndex, int backtickPos, ref List? hits, bool allowGenericTag) - { - // Skip inter-token whitespace backward, crossing line boundaries when the tag - // identifier lives on a prior line (multi-line forms like `tag\n\`hello\``). - // Prior lines are already fully masked by the outer loop, so we can safely read - // `lines[i]` for `i < lineIndex`. - // トークン間空白を後方に辿る。`tag\n\`hello\`` のようにタグが前行にある形も扱うため、 - // 行境界を越えて走査する。先行行は外側ループで既にマスク済みなので `lines[i]` を - // そのまま参照できる。 - int curLine = lineIndex; - int k = backtickPos - 1; - while (true) - { - if (curLine == lineIndex) - { - while (k >= 0 && IsJsInterTokenWhitespace(masked[k])) - k--; - if (k >= 0) break; - } - else - { - var l = lines[curLine]; - while (k >= 0 && IsJsInterTokenWhitespace(l[k])) - k--; - if (k >= 0) break; - } - curLine--; - if (curLine < 0) return; - k = (curLine == lineIndex ? masked.Length : lines[curLine].Length) - 1; - } - - char CharAt(int li, int col) - => li == lineIndex ? masked[col] : lines[li][col]; - int LineLen(int li) - => li == lineIndex ? masked.Length : lines[li].Length; - - // Skip a balanced `<...>` (TypeScript generics) so `html\`...\`` still sees `html`. - // The generic-strip is TypeScript-only (`allowGenericTag`) because plain JavaScript has - // no generics: `foo\`x\`` is always the chained comparison `(foo\`x\``. Even - // inside TypeScript we still require the `<` to directly abut an identifier so - // whitespace-bearing comparison expressions like `foo < bar > \`plain\`` are rejected, - // and we ignore `>` from `=>` (arrow-function type inside the generic range). The - // generic-strip is same-line only; a generic argument list spanning line breaks is - // extremely rare in practice. - // `html\`...\`` のジェネリクスを読み飛ばすため、同一行内で `<...>` が釣り合っている - // 場合のみ括弧を剥がす。ジェネリクスは TypeScript 限定(`allowGenericTag`)。JavaScript - // では `foo\`x\`` は常に連鎖比較式なので generic とは扱わない。TypeScript 側でも - // `foo < bar > \`plain\`` のような比較式と区別するため `<` が識別子に隣接していることを - // 要求し、`=>` 由来の `>` は関数型なので閉じ記号として数えない。ジェネリクス走査は - // 同一行限定。行をまたぐジェネリクス引数リストは実運用で極めて稀。 - if (CharAt(curLine, k) == '>' && allowGenericTag) - { - int probe = k - 1; - int depth = 1; - while (probe >= 0 && depth > 0) - { - var ch = CharAt(curLine, probe); - if (ch == '>' && probe > 0 && CharAt(curLine, probe - 1) == '=') - { - probe -= 2; - continue; - } - if (ch == '>') depth++; - else if (ch == '<') depth--; - probe--; - } - if (depth != 0) - return; - if (probe < 0 || !IsJsIdentifierPart(CharAt(curLine, probe))) - return; - k = probe; - } - - if (!IsJsIdentifierPart(CharAt(curLine, k))) - return; - - // Identifier read stays within the current line — JS identifiers do not cross lines. - // 識別子は行をまたがないため同一行内で読み切る。 - int end = k + 1; - while (k >= 0 && IsJsIdentifierPart(CharAt(curLine, k))) - k--; - int start = k + 1; - - if (!IsJsIdentifierStart(CharAt(curLine, start))) - return; - - string name = curLine == lineIndex - ? new string(masked, start, end - start) - : lines[curLine].Substring(start, end - start); - - // Member-access detection: look for a `.` (possibly after inter-token whitespace, - // possibly across line breaks like `obj\n.default\`x\``) before the tag identifier. - // Member-access tags bypass the keyword denylist downstream because any reserved - // word — including `default`, `finally`, `in`, `instanceof`, `delete`, `void`, - // `case` — is a legal property name in JavaScript/TypeScript. - // メンバーアクセス判定: タグ識別子の前に空白(行境界含む)を挟んで `.` があれば - // メンバーアクセス。JS/TS ではすべての予約語が property 名になりうるので、 - // メンバーアクセス扱いのタグは下流のキーワード除外リスト(`default` / `finally` / - // `in` / `instanceof` / `delete` / `void` / `case`)の対象外にする。 - bool isMemberAccess = false; - int mLine = curLine; - int mk = start - 1; - while (true) - { - if (mk < 0) - { - mLine--; - if (mLine < 0) break; - mk = LineLen(mLine) - 1; - continue; - } - char pc = CharAt(mLine, mk); - if (IsJsInterTokenWhitespace(pc)) - { - mk--; - continue; - } - if (pc == '.') isMemberAccess = true; - break; - } - - (hits ??= []).Add(new JsTaggedTemplateHit(curLine + 1, start + 1, name, isMemberAccess)); - } - - // Decide whether `/` at the current scan position starts a regex literal rather - // than a division operator. Division follows numeric / string / regex / template literals, - // `)`, `]`, and non-keyword identifiers. Everything else (operators, `{`, `(`, `[`, `,`, - // `;`, `=`, `?`, `:`, leading None) puts us in an expression-prefix context where `/` - // begins a regex. Regex-prefix keywords such as `return`, `throw`, `typeof` re-enable - // regex mode even though they are identifier-shaped. - // `/` が division ではなく regex literal の開始かを判定する。数値 / 文字列 / regex / - // template 等のリテラル、`)`、`]`、および非 keyword な識別子の後は division。 - // それ以外(演算子、`(` / `[` / `=` / `?` / `:` / `,` / `;` や行頭 None)は式の - // 先頭コンテキストで `/` は regex。`return` / `throw` / `typeof` など regex-prefix - // keyword は識別子形でも regex を許す。 - private static bool CanStartJsRegexLiteral(JsLexState lexState) - { - switch (lexState.PrevTokenKind) - { - case JsPrevTokenKind.None: - return true; - case JsPrevTokenKind.CloseParen: - case JsPrevTokenKind.CloseBracket: - case JsPrevTokenKind.CloseBrace: - case JsPrevTokenKind.Numeric: - case JsPrevTokenKind.Literal: - return false; - case JsPrevTokenKind.Identifier: - return IsJsRegexPrefixKeyword(lexState.PrevIdentifier); - case JsPrevTokenKind.StatementHeadCloseParen: - case JsPrevTokenKind.Arrow: - case JsPrevTokenKind.Other: - default: - return true; - } - } - - // Classify a nested `{` opened inside a template-literal hole as an expression - // brace (object literal or `() => ({})` body, follows `=`, `(`, `[`, `,`, `:`, - // `?`, operator, regex-prefix keyword) vs. a statement block (arrow-function - // body, `if`/`while`/`for`/`function` block body — typically follows `)` or - // `=>`). Expression braces classify the matching `}` as division-context; block - // braces keep regex-legal classification so `{} /regex/` still parses. - // テンプレートホール内でネストした `{` が expression brace(object literal / - // `() => ({})` 本体)か statement block(arrow body / `if/while/for/function` - // ブロック)かを判定する。expression は `=`、`(`、`[`、`,`、`:`、`?`、演算子、 - // regex-prefix keyword の直後。block は `)` や `=>` の直後。 - private static bool IsJsExpressionBraceContext(JsLexState lexState) - { - switch (lexState.PrevTokenKind) - { - case JsPrevTokenKind.CloseParen: - case JsPrevTokenKind.StatementHeadCloseParen: - case JsPrevTokenKind.Arrow: - return false; - case JsPrevTokenKind.Identifier: - // Keywords that open a statement block follow the same rule as `)`. - // `else { ... }`, `do { ... }`, `try { ... }`, `finally { ... }`, - // and the optional-binding `catch { ... }` (ES2019). - // block を開く keyword は `)` と同じ扱い。ES2019 の optional - // binding 付き `catch { ... }` も block として扱う。 - return lexState.PrevIdentifier is not ("else" or "do" or "try" or "finally" or "catch"); - default: - return true; - } - } - - private static bool IsJsRegexPrefixKeyword(string word) => - word is "return" or "throw" or "case" or "delete" or "typeof" or "void" - or "new" or "in" or "of" or "instanceof" or "yield" or "await" - or "else" or "do" or "finally"; - - private static int SkipJsRegexLiteral(string line, int startIndex) - { - var p = startIndex + 1; - var inCharClass = false; - - while (p < line.Length) - { - var ch = line[p]; - if (ch == '\\') - { - if (p + 1 < line.Length) - { - p += 2; - continue; - } - - return line.Length; - } - - if (ch == '[') - { - inCharClass = true; - p++; - continue; - } - - if (ch == ']' && inCharClass) - { - inCharClass = false; - p++; - continue; - } - - if (ch == '/' && !inCharClass) - { - p++; - while (p < line.Length && char.IsLetter(line[p])) - p++; - return p; - } - - p++; - } - - return line.Length; - } - - private static int MaskJsTemplateHoleString(string line, int startIndex, char[] masked, char quote, bool startsInsideString, out bool continuesOnNextLine) - { - var p = startIndex; - if (!startsInsideString) - { - masked[p] = ' '; - p++; - } - - while (p < line.Length) - { - var ch = line[p]; - masked[p] = ' '; - - if (ch == '\\') - { - if (p + 1 == line.Length) - { - continuesOnNextLine = true; - return p + 1; - } - - if (p + 2 == line.Length && line[p + 1] == '\r') - { - masked[p + 1] = ' '; - continuesOnNextLine = true; - return line.Length; - } - - if (p + 1 < line.Length) - { - masked[p + 1] = ' '; - p += 2; - continue; - } - } - - p++; - if (ch == quote) - { - continuesOnNextLine = false; - return p; - } - } - - continuesOnNextLine = false; - return p; - } - - // Mask a single-line Swift extended raw string `#"..."#` while preserving - // any matching `\#(...)` interpolation hole bodies so real call edges inside - // the holes still reach the reference graph. Returns the position immediately - // after the closing delimiter (or end of line if the source is malformed). - // Callers must have already verified that `line[startIndex .. startIndex + hashCount]` - // is `#"`. Closes #1001. - // Swift の単行 `#"..."#` 拡張 raw 文字列をマスクしつつ、内側の hash 数一致 `\#(...)` - // 補間ホール本文だけは残し、ホール内の本物の call が reference graph に届くようにする。 - private static int MaskSwiftSingleLineRawString(string line, int startIndex, int hashCount, char[] masked) - { - // Mask leading `#"` (hashCount + 1 chars). - ReplaceWithSpaces(masked, startIndex, hashCount + 1); - var q = startIndex + hashCount + 1; - while (q < line.Length) - { - // Closing `"#` with matching hash count. - // 一致 hash 数の閉じ `"#`。 - if (line[q] == '"' && HasHashRun(line, q + 1, hashCount)) - { - ReplaceWithSpaces(masked, q, 1 + hashCount); - return q + 1 + hashCount; - } - // Interpolation hole opener `\#(` with matching hash run. Mask the - // `\#(` opener but preserve the body until the matching `)` so the - // real call inside the hole survives masking. - // 一致 hash 数の補間ホール `\#(`。`\#(` 自体はマスクし、本文は本物の - // call を残すために保存し、対応する `)` で閉じる。 - if (line[q] == '\\' - && HasHashRun(line, q + 1, hashCount) - && q + 1 + hashCount < line.Length - && line[q + 1 + hashCount] == '(') - { - ReplaceWithSpaces(masked, q, 2 + hashCount); - q += 2 + hashCount; - var holeDepth = 0; - while (q < line.Length) - { - // Nested single-line raw string inside the hole. Recurse so the - // nested `\(...)` bodies remain visible too. - // ホール内に入れ子の単行 raw 文字列があれば再帰処理し、 - // 内側の `\(...)` 本文も見えるままにする。 - var nestedHashCount = CountRun(line, q, '#'); - if (nestedHashCount > 0 - && q + nestedHashCount < line.Length - && line[q + nestedHashCount] == '"') - { - q = MaskSwiftSingleLineRawString(line, q, nestedHashCount, masked); - continue; - } - - if (line[q] == '"' || line[q] == '\'') - { - q = SkipJsSingleLineString(line, q); - continue; - } - if (line[q] == '(') - { - holeDepth++; - q++; - continue; - } - if (line[q] == ')') - { - if (holeDepth == 0) - { - masked[q] = ' '; - q++; - break; - } - holeDepth--; - q++; - continue; - } - q++; - } - continue; - } - masked[q] = ' '; - q++; - } - return q; - } - - private static int SkipJsSingleLineString(string line, int startIndex) - { - var quote = line[startIndex]; - var p = startIndex + 1; - while (p < line.Length && line[p] != quote) - { - if (line[p] == '\\' && p + 1 < line.Length) - p += 2; - else - p++; - } - if (p < line.Length) - p++; - return p; - } - - private static int SkipJsSingleLineStringContinuation(string line, int startIndex, out bool continuesOnNextLine) - { - var quote = line[startIndex]; - var p = startIndex + 1; - while (p < line.Length && line[p] != quote) - { - if (line[p] == '\\') - { - if (p + 1 == line.Length) - { - continuesOnNextLine = true; - return p + 1; - } - - if (p + 2 == line.Length && line[p + 1] == '\r') - { - continuesOnNextLine = true; - return line.Length; - } - - p += 2; - continue; - } - - p++; - } - - if (p < line.Length) - p++; - - continuesOnNextLine = false; - return p; - } - - // Kotlin multi-line raw string literals: """...""". - // Body is raw (no backslash escape processing). Interpolation: $identifier and - // ${expression}. Only ${expr} hole contents are preserved so downstream reference - // extraction still sees real call edges; $ident is a bare identifier that cannot - // be a call by itself, so masking the surrounding body is safe. - // Regression target: issue #385. - // Kotlin の複数行 raw 文字列 """...""" を扱う。本文は raw(\ エスケープなし)。 - // 補間は $identifier と ${expression}。${expr} ホール内の本物の呼び出しを - // 参照抽出に残すため、ホール内は保存する。$ident は単独識別子で call にならないため - // 周囲本体と一緒にマスクしてよい。回帰対象: issue #385。 - private static void MaskKotlinTripleStringContents(string[] lines) - { - var insideTriple = false; - var blockCommentDepth = 0; - // Hole state persists across lines so multi-line ${ ... } bodies keep real - // call edges and do not accidentally close at the wrong `}`. - // -1 when outside a hole, >=0 = nested `{` depth inside the hole (0 = top). - // ホール状態は行をまたいで保持する。ホール外は -1、ホール内は `{` 深さ(0 が最上位)。 - var holeBraceDepth = -1; - // Persistent across lines: a nested `"""..."""` literal opened inside the - // current `${ ... }` hole. While true, the nested literal acts like its own - // mini triple body — `${...}` holes inside it still preserve real call - // edges (closes #996), but body chars between holes are masked through to - // the next `"""` closer so call-shaped identifiers cannot leak (closes #992). - // ホール内に開いた nested triple-quoted string の状態。nested literal 内も - // 自身の `${...}` ホールでは本物の call を残しつつ、本文は次の `"""` まで - // 空白化して phantom call の漏れを防ぐ。 - var nestedTripleOpen = false; - // -1 when not inside a nested-triple ${...} hole, >=0 = brace depth of that - // inner hole. The inner hole preserves real call edges inside the nested - // triple-quoted literal. - // nested triple 内 `${...}` ホールの brace 深さ。-1 はホール外。 - var nestedHoleBraceDepth = -1; - // Defensive depth tracking for triple-quoted literals opened 3+ levels deep - // (i.e. inside the nested triple's own `${...}` hole). >0 = current 3+ deep - // body. While >0, every char is masked and `"""` toggles depth so phantom - // calls cannot leak. Real calls 4+ levels deep are not preserved — full - // stack tracking would be needed for that — but masking soundness is. - // 3 段以上のネスト triple に対する防御的な深さ追跡。> 0 の間は本文をマスクし、 - // 4 段以上の本物の call は保持しないが、phantom の漏れは防ぐ。 - var deepNestedTripleDepth = 0; - var deepNestedTripleHashCounts = new Stack(); - - for (int i = 0; i < lines.Length; i++) - { - var line = lines[i]; - if (line.Length == 0) - continue; - - char[]? masked = null; - char[] GetMaskedLine() => masked ??= line.ToCharArray(); - var pos = 0; - - while (pos < line.Length) - { - if (blockCommentDepth > 0) - { - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - blockCommentDepth++; - pos += 2; - continue; - } - if (pos + 1 < line.Length && line[pos] == '*' && line[pos + 1] == '/') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - blockCommentDepth--; - pos += 2; - continue; - } - GetMaskedLine()[pos] = ' '; - pos++; - continue; - } - - if (insideTriple) - { - if (holeBraceDepth >= 0) - { - // Inside ${expr} hole: preserve body. Block comments and line - // comments must be recognized first so a legal `/* } */` inside - // the hole does not close the hole at the comment body's `}`. - // Nested single-line strings and char literals are also skipped - // so their `}` does not close the hole, and nested `{` / `}` - // are tracked for lambdas / object literals. - // ${expr} ホール内: 本文を保存。block / line コメントを先に - // 認識して `/* } */` のようなコメント内 `}` でホールを早閉じ - // しないようにする。単行文字列・char リテラルも同様にスキップし、 - // lambda / object literal 用のネスト `{` / `}` を追跡する。 - if (nestedTripleOpen) - { - if (nestedHoleBraceDepth >= 0) - { - // Inside the nested triple's own ${expr} hole: preserve - // body chars so real call edges land in the reference - // graph. Closes #996. - // nested triple 内の `${expr}` ホール内: 本文を保存し、 - // 本物の call が reference graph に届くようにする。 - if (deepNestedTripleDepth > 0) - { - // 3+ level deep triple body: keep masking through - // nested open/close pairs so a 4th opener cannot - // unwind the 3-deep frame early. - // 3 段以上深い triple 本文: ネスト open/close を - // 追跡し、4 段目の opener で 3 段深い frame が - // 早抜けしないようにする。 - if (pos + 2 < line.Length - && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"') - { - var looksLikeNestedOpen = LooksLikeDeepTripleOpenerContext(lines, i, pos, 3); - if (looksLikeNestedOpen) - { - ReplaceWithSpaces(GetMaskedLine(), pos, 3); - pos += 3; - deepNestedTripleDepth++; - deepNestedTripleHashCounts.Push(0); - continue; - } - - ReplaceWithSpaces(GetMaskedLine(), pos, 3); - pos += 3; - deepNestedTripleDepth--; - if (deepNestedTripleHashCounts.Count > 0) - deepNestedTripleHashCounts.Pop(); - continue; - } - GetMaskedLine()[pos] = ' '; - pos++; - continue; - } - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') - { - ReplaceWithSpaces(GetMaskedLine(), pos, line.Length - pos); - pos = line.Length; - continue; - } - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - blockCommentDepth = 1; - pos += 2; - continue; - } - // 3rd-level triple opener inside the inner hole. - // Detect before the single-line-string skipper so the - // leading `"` does not advance us into the literal - // body via SkipJsSingleLineString and break paren / brace - // counting. - // 3 段目の triple opener。先頭 `"` が単行スキッパーへ - // 渡って literal 本体に進まないよう先に検知する。 - if (pos + 2 < line.Length - && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 3); - pos += 3; - deepNestedTripleDepth = 1; - deepNestedTripleHashCounts.Push(0); - continue; - } - if (line[pos] == '"' || line[pos] == '\'') - { - pos = SkipJsSingleLineString(line, pos); - continue; - } - if (line[pos] == '{') - { - nestedHoleBraceDepth++; - pos++; - continue; - } - if (line[pos] == '}') - { - if (nestedHoleBraceDepth == 0) - { - GetMaskedLine()[pos] = ' '; - nestedHoleBraceDepth = -1; - pos++; - continue; - } - nestedHoleBraceDepth--; - pos++; - continue; - } - pos++; - continue; - } - - // Inside a nested `"""..."""` literal opened earlier in this - // outer hole. Recognize a closing `"""`, an opening `${...}` - // hole inside the nested literal (so real calls inside it - // still reach the reference graph), and otherwise mask. - // 外側ホール内で開いた nested triple 本体。閉じ `"""`、内側 - // `${...}` ホール、それ以外は body としてマスク。 - if (pos + 2 < line.Length - && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 3); - pos += 3; - nestedTripleOpen = false; - nestedHoleBraceDepth = -1; - deepNestedTripleDepth = 0; - deepNestedTripleHashCounts.Clear(); - continue; - } - if (pos + 1 < line.Length && line[pos] == '$' && line[pos + 1] == '{') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - nestedHoleBraceDepth = 0; - pos += 2; - continue; - } - GetMaskedLine()[pos] = ' '; - pos++; - continue; - } - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') - { - ReplaceWithSpaces(GetMaskedLine(), pos, line.Length - pos); - pos = line.Length; - continue; - } - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - blockCommentDepth = 1; - pos += 2; - continue; - } - - // Nested `"""..."""` literal opener inside the hole. Detect - // before the single-line-string skipper so the first `"` does - // not advance us into the literal body via `SkipJsSingleLineString`. - // ホール内で開く nested `"""..."""` の opener。先頭 `"` が単行 - // 文字列スキッパーに渡って literal 本体へ進まないよう先に検知する。 - if (pos + 2 < line.Length - && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 3); - pos += 3; - nestedTripleOpen = true; - nestedHoleBraceDepth = -1; - continue; - } - - if (line[pos] == '"' || line[pos] == '\'') - { - pos = SkipJsSingleLineString(line, pos); - continue; - } - - if (line[pos] == '{') - { - holeBraceDepth++; - pos++; - continue; - } - - if (line[pos] == '}') - { - if (holeBraceDepth == 0) - { - GetMaskedLine()[pos] = ' '; - holeBraceDepth = -1; - pos++; - continue; - } - - holeBraceDepth--; - pos++; - continue; - } - - pos++; - continue; - } - - if (pos + 2 < line.Length - && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 3); - pos += 3; - insideTriple = false; - // Defensive: any open nested-triple state is owned by the just- - // closed outer triple, so reset it as well. - // 防御的に、外側 triple を閉じた時点で nested-triple 状態も解除する。 - nestedTripleOpen = false; - nestedHoleBraceDepth = -1; - deepNestedTripleDepth = 0; - deepNestedTripleHashCounts.Clear(); - continue; - } - - if (pos + 1 < line.Length && line[pos] == '$' && line[pos + 1] == '{') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - holeBraceDepth = 0; - pos += 2; - continue; - } - - GetMaskedLine()[pos] = ' '; - pos++; - continue; - } - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') - break; - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - blockCommentDepth = 1; - pos += 2; - continue; - } - - if (pos + 2 < line.Length - && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 3); - pos += 3; - insideTriple = true; - continue; - } - - if (line[pos] == '"' || line[pos] == '\'') - { - pos = SkipJsSingleLineString(line, pos); - continue; - } - - pos++; - } - - if (masked is not null) - lines[i] = new string(masked); - } - } - - // Swift multi-line string literals: """...""" and extended """#"""..."""# forms. - // Plain form supports \(expr) interpolation; N-hash extended form needs \#(expr) - // (matching hash count). Interpolation hole contents are preserved so downstream - // reference extraction keeps real call edges inside \(...). - // Regression target: issue #385. - // Swift の複数行文字列 """...""" と拡張 #"""..."""# 系を扱う。通常形の補間は - // \(expr)、N 個の # 付き拡張形は \#(expr)(個数一致)。\(...) ホール内は保存し、 - // 本物の call を参照抽出に見せる。回帰対象: issue #385。 - private static void MaskSwiftMultilineStringContents(string[] lines) - { - var insideTriple = false; - // 0 for plain """...""", N for the extended """#"""..."""# variant. - // 通常 """...""" は 0、拡張形は一致させる # 個数 N。 - var tripleHashCount = 0; - var blockCommentDepth = 0; - // -1 when outside a \(...) interpolation hole, >=0 = nested `(` depth. - // \(...) ホール外は -1、ホール内は `(` 深さ。 - var holeParenDepth = -1; - // Persistent across lines: a nested `"""..."""` or `#"""..."""#` literal - // opened inside the current `\(...)` hole. -1 when no nested triple is - // open; >=0 = leading `#` count required at the matching close. While set, - // the nested literal acts like its own mini triple body — its own - // `\(...)` (or `\#(...)` / `\##(...)` etc.) interpolation holes still - // preserve real call edges (closes #996), and body chars between holes - // are masked through to the close so phantom calls cannot leak (closes #992). - // ホール内に開いた nested `"""..."""` / `#"""..."""#` の状態。-1 は未オープン、 - // 0 以上は閉じに必要な `#` 個数。set 中は内部 `\(...)` ホールでも本物の call を残す。 - var nestedTripleHashCount = -1; - // -1 when not inside the nested triple's own `\(...)` hole, >=0 = paren - // depth of that inner hole. Preserves real call edges inside the nested - // literal. - // nested triple 内 `\(...)` ホールの paren 深さ。-1 はホール外。 - var nestedHoleParenDepth = -1; - // Defensive depth tracking for triple-quoted literals opened 3+ levels deep - // (i.e. inside the nested triple's own `\(...)` hole). >0 = current 3+ deep - // body. While >0, every char is masked and the close requires the same - // hash count as the deep open so phantom calls cannot leak even when the - // deep triple is hash-delimited (`#"""..."""#` etc.). Closes #1000 — the - // earlier version only matched plain `"""` for the close and could exit - // the deep state at the wrong delimiter when the deep triple was raw. - // Real calls 4+ levels deep are not preserved — full stack tracking would - // be needed for that — but masking soundness is. - // 3 段以上のネスト triple に対する防御的な深さ追跡。 - var deepNestedTripleDepth = 0; - // Hash count required at each deep triple's matching close. Stack top - // tracks the currently-open deep frame. - // 各 deep triple の閉じに必要な hash 個数。スタック頂点が現在の deep frame。 - var deepNestedTripleHashCounts = new Stack(); - - for (int i = 0; i < lines.Length; i++) - { - var line = lines[i]; - if (line.Length == 0) - continue; - - char[]? masked = null; - char[] GetMaskedLine() => masked ??= line.ToCharArray(); - var pos = 0; - - while (pos < line.Length) - { - if (blockCommentDepth > 0) - { - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - blockCommentDepth++; - pos += 2; - continue; - } - if (pos + 1 < line.Length && line[pos] == '*' && line[pos + 1] == '/') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - blockCommentDepth--; - pos += 2; - continue; - } - GetMaskedLine()[pos] = ' '; - pos++; - continue; - } - - if (insideTriple) - { - if (holeParenDepth >= 0) - { - // Inside \(expr) hole: preserve body. Block comments and line - // comments must be recognized first so a legal `/* ) */` inside - // the hole does not close the hole at the comment body's `)`. - // Nested single-line strings are also skipped so their `)` does - // not close the hole, and nested `(` / `)` are tracked. - // \(expr) ホール内: 本文を保存。block / line コメントを先に - // 認識して `/* ) */` のようなコメント内 `)` でホールを早閉じ - // しないようにする。単行文字列もスキップし、ネスト `(` / `)` も追跡する。 - if (nestedTripleHashCount >= 0) - { - if (nestedHoleParenDepth >= 0) - { - // Inside the nested triple's own `\(...)` hole: preserve - // body chars so real call edges land in the reference - // graph. Closes #996. - // nested triple 内の `\(...)` ホール内: 本物の call を残す。 - if (deepNestedTripleDepth > 0) - { - // 3+ level deep triple body: mask through nested - // opener/close pairs so a 4th opener cannot unwind - // the 3-deep frame early. - // 3 段以上深い triple 本文: ネスト open/close を - // 追跡し、4 段目の opener で 3 段深い frame が - // 早抜けしないようにする。 - var deepBodyHashes = CountRun(line, pos, '#'); - if (pos + 2 < line.Length - && line[pos] == '"' - && line[pos + 1] == '"' - && line[pos + 2] == '"') - { - var closeHashCount = CountRun(line, pos + 3, '#'); - if (closeHashCount > 0 - && !LooksLikeDeepTripleOpenerContext(lines, i, pos, 3 + closeHashCount)) - { - ReplaceWithSpaces(GetMaskedLine(), pos, 3 + closeHashCount); - pos += 3 + closeHashCount; - deepNestedTripleDepth--; - if (deepNestedTripleHashCounts.Count > 0) - deepNestedTripleHashCounts.Pop(); - continue; - } - var currentDeepHashCount = deepNestedTripleHashCounts.Count > 0 - ? deepNestedTripleHashCounts.Peek() - : 0; - if (closeHashCount == 0 - && currentDeepHashCount == 0 - && !LooksLikeDeepTripleOpenerContext(lines, i, pos, 3)) - { - ReplaceWithSpaces(GetMaskedLine(), pos, 3); - pos += 3; - deepNestedTripleDepth--; - if (deepNestedTripleHashCounts.Count > 0) - deepNestedTripleHashCounts.Pop(); - continue; - } - } - if (pos + 2 < line.Length - && line[pos] == '"' - && line[pos + 1] == '"' - && line[pos + 2] == '"' - && LooksLikeDeepTripleOpenerContext(lines, i, pos, 3)) - { - ReplaceWithSpaces(GetMaskedLine(), pos, 3); - pos += 3; - deepNestedTripleDepth++; - deepNestedTripleHashCounts.Push(0); - continue; - } - if (deepBodyHashes > 0 - && pos + deepBodyHashes + 2 < line.Length - && line[pos + deepBodyHashes] == '"' - && line[pos + deepBodyHashes + 1] == '"' - && line[pos + deepBodyHashes + 2] == '"') - { - var looksLikeNestedOpen = LooksLikeDeepTripleOpenerContext(lines, i, pos, deepBodyHashes + 3); - if (looksLikeNestedOpen) - { - ReplaceWithSpaces(GetMaskedLine(), pos, deepBodyHashes + 3); - pos += deepBodyHashes + 3; - deepNestedTripleDepth++; - deepNestedTripleHashCounts.Push(deepBodyHashes); - continue; - } - - } - - GetMaskedLine()[pos] = ' '; - pos++; - continue; - } - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') - { - ReplaceWithSpaces(GetMaskedLine(), pos, line.Length - pos); - pos = line.Length; - continue; - } - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - blockCommentDepth = 1; - pos += 2; - continue; - } - // 3rd-level triple opener (optionally with leading `#`) - // inside the inner hole. Detect before the single-line - // string skipper so the leading `"` does not advance into - // the literal body via SkipJsSingleLineString and break - // paren counting. - // 3 段目の triple opener。先頭 `"` が単行スキッパーへ - // 渡って literal 本体に進まないよう先に検知する。 - var deepHashes = CountRun(line, pos, '#'); - if (pos + deepHashes + 2 < line.Length - && line[pos + deepHashes] == '"' - && line[pos + deepHashes + 1] == '"' - && line[pos + deepHashes + 2] == '"') - { - ReplaceWithSpaces(GetMaskedLine(), pos, deepHashes + 3); - pos += deepHashes + 3; - deepNestedTripleDepth = 1; - deepNestedTripleHashCounts.Push(deepHashes); - continue; - } - // Single-line `#"..."#` raw string inside the inner hole. - // Preserve any matching `\#(...)` interpolation hole bodies - // so real call edges inside the raw string still reach the - // reference graph. Closes #1001. - // 単行 `#"..."#` 拡張 raw 文字列。内側の `\#(...)` ホール本文は - // 残し、本物の call を reference graph に届ける。 - if (deepHashes > 0 - && pos + deepHashes < line.Length - && line[pos + deepHashes] == '"') - { - pos = MaskSwiftSingleLineRawString(line, pos, deepHashes, GetMaskedLine()); - continue; - } - if (line[pos] == '"' || line[pos] == '\'') - { - pos = SkipJsSingleLineString(line, pos); - continue; - } - if (line[pos] == '(') - { - nestedHoleParenDepth++; - pos++; - continue; - } - if (line[pos] == ')') - { - if (nestedHoleParenDepth == 0) - { - GetMaskedLine()[pos] = ' '; - nestedHoleParenDepth = -1; - pos++; - continue; - } - nestedHoleParenDepth--; - pos++; - continue; - } - pos++; - continue; - } - - // Inside a nested `"""..."""` (optionally hash-delimited) literal - // opened earlier in this outer hole. Recognize the matching close, - // a `\(...)` (or `\#(...)` / `\##(...)` etc.) interpolation hole - // opener inside the nested literal so real calls inside it still - // reach the reference graph, and otherwise mask the body. - // 外側ホール内で開いた nested triple 本体。一致 hash 数の `"""` - // クローザ、内側 `\(...)` ホール(hash 数一致)、それ以外は body - // としてマスク。 - if (pos + 2 < line.Length - && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"' - && HasHashRun(line, pos + 3, nestedTripleHashCount)) - { - ReplaceWithSpaces(GetMaskedLine(), pos, 3 + nestedTripleHashCount); - pos += 3 + nestedTripleHashCount; - nestedTripleHashCount = -1; - nestedHoleParenDepth = -1; - deepNestedTripleDepth = 0; - deepNestedTripleHashCounts.Clear(); - continue; - } - if (line[pos] == '\\' - && HasHashRun(line, pos + 1, nestedTripleHashCount) - && pos + 1 + nestedTripleHashCount < line.Length - && line[pos + 1 + nestedTripleHashCount] == '(') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2 + nestedTripleHashCount); - pos += 2 + nestedTripleHashCount; - nestedHoleParenDepth = 0; - continue; - } - // Plain (non-raw) nested triple: `\\` is a literal backslash. - // 通常 nested triple 内: `\\` は literal backslash。 - if (nestedTripleHashCount == 0 && line[pos] == '\\' && pos + 1 < line.Length) - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - pos += 2; - continue; - } - GetMaskedLine()[pos] = ' '; - pos++; - continue; - } - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') - { - ReplaceWithSpaces(GetMaskedLine(), pos, line.Length - pos); - pos = line.Length; - continue; - } - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - blockCommentDepth = 1; - pos += 2; - continue; - } - - // Nested triple-quoted string opener inside the hole: optional - // leading `#` run then `"""`. Detect before the single-line-string - // skipper so the first `"` of `"""` does not advance into the body. - // ホール内で開く nested triple の opener。先頭 `"` が単行文字列 - // スキッパーに渡って literal 本体へ進まないよう先に検知する。 - var holeNestedHashes = CountRun(line, pos, '#'); - if (pos + holeNestedHashes + 2 < line.Length - && line[pos + holeNestedHashes] == '"' - && line[pos + holeNestedHashes + 1] == '"' - && line[pos + holeNestedHashes + 2] == '"') - { - ReplaceWithSpaces(GetMaskedLine(), pos, holeNestedHashes + 3); - pos += holeNestedHashes + 3; - nestedTripleHashCount = holeNestedHashes; - continue; - } - - // Single-line `#"..."#` extended raw string inside the outer - // hole. The body may contain unescaped `"`, `(`, and `)`, so - // the generic single-line skipper would stop at the first `"` - // and leave the remainder visible — breaking the outer hole's - // paren counting. Use the shared raw-string helper to mask - // through to the matching `"` close while preserving - // any `\(...)` interpolation hole bodies. Closes #1001. - // ホール内の単行 `#"..."#` 拡張 raw 文字列。body に `"` / `(` / `)` - // を含むため通常スキッパーは早すぎて止まる。共有ヘルパーで - // `"` クローザまでマスクし、`\(...)` ホール本文は残す。 - if (holeNestedHashes > 0 - && pos + holeNestedHashes < line.Length - && line[pos + holeNestedHashes] == '"') - { - pos = MaskSwiftSingleLineRawString(line, pos, holeNestedHashes, GetMaskedLine()); - continue; - } - - if (line[pos] == '"' || line[pos] == '\'') - { - pos = SkipJsSingleLineString(line, pos); - continue; - } - - if (line[pos] == '(') - { - holeParenDepth++; - pos++; - continue; - } - - if (line[pos] == ')') - { - if (holeParenDepth == 0) - { - GetMaskedLine()[pos] = ' '; - holeParenDepth = -1; - pos++; - continue; - } - - holeParenDepth--; - pos++; - continue; - } - - pos++; - continue; - } - - // Closing """[#...] with matching hash count. - // 閉じ """[#...](hash 数一致)。 - if (pos + 2 < line.Length - && line[pos] == '"' && line[pos + 1] == '"' && line[pos + 2] == '"' - && HasHashRun(line, pos + 3, tripleHashCount)) - { - ReplaceWithSpaces(GetMaskedLine(), pos, 3 + tripleHashCount); - pos += 3 + tripleHashCount; - insideTriple = false; - tripleHashCount = 0; - // Defensive: outer triple owns any nested-triple state from a - // hole, so reset it as well when the outer literal closes. - // 防御的に、外側 triple が閉じた時点で nested-triple 状態も解除する。 - nestedTripleHashCount = -1; - nestedHoleParenDepth = -1; - deepNestedTripleDepth = 0; - deepNestedTripleHashCounts.Clear(); - continue; - } - - if (line[pos] == '\\') - { - // \(expr) interpolation opener (for raw forms, needs matching - // `#` run: \#(, \##(, ...). - // \(expr) 補間の開始。拡張形では hash 数一致が必要: \#(、\##( など。 - if (HasHashRun(line, pos + 1, tripleHashCount) - && pos + 1 + tripleHashCount < line.Length - && line[pos + 1 + tripleHashCount] == '(') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2 + tripleHashCount); - pos += 2 + tripleHashCount; - holeParenDepth = 0; - continue; - } - - // Plain `"""..."""`: `\\` is a literal backslash — consume both - // so the second char cannot accidentally start a triple close or - // escape parser. - // 通常 `"""..."""`: `\\` は literal backslash。2 文字まとめて - // 消費し、2 文字目が triple close の一部と誤検出されないようにする。 - if (tripleHashCount == 0 && pos + 1 < line.Length) - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - pos += 2; - continue; - } - - // Extended form `#"""..."""#` (or more hashes): without a - // matching `\#` run the backslash is literal; advance one char. - // 拡張形 `#"""..."""#` など: hash 数が一致しない `\` は literal。 - GetMaskedLine()[pos] = ' '; - pos++; - continue; - } - - GetMaskedLine()[pos] = ' '; - pos++; - continue; - } - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '/') - break; - - if (pos + 1 < line.Length && line[pos] == '/' && line[pos + 1] == '*') - { - ReplaceWithSpaces(GetMaskedLine(), pos, 2); - blockCommentDepth = 1; - pos += 2; - continue; - } - - // Extended / plain triple-quoted opener: optional leading `#` run then `"""`. - // 拡張または通常の triple 開始: 任意の `#` 列 + `"""`。 - var leadingHashes = CountRun(line, pos, '#'); - if (pos + leadingHashes + 2 < line.Length - && line[pos + leadingHashes] == '"' - && line[pos + leadingHashes + 1] == '"' - && line[pos + leadingHashes + 2] == '"') - { - ReplaceWithSpaces(GetMaskedLine(), pos, leadingHashes + 3); - pos += leadingHashes + 3; - insideTriple = true; - tripleHashCount = leadingHashes; - continue; - } - - // Single-line extended raw string `#"..."#` with matching `#` run. - // The body may contain unescaped `"`, so the generic single-quote - // skipper would stop too early. Use the shared helper to mask through - // to the matching `"` close while preserving any matching - // `\(...)` interpolation hole bodies (closes #1001). - // 単行の `#"..."#` 拡張 raw 文字列。共有ヘルパーで `"` まで - // マスクし、内側の `\(...)` ホール本文は残す。 - if (leadingHashes > 0 - && pos + leadingHashes < line.Length - && line[pos + leadingHashes] == '"') - { - pos = MaskSwiftSingleLineRawString(line, pos, leadingHashes, GetMaskedLine()); - continue; - } - - if (line[pos] == '"') - { - pos = SkipJsSingleLineString(line, pos); - continue; - } - - pos++; - } - - if (masked is not null) - lines[i] = new string(masked); - } - } - - // Scala multi-line string literals: """...""". Only interpolator-prefixed forms - // (s""", f""", raw""", or any identifier-prefixed form) interpret $ident / ${expr} - // holes; plain """...""" is a raw literal with no interpolation. ${expr} hole - // contents are preserved so downstream reference extraction keeps real call - // edges inside ${...}; bare $ident is not a call and is masked with the body. - // Regression target: issue #385. - // Scala の複数行文字列 """..."""。補間は interpolator prefix(`s"""` / `f"""` / - // `raw"""`、または任意の識別子 prefix)のときだけ有効。プレーン """...""" は - // 補間なしの raw。${expr} ホール内は本物の call を参照抽出に残すため保存、 - // `$ident` は単独識別子で call にならないため本体とともにマスクする。 - // 回帰対象: issue #385。 - private static bool IsIdentifierPart(char c) => - c == '_' || char.IsLetterOrDigit(c); } From 04cbc3184d2a99d8a9052be030ae53f6f57f6fc3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:29:33 +0900 Subject: [PATCH 047/101] Split cross-language reference support --- .../LanguageReferenceExtractionSupport.Go.cs | 1546 ----------------- ...renceExtractionSupport.GoCompositeTypes.cs | 700 ++++++++ ...ceExtractionSupport.GoMethodExpressions.cs | 458 +++++ ...ReferenceExtractionSupport.GoSignatures.cs | 415 +++++ ...uageReferenceExtractionSupport.Patterns.cs | 502 ++++++ ...ferenceExtractionSupport.SecondaryCalls.cs | 270 +++ ...ferenceExtractionSupport.SecondaryTypes.cs | 212 +++ ...ageReferenceExtractionSupport.Utilities.cs | 167 ++ .../LanguageReferenceExtractionSupport.cs | 1115 ------------ 9 files changed, 2724 insertions(+), 2661 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.GoCompositeTypes.cs create mode 100644 src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.GoMethodExpressions.cs create mode 100644 src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.GoSignatures.cs create mode 100644 src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.Patterns.cs create mode 100644 src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.SecondaryCalls.cs create mode 100644 src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.SecondaryTypes.cs create mode 100644 src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.Utilities.cs diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.Go.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.Go.cs index 02b82bf5b..7c7c5fd77 100644 --- a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.Go.cs +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.Go.cs @@ -688,1550 +688,4 @@ private static void EmitGoEmbeddedFieldType( EmitGoTypeExpression(line[typeStart..cursor], typeStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); } - private static void EmitGoBuiltinTypeArgumentReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - foreach (Match match in GoBuiltinTypeArgumentRegex.Matches(line)) - { - var open = line.IndexOf('(', match.Index); - if (open < 0) - continue; - - var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); - if (close < 0) - continue; - - var argumentList = line[(open + 1)..close]; - var firstArgument = ReferenceExtractor.GetFirstTopLevelCommaSpan(argumentList); - if (firstArgument.Length <= 0) - continue; - - var rawType = argumentList.Substring(firstArgument.Start, firstArgument.Length); - var expression = rawType.Trim(); - if (expression.Length == 0) - continue; - - var trimStart = rawType.IndexOf(expression, StringComparison.Ordinal); - var absoluteStart = open + 1 + firstArgument.Start + Math.Max(0, trimStart); - EmitGoTypeExpression(expression, absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static void EmitGoTypeAssertionReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - foreach (Match match in GoTypeAssertionRegex.Matches(line)) - { - var group = match.Groups["type"]; - var expression = group.Value.Trim(); - if (expression.Length == 0 || string.Equals(expression, "type", StringComparison.Ordinal)) - continue; - - var trimStart = group.Value.IndexOf(expression, StringComparison.Ordinal); - EmitGoTypeExpression(expression, group.Index + Math.Max(0, trimStart), references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static void EmitGoTypeSwitchCaseReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var match = GoTypeSwitchCaseRegex.Match(line); - if (!match.Success) - return; - - var group = match.Groups["types"]; - var typeList = group.ValueSpan; - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeList)) - { - var expressionSpan = TrimGoCommaSegment(typeList.Slice(segmentStart, segmentLength), out var trimStart); - var expression = expressionSpan.ToString(); - if (expression.Length == 0 - || expression is "nil" or "default" - || !IsLikelyGoTypeSwitchCaseType(expression)) - { - continue; - } - - EmitGoTypeExpression(expression, group.Index + segmentStart + Math.Max(0, trimStart), references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static bool IsLikelyGoTypeSwitchCaseType(string expression) - { - var cursor = 0; - var hasPointerPrefix = false; - while (cursor < expression.Length) - { - cursor = SkipWhitespace(expression, cursor); - if (cursor >= expression.Length || expression[cursor] != '*') - break; - - hasPointerPrefix = true; - cursor++; - } - - if (cursor >= expression.Length) - return false; - if (expression[cursor] == '[') - return true; - if (hasPointerPrefix && char.IsUpper(expression[cursor])) - return true; - if (hasPointerPrefix && expression.IndexOf('.', cursor) >= 0) - return true; - - return StartsWithKeyword(expression, cursor, "map") - || StartsWithKeyword(expression, cursor, "chan") - || StartsWithKeyword(expression, cursor, "func") - || StartsWithKeyword(expression, cursor, "interface") - || StartsWithKeyword(expression, cursor, "struct"); - } - - private static void EmitGoChannelElementTypeReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var searchStart = 0; - while (searchStart < line.Length) - { - var chanIndex = line.IndexOf("chan", searchStart, StringComparison.Ordinal); - if (chanIndex < 0) - return; - - searchStart = chanIndex + "chan".Length; - if (!IsIdentifierAt(line, chanIndex, "chan")) - continue; - - var elementStart = SkipWhitespace(line, searchStart); - if (elementStart + 1 < line.Length && line[elementStart] == '<' && line[elementStart + 1] == '-') - elementStart = SkipWhitespace(line, elementStart + 2); - - if (elementStart >= line.Length || !IsGoTypeExpressionStart(line, elementStart)) - continue; - - var elementEnd = FindGoInlineTypeExpressionEnd(line, elementStart); - if (elementEnd <= elementStart) - continue; - - EmitGoTypeExpression(line[elementStart..elementEnd], elementStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static void EmitGoFunctionLiteralSignatureTypes( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - foreach (Match match in GoFunctionLiteralRegex.Matches(line)) - { - var open = line.IndexOf('(', match.Index); - if (open < 0) - continue; - - var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); - if (close < 0) - continue; - - EmitGoParameterListTypes(line, open + 1, close, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitGoSignatureReturnTypes(line, close + 1, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static void EmitGoGenericCallTypeArgumentReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (line.IndexOf("func", StringComparison.Ordinal) >= 0 - && GoFuncRegex.IsMatch(line)) - { - return; - } - - var searchStart = 0; - while (searchStart < line.Length) - { - var open = line.IndexOf('[', searchStart); - if (open < 0) - return; - - searchStart = open + 1; - if (!HasGoIdentifierBeforeBracket(line, open)) - continue; - - var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); - if (close < 0) - continue; - - var afterClose = SkipWhitespace(line, close + 1); - if (afterClose >= line.Length || line[afterClose] != '(') - continue; - - var typeArguments = line.AsSpan(open + 1, close - open - 1); - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeArguments)) - { - var expressionSpan = TrimGoCommaSegment(typeArguments.Slice(segmentStart, segmentLength), out var trimStart); - var expression = expressionSpan.ToString(); - if (expression.Length == 0 || !ContainsLikelyGoTypeArgument(expression)) - continue; - - var absoluteStart = open + 1 + segmentStart + Math.Max(0, trimStart); - EmitGoTypeExpression(expression, absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - } - - private static void EmitGoFunctionTypeSignatureTypes( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var searchStart = 0; - while (searchStart < line.Length) - { - var funcIndex = line.IndexOf("func", searchStart, StringComparison.Ordinal); - if (funcIndex < 0) - return; - - searchStart = funcIndex + "func".Length; - if (!IsIdentifierAt(line, funcIndex, "func")) - continue; - - var open = SkipWhitespace(line, searchStart); - if (open >= line.Length || line[open] != '(') - continue; - - var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); - if (close < 0) - continue; - - EmitGoParameterListTypes(line, open + 1, close, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitGoSignatureReturnTypes(line, close + 1, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static void EmitGoGenericCompositeLiteralReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var searchStart = 0; - while (searchStart < line.Length) - { - var open = line.IndexOf('[', searchStart); - if (open < 0) - return; - - searchStart = open + 1; - if (!TryGetGoIdentifierBeforeBracket(line, open, out var nameStart, out var nameLength)) - continue; - - var typeName = line.Substring(nameStart, nameLength); - if (typeName.Length == 0 || !char.IsUpper(typeName[0])) - continue; - - var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); - if (close < 0) - continue; - - var afterClose = SkipWhitespace(line, close + 1); - if (afterClose >= line.Length || line[afterClose] != '{') - continue; - - if (!IsGoCompositeLiteralContext(line, nameStart, nameLength)) - continue; - - ReferenceExtractor.AddReference(references, seen, fileId, typeName, nameStart, "instantiate", context, lineNumber, resolveContainerForColumn(nameStart)); - - var typeArguments = line.AsSpan(open + 1, close - open - 1); - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeArguments)) - { - var expressionSpan = TrimGoCommaSegment(typeArguments.Slice(segmentStart, segmentLength), out var trimStart); - var expression = expressionSpan.ToString(); - if (expression.Length == 0 || !ContainsLikelyGoTypeArgument(expression)) - continue; - - var absoluteStart = open + 1 + segmentStart + Math.Max(0, trimStart); - EmitGoTypeExpression(expression, absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - } - - private static void EmitGoInlineStructFieldTypeReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var searchStart = 0; - while (searchStart < line.Length) - { - var structIndex = line.IndexOf("struct", searchStart, StringComparison.Ordinal); - if (structIndex < 0) - return; - - searchStart = structIndex + "struct".Length; - if (!IsIdentifierAt(line, structIndex, "struct")) - continue; - - var open = SkipWhitespace(line, searchStart); - if (open >= line.Length || line[open] != '{') - continue; - - var close = ReferenceExtractor.FindMatchingChar(line, open, '{', '}'); - if (close <= open + 1) - continue; - - var body = line[(open + 1)..close]; - var bodyStart = open + 1; - foreach (var (fieldStart, fieldLength) in SplitGoInlineStructFieldSpans(body)) - EmitGoInlineStructFieldType(body.Substring(fieldStart, fieldLength), bodyStart + fieldStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static List<(int Start, int Length)> SplitGoInlineStructFieldSpans(string body) - { - var spans = new List<(int Start, int Length)>(4); - var fieldStart = 0; - var squareDepth = 0; - var parenDepth = 0; - for (var cursor = 0; cursor < body.Length; cursor++) - { - switch (body[cursor]) - { - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) - squareDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) - parenDepth--; - break; - case ';': - if (squareDepth == 0 && parenDepth == 0) - { - spans.Add((fieldStart, cursor - fieldStart)); - fieldStart = cursor + 1; - } - break; - } - } - - spans.Add((fieldStart, body.Length - fieldStart)); - return spans; - } - - private static void EmitGoInlineStructFieldType( - string rawField, - int rawFieldStart, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var tagStart = rawField.IndexOf('`'); - if (tagStart >= 0) - rawField = rawField[..tagStart]; - - var field = rawField.Trim(); - if (field.Length == 0) - return; - - var fieldTrimStart = rawField.IndexOf(field, StringComparison.Ordinal); - var absoluteFieldStart = rawFieldStart + Math.Max(0, fieldTrimStart); - var typeStart = LastWhitespaceSeparatedTokenStart(field); - if (typeStart < 0) - return; - - var expression = typeStart == 0 ? field : field[typeStart..]; - EmitGoTypeExpression(expression, absoluteFieldStart + typeStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static void EmitGoInlineInterfaceMemberTypeReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var searchStart = 0; - while (searchStart < line.Length) - { - var interfaceIndex = line.IndexOf("interface", searchStart, StringComparison.Ordinal); - if (interfaceIndex < 0) - return; - - searchStart = interfaceIndex + "interface".Length; - if (!IsIdentifierAt(line, interfaceIndex, "interface")) - continue; - - var open = SkipWhitespace(line, searchStart); - if (open >= line.Length || line[open] != '{') - continue; - - var close = ReferenceExtractor.FindMatchingChar(line, open, '{', '}'); - if (close <= open + 1) - continue; - - var body = line[(open + 1)..close]; - var bodyStart = open + 1; - foreach (var (memberStart, memberLength) in SplitGoInlineStructFieldSpans(body)) - EmitGoInlineInterfaceMemberTypes(line, bodyStart + memberStart, bodyStart + memberStart + memberLength, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static void EmitGoInlineInterfaceMemberTypes( - string line, - int memberStart, - int memberEnd, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var cursor = SkipWhitespace(line, memberStart); - if (cursor >= memberEnd) - return; - - if (!IsIdentifierStart(line[cursor])) - { - EmitGoInlineInterfaceEmbeddedType(line, cursor, memberEnd, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - return; - } - - var nameStart = cursor; - cursor++; - while (cursor < memberEnd && IsSimpleIdentifierPart(line[cursor])) - cursor++; - - var name = line[nameStart..cursor]; - if (IsGoStatementKeyword(name)) - return; - - var open = SkipWhitespace(line, cursor); - if (open < memberEnd && line[open] == '(') - { - var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); - if (close > open && close <= memberEnd) - { - EmitGoParameterListTypes(line, open + 1, close, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitGoSignatureReturnTypesInRange(line, close + 1, memberEnd, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - return; - } - - EmitGoInlineInterfaceEmbeddedType(line, nameStart, memberEnd, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static void EmitGoInlineInterfaceEmbeddedType( - string line, - int start, - int end, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var typeStart = SkipWhitespace(line, start); - if (typeStart >= end) - return; - - var expression = line[typeStart..end].Trim(); - if (expression.Length == 0) - return; - - EmitGoTypeExpression(expression, typeStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static void EmitGoSignatureReturnTypesInRange( - string line, - int start, - int end, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var returnStart = SkipWhitespace(line, start); - if (returnStart >= end || line[returnStart] == '{') - return; - - if (line[returnStart] == '(') - { - var returnClose = ReferenceExtractor.FindMatchingChar(line, returnStart, '(', ')'); - if (returnClose > returnStart && returnClose <= end) - EmitGoParameterListTypes(line, returnStart + 1, returnClose, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - return; - } - - EmitGoTypeExpression(line, returnStart, end, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static void EmitGoMapCompositeLiteralTypeReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var searchStart = 0; - while (searchStart < line.Length) - { - var mapIndex = line.IndexOf("map", searchStart, StringComparison.Ordinal); - if (mapIndex < 0) - return; - - searchStart = mapIndex + "map".Length; - if (!IsIdentifierAt(line, mapIndex, "map")) - continue; - - var open = SkipWhitespace(line, searchStart); - if (open >= line.Length || line[open] != '[') - continue; - - var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); - if (close < 0) - continue; - - var valueStart = SkipWhitespace(line, close + 1); - if (valueStart >= line.Length || !IsGoTypeExpressionStart(line, valueStart)) - continue; - - var valueEnd = FindGoInlineTypeExpressionEnd(line, valueStart); - var literalOpen = SkipWhitespace(line, valueEnd); - if (literalOpen >= line.Length || line[literalOpen] != '{') - continue; - - var keyExpression = line[(open + 1)..close].Trim(); - if (keyExpression.Length > 0) - { - var keyStart = line.IndexOf(keyExpression, open + 1, StringComparison.Ordinal); - EmitGoTypeExpression(keyExpression, keyStart >= 0 ? keyStart : open + 1, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - EmitGoTypeExpression(line[valueStart..valueEnd], valueStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static void EmitGoArraySliceCompositeLiteralTypeReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var searchStart = 0; - while (searchStart < line.Length) - { - var open = line.IndexOf('[', searchStart); - if (open < 0) - return; - - searchStart = open + 1; - var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); - if (close < 0) - continue; - - var elementStart = SkipWhitespace(line, close + 1); - if (elementStart >= line.Length || !IsGoTypeExpressionStart(line, elementStart)) - continue; - - var elementEnd = FindGoInlineTypeExpressionEnd(line, elementStart); - var literalOpen = SkipWhitespace(line, elementEnd); - if (literalOpen >= line.Length || line[literalOpen] != '{') - continue; - - if (!IsGoCompositeLiteralContext(line, open, elementEnd - open)) - continue; - - EmitGoTypeExpression(line[elementStart..elementEnd], elementStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static void EmitGoParenthesizedTypeConversionReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var searchStart = 0; - while (searchStart < line.Length) - { - var open = line.IndexOf('(', searchStart); - if (open < 0) - return; - - searchStart = open + 1; - var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); - if (close <= open + 1) - continue; - - var afterClose = SkipWhitespace(line, close + 1); - if (afterClose >= line.Length || line[afterClose] != '(') - continue; - - var rawExpression = line[(open + 1)..close]; - var expression = rawExpression.Trim(); - if (!IsLikelyGoParenthesizedConversionType(expression)) - continue; - - var trimStart = rawExpression.IndexOf(expression, StringComparison.Ordinal); - EmitGoTypeExpression(expression, open + 1 + Math.Max(0, trimStart), references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static void EmitGoCompositeTypeConversionReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var searchStart = 0; - while (searchStart < line.Length) - { - var typeStart = NextGoCompositeConversionTypeStart(line, searchStart); - if (typeStart < 0) - return; - - searchStart = typeStart + 1; - if (!IsGoTypeExpressionValueContext(line, typeStart)) - continue; - - var typeEnd = FindGoConversionTypeExpressionEnd(line, typeStart); - if (typeEnd <= typeStart) - continue; - - var open = SkipWhitespace(line, typeEnd); - if (open >= line.Length || line[open] != '(') - continue; - if (ReferenceExtractor.FindMatchingChar(line, open, '(', ')') < 0) - continue; - - EmitGoTypeExpression(line[typeStart..typeEnd], typeStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static int NextGoCompositeConversionTypeStart(string line, int searchStart) - { - for (var cursor = searchStart; cursor < line.Length; cursor++) - { - if (line[cursor] == '[') - return cursor; - if (IsIdentifierAt(line, cursor, "map") || IsIdentifierAt(line, cursor, "chan")) - return cursor; - } - - return -1; - } - - private static int FindGoConversionTypeExpressionEnd(string line, int start) - { - var squareDepth = 0; - for (var cursor = start; cursor < line.Length; cursor++) - { - switch (line[cursor]) - { - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) - squareDepth--; - break; - case '(': - case ',': - case '{': - case '`': - case '=': - case ';': - if (squareDepth == 0) - return cursor; - break; - } - } - - return line.Length; - } - - private static bool IsGoTypeExpressionValueContext(string line, int start) - { - var previous = start - 1; - while (previous >= 0 && char.IsWhiteSpace(line[previous])) - previous--; - if (previous < 0) - return true; - if (line[previous] is '=' or ':' or '(' or '[' or '{' or ',' or '!' or '&' or '*') - return true; - - var tokenEnd = previous + 1; - while (previous >= 0 && IsSimpleIdentifierPart(line[previous])) - previous--; - var token = line[(previous + 1)..tokenEnd]; - return string.Equals(token, "return", StringComparison.Ordinal); - } - - private static bool IsLikelyGoParenthesizedConversionType(string expression) - { - if (expression.Length == 0 || expression.Contains(',')) - return false; - - var cursor = 0; - while (cursor < expression.Length && char.IsWhiteSpace(expression[cursor])) - cursor++; - var isPointerConversion = cursor < expression.Length && expression[cursor] == '*'; - while (cursor < expression.Length && expression[cursor] == '*') - cursor = SkipWhitespace(expression, cursor + 1); - - if (cursor >= expression.Length || !IsIdentifierStart(expression[cursor])) - { - return cursor < expression.Length && expression[cursor] == '['; - } - - if (StartsWithKeyword(expression, cursor, "map") - || StartsWithKeyword(expression, cursor, "chan")) - { - return true; - } - - var lastSegmentStart = cursor; - while (cursor < expression.Length) - { - if (IsSimpleIdentifierPart(expression[cursor])) - { - cursor++; - continue; - } - - if (expression[cursor] != '.') - return false; - - cursor++; - if (cursor >= expression.Length || !IsIdentifierStart(expression[cursor])) - return false; - lastSegmentStart = cursor; - cursor++; - } - - return isPointerConversion || expression.Contains('.'); - } - - private static void EmitGoMethodExpressionReceiverTypeReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - EmitGoParenthesizedMethodExpressionReceiverTypes(line, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitGoBareMethodExpressionReceiverTypes(line, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitGoGenericMethodExpressionReceiverTypes(line, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static void EmitGoParenthesizedMethodExpressionReceiverTypes( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var searchStart = 0; - while (searchStart < line.Length) - { - var open = line.IndexOf('(', searchStart); - if (open < 0) - return; - - searchStart = open + 1; - var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); - if (close <= open + 1) - continue; - - var dot = SkipWhitespace(line, close + 1); - if (dot >= line.Length || line[dot] != '.') - continue; - - var methodStart = dot + 1; - if (methodStart >= line.Length || !IsIdentifierStart(line[methodStart])) - continue; - - var rawExpression = line[(open + 1)..close]; - var expression = rawExpression.Trim(); - if (!IsLikelyGoMethodExpressionReceiverType(expression)) - continue; - - var trimStart = rawExpression.IndexOf(expression, StringComparison.Ordinal); - EmitGoTypeExpression(expression, open + 1 + Math.Max(0, trimStart), references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static void EmitGoBareMethodExpressionReceiverTypes( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - for (var dot = 1; dot < line.Length - 1; dot++) - { - if (line[dot] != '.') - continue; - if (!IsSimpleIdentifierPart(line[dot - 1]) || !IsIdentifierStart(line[dot + 1])) - continue; - - var receiverStart = dot - 1; - while (receiverStart >= 0 && IsSimpleIdentifierPart(line[receiverStart])) - receiverStart--; - receiverStart++; - - var receiverName = line[receiverStart..dot]; - if (receiverName.Length == 0 || !char.IsUpper(receiverName[0])) - continue; - - ReferenceExtractor.AddReference(references, seen, fileId, receiverName, receiverStart, "type_reference", context, lineNumber, resolveContainerForColumn(receiverStart)); - } - } - - private static bool IsLikelyGoMethodExpressionReceiverType(string expression) - { - if (expression.Length == 0 || expression.Contains(',')) - return false; - - var cursor = 0; - while (cursor < expression.Length && char.IsWhiteSpace(expression[cursor])) - cursor++; - if (cursor < expression.Length && expression[cursor] == '*') - cursor = SkipWhitespace(expression, cursor + 1); - - if (cursor >= expression.Length || !IsIdentifierStart(expression[cursor])) - return false; - - if (IsLikelyGoGenericReceiverTypeExpression(expression, cursor)) - return true; - - var lastSegmentStart = cursor; - while (cursor < expression.Length) - { - if (IsSimpleIdentifierPart(expression[cursor])) - { - cursor++; - continue; - } - - if (expression[cursor] != '.') - return false; - - cursor++; - if (cursor >= expression.Length || !IsIdentifierStart(expression[cursor])) - return false; - lastSegmentStart = cursor; - cursor++; - } - - return expression.Contains('.') || char.IsUpper(expression[lastSegmentStart]); - } - - private static void EmitGoGenericMethodExpressionReceiverTypes( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var searchStart = 0; - while (searchStart < line.Length) - { - var open = line.IndexOf('[', searchStart); - if (open < 0) - return; - - searchStart = open + 1; - if (!TryGetGoIdentifierBeforeBracket(line, open, out var nameStart, out var nameLength)) - continue; - if (nameLength == 0 || !char.IsUpper(line[nameStart])) - continue; - - var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); - if (close < 0) - continue; - - var dot = SkipWhitespace(line, close + 1); - if (dot >= line.Length || line[dot] != '.') - continue; - - var methodStart = dot + 1; - if (methodStart >= line.Length || !IsIdentifierStart(line[methodStart])) - continue; - - EmitGoTypeExpression(line[nameStart..(close + 1)], nameStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static bool IsLikelyGoGenericReceiverTypeExpression(string expression, int receiverStart) - { - var open = expression.IndexOf('[', receiverStart); - if (open < 0 || !ContainsLikelyGoTypeArgument(expression[open..])) - return false; - - var firstSegmentStart = receiverStart; - var firstSegmentEnd = firstSegmentStart; - while (firstSegmentEnd < expression.Length && IsSimpleIdentifierPart(expression[firstSegmentEnd])) - firstSegmentEnd++; - - if (firstSegmentEnd <= firstSegmentStart) - return false; - if (char.IsUpper(expression[firstSegmentStart])) - return true; - - var afterFirst = SkipWhitespace(expression, firstSegmentEnd); - return afterFirst < expression.Length && expression[afterFirst] == '.'; - } - - private static void EmitGoGenericInstantiationTypeArgumentReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (line.IndexOf("func", StringComparison.Ordinal) >= 0 - && GoFuncRegex.IsMatch(line)) - { - return; - } - - var searchStart = 0; - while (searchStart < line.Length) - { - var open = line.IndexOf('[', searchStart); - if (open < 0) - return; - - searchStart = open + 1; - if (!TryGetGoIdentifierBeforeBracket(line, open, out var nameStart, out var nameLength)) - continue; - if (nameLength == 0 || !char.IsUpper(line[nameStart])) - continue; - - var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); - if (close < 0) - continue; - - var afterClose = SkipWhitespace(line, close + 1); - if (afterClose < line.Length && line[afterClose] is '(' or '{') - continue; - if (afterClose < line.Length && !IsGoGenericInstantiationTerminator(line[afterClose])) - continue; - - EmitGoGenericTypeArgumentList(line, open, close, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static bool IsGoGenericInstantiationTerminator(char ch) - => char.IsWhiteSpace(ch) || ch is ',' or ')' or ']' or '}' or ';'; - - private static void EmitGoGenericTypeArgumentList( - string line, - int open, - int close, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var typeArguments = line.AsSpan(open + 1, close - open - 1); - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeArguments)) - { - var expressionSpan = TrimGoCommaSegment(typeArguments.Slice(segmentStart, segmentLength), out var trimStart); - var expression = expressionSpan.ToString(); - if (expression.Length == 0 || !ContainsLikelyGoTypeArgument(expression)) - continue; - - var absoluteStart = open + 1 + segmentStart + Math.Max(0, trimStart); - EmitGoTypeExpression(expression, absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static bool HasGoIdentifierBeforeBracket(string line, int openBracket) - => TryGetGoIdentifierBeforeBracket(line, openBracket, out _, out _); - - private static bool TryGetGoIdentifierBeforeBracket(string line, int openBracket, out int start, out int length) - { - start = -1; - length = 0; - var cursor = openBracket - 1; - while (cursor >= 0 && char.IsWhiteSpace(line[cursor])) - cursor--; - if (cursor < 0 || !IsSimpleIdentifierPart(line[cursor])) - return false; - - var end = cursor + 1; - while (cursor >= 0 && IsSimpleIdentifierPart(line[cursor])) - cursor--; - - start = cursor + 1; - length = end - start; - return true; - } - - private static bool ContainsLikelyGoTypeArgument(string expression) - { - for (var i = 0; i < expression.Length; i++) - { - if (!IsIdentifierStart(expression[i])) - continue; - - var start = i; - i++; - while (i < expression.Length && IsSimpleIdentifierPart(expression[i])) - i++; - - if (char.IsUpper(expression[start])) - return true; - } - - return false; - } - - private static bool ContainsGoUppercaseAscii(string line) - { - foreach (var ch in line) - { - if (ch is >= 'A' and <= 'Z') - return true; - } - - return false; - } - - private static bool IsGoTypeDeclarationBodyStart(string line, int index) - { - if (StartsWithKeyword(line, index, "struct") - || StartsWithKeyword(line, index, "interface") - || StartsWithKeyword(line, index, "func") - || StartsWithKeyword(line, index, "map") - || StartsWithKeyword(line, index, "chan")) - { - return true; - } - - return line[index] is '*' or '[' or '~' || IsIdentifierStart(line[index]); - } - - private static bool IsGoCompositeLiteralContext(string line, int nameIndex, int nameLength) - { - var openBraceIndex = line.IndexOf('{', nameIndex + nameLength); - if (openBraceIndex < 0) - return false; - - var trimmed = line.TrimStart(); - var firstBraceIndex = line.IndexOf('{'); - if (trimmed.StartsWith("func ", StringComparison.Ordinal) && firstBraceIndex == openBraceIndex) - return false; - - var previous = nameIndex - 1; - while (previous >= 0 && char.IsWhiteSpace(line[previous])) - previous--; - if (previous < 0) - return false; - - if (line[previous] is '=' or ':' or '(' or '[' or '{' or ',' or '!' or '&' or '*') - return true; - if (line[previous] == '.') - return previous > 0 && IsSimpleIdentifierPart(line[previous - 1]); - if (line[previous] == ']') - return !trimmed.StartsWith("func ", StringComparison.Ordinal); - - var tokenEnd = previous + 1; - while (previous >= 0 && IsSimpleIdentifierPart(line[previous])) - previous--; - var token = line[(previous + 1)..tokenEnd]; - return string.Equals(token, "return", StringComparison.Ordinal); - } - - internal static void EmitGoBranchLabelReferences(string preparedLine, Action addCallLikeReference) - { - foreach (Match match in GoBranchLabelRegex.Matches(preparedLine)) - addCallLikeReference(match.Groups["name"].Value, match.Groups["name"].Index); - } - - private static void EmitGoFunctionSignatureTypes( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var firstParen = preparedLine.IndexOf('('); - if (firstParen < 0) - return; - - var parameterOpen = firstParen; - var functionHeaderStart = GoFuncRegex.Match(preparedLine).Length; - var receiverClose = ReferenceExtractor.FindMatchingChar(preparedLine, firstParen, '(', ')'); - if (receiverClose >= 0) - { - var afterReceiver = receiverClose + 1; - while (afterReceiver < preparedLine.Length && char.IsWhiteSpace(preparedLine[afterReceiver])) - afterReceiver++; - if (afterReceiver < preparedLine.Length && IsIdentifierStart(preparedLine[afterReceiver])) - { - var nextParen = preparedLine.IndexOf('(', afterReceiver); - if (nextParen > afterReceiver) - { - EmitGoParameterListTypes(preparedLine, firstParen + 1, receiverClose, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - functionHeaderStart = afterReceiver; - - var afterName = afterReceiver + 1; - while (afterName < preparedLine.Length && IsSimpleIdentifierPart(preparedLine[afterName])) - afterName++; - while (afterName < preparedLine.Length && char.IsWhiteSpace(preparedLine[afterName])) - afterName++; - - if (afterName < preparedLine.Length && preparedLine[afterName] == '[') - { - var typeParameterClose = ReferenceExtractor.FindMatchingChar(preparedLine, afterName, '[', ']'); - if (typeParameterClose > afterName) - { - EmitGoTypeParameterConstraints(preparedLine, afterName, typeParameterClose + 1, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - var valueParameterOpen = preparedLine.IndexOf('(', typeParameterClose + 1); - if (valueParameterOpen < 0) - return; - - nextParen = valueParameterOpen; - } - } - - parameterOpen = nextParen; - } - } - } - - var parameterClose = ReferenceExtractor.FindMatchingChar(preparedLine, parameterOpen, '(', ')'); - if (parameterClose < 0) - return; - - EmitGoTypeParameterConstraints(preparedLine, functionHeaderStart, parameterOpen, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitGoParameterListTypes(preparedLine, parameterOpen + 1, parameterClose, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - - var returnStart = parameterClose + 1; - while (returnStart < preparedLine.Length && char.IsWhiteSpace(preparedLine[returnStart])) - returnStart++; - if (returnStart >= preparedLine.Length || preparedLine[returnStart] == '{') - return; - - if (preparedLine[returnStart] == '(') - { - var returnClose = ReferenceExtractor.FindMatchingChar(preparedLine, returnStart, '(', ')'); - if (returnClose > returnStart) - EmitGoParameterListTypes(preparedLine, returnStart + 1, returnClose, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - return; - } - - var returnEnd = returnStart; - while (returnEnd < preparedLine.Length && preparedLine[returnEnd] != '{') - returnEnd++; - - EmitGoTypeExpression(preparedLine, returnStart, returnEnd, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static void EmitGoInterfaceMethodSignatureTypes( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var nameStart = SkipWhitespace(preparedLine, 0); - if (nameStart >= preparedLine.Length || !IsIdentifierStart(preparedLine[nameStart])) - return; - - var nameEnd = nameStart + 1; - while (nameEnd < preparedLine.Length && IsSimpleIdentifierPart(preparedLine[nameEnd])) - nameEnd++; - - if (IsGoStatementKeyword(preparedLine[nameStart..nameEnd])) - return; - - var open = SkipWhitespace(preparedLine, nameEnd); - if (open >= preparedLine.Length || preparedLine[open] != '(') - return; - - var close = ReferenceExtractor.FindMatchingChar(preparedLine, open, '(', ')'); - if (close < 0) - return; - - var returnStart = SkipWhitespace(preparedLine, close + 1); - if (!IsGoSignatureReturnStart(preparedLine, returnStart)) - return; - - EmitGoParameterListTypes(preparedLine, open + 1, close, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitGoSignatureReturnTypes(preparedLine, close + 1, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static bool IsGoStatementKeyword(string value) - => value is "break" or "case" or "const" or "continue" or "default" or "defer" - or "else" or "fallthrough" or "for" or "func" or "go" or "goto" or "if" - or "import" or "package" or "range" or "return" or "select" or "switch" - or "type" or "var"; - - private static bool IsGoSignatureReturnStart(string line, int index) - { - if (index >= line.Length) - return false; - - return line[index] == '(' || IsGoTypeExpressionStart(line, index); - } - - private static bool IsGoTypeExpressionStart(string line, int index) - { - if (index >= line.Length) - return false; - - return line[index] is '*' or '[' or '~' or '<' || IsIdentifierStart(line[index]); - } - - private static int FindGoInlineTypeExpressionEnd(string line, int start) - { - var squareDepth = 0; - var parenDepth = 0; - for (var cursor = start; cursor < line.Length; cursor++) - { - switch (line[cursor]) - { - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) - squareDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth == 0) - return cursor; - parenDepth--; - break; - case ',': - case '{': - case '`': - case '=': - case ';': - if (squareDepth == 0 && parenDepth == 0) - return cursor; - break; - } - } - - return line.Length; - } - - private static void EmitGoSignatureReturnTypes( - string preparedLine, - int start, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var returnStart = SkipWhitespace(preparedLine, start); - if (returnStart >= preparedLine.Length || preparedLine[returnStart] == '{') - return; - - if (preparedLine[returnStart] == '(') - { - var returnClose = ReferenceExtractor.FindMatchingChar(preparedLine, returnStart, '(', ')'); - if (returnClose > returnStart) - EmitGoParameterListTypes(preparedLine, returnStart + 1, returnClose, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - return; - } - - var returnEnd = returnStart; - while (returnEnd < preparedLine.Length && preparedLine[returnEnd] != '{') - returnEnd++; - - EmitGoTypeExpression(preparedLine, returnStart, returnEnd, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static void EmitGoTypeParameterConstraints( - string line, - int searchStart, - int searchEnd, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (searchStart < 0 || searchStart >= searchEnd || searchEnd > line.Length) - return; - - var open = line.IndexOf('[', searchStart, searchEnd - searchStart); - if (open < 0) - return; - - var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); - if (close < 0 || close > searchEnd) - return; - - var list = line.AsSpan(open + 1, close - open - 1); - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(list)) - { - var fragmentSpan = TrimGoCommaSegment(list.Slice(segmentStart, segmentLength), out var fragmentTrimStart); - var fragment = fragmentSpan.ToString(); - if (fragment.Length == 0) - continue; - - var constraintStart = FirstGoTypeParameterConstraintStart(fragment); - if (constraintStart < 0) - continue; - - var absoluteStart = open + 1 + segmentStart + Math.Max(0, fragmentTrimStart) + constraintStart; - EmitGoTypeExpression(fragment[constraintStart..], absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - } - - private static int FirstGoTypeParameterConstraintStart(string fragment) - { - var cursor = 0; - while (cursor < fragment.Length && char.IsWhiteSpace(fragment[cursor])) - cursor++; - if (cursor >= fragment.Length || !IsIdentifierStart(fragment[cursor])) - return -1; - - cursor++; - while (cursor < fragment.Length && IsSimpleIdentifierPart(fragment[cursor])) - cursor++; - - var constraintStart = cursor; - while (constraintStart < fragment.Length && char.IsWhiteSpace(fragment[constraintStart])) - constraintStart++; - - return constraintStart < fragment.Length ? constraintStart : -1; - } - - private static void EmitGoParameterListTypes( - string line, - int start, - int end, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (end <= start) - return; - - var list = line.AsSpan(start, end - start); - List<(string Expression, int AbsoluteStart)>? pendingSingleExpressions = null; - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(list)) - { - var fragmentSpan = TrimGoCommaSegment(list.Slice(segmentStart, segmentLength), out var fragmentTrimStart); - var fragment = fragmentSpan.ToString(); - if (fragment.Length == 0) - continue; - - var absoluteFragmentStart = start + segmentStart + Math.Max(0, fragmentTrimStart); - var typeStartInFragment = LastWhitespaceSeparatedTokenStart(fragment); - if (typeStartInFragment < 0) - continue; - if (typeStartInFragment == 0) - { - (pendingSingleExpressions ??= []).Add((fragment, absoluteFragmentStart)); - continue; - } - - pendingSingleExpressions?.Clear(); - var expression = fragment[typeStartInFragment..]; - var absoluteStart = absoluteFragmentStart + typeStartInFragment; - EmitGoTypeExpression(expression, absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - if (pendingSingleExpressions is null) - return; - - foreach (var (expression, absoluteStart) in pendingSingleExpressions) - EmitGoTypeExpression(expression, absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static ReadOnlySpan TrimGoCommaSegment(ReadOnlySpan segment, out int leading) - { - leading = 0; - while (leading < segment.Length && char.IsWhiteSpace(segment[leading])) - leading++; - - var length = segment.Length - leading; - while (length > 0 && char.IsWhiteSpace(segment[leading + length - 1])) - length--; - - return segment.Slice(leading, length); - } - - private static void EmitGoTypeExpression( - string expression, - int start, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - EmitGoTypeExpressionRange( - expression, - 0, - expression.Length, - start, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - - private static void EmitGoTypeExpression( - string source, - int start, - int endExclusive, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - EmitGoTypeExpressionRange( - source, - start, - endExclusive, - 0, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - - private static void EmitGoTypeExpressionRange( - string source, - int start, - int endExclusive, - int absoluteOffset, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - start = Math.Clamp(start, 0, source.Length); - endExclusive = Math.Clamp(endExclusive, start, source.Length); - - while (start < endExclusive && char.IsWhiteSpace(source[start])) - start++; - - while (endExclusive > start && char.IsWhiteSpace(source[endExclusive - 1])) - endExclusive--; - - if (endExclusive <= start) - return; - - var normalized = start == 0 && endExclusive == source.Length - ? source - : source[start..endExclusive]; - var absoluteStart = absoluteOffset + start; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, normalized, absoluteStart, context, lineNumber, resolveContainerForColumn(absoluteStart), "go"); - } } diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.GoCompositeTypes.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.GoCompositeTypes.cs new file mode 100644 index 000000000..838a818b5 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.GoCompositeTypes.cs @@ -0,0 +1,700 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class LanguageReferenceExtractionSupport +{ + private static void EmitGoBuiltinTypeArgumentReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + foreach (Match match in GoBuiltinTypeArgumentRegex.Matches(line)) + { + var open = line.IndexOf('(', match.Index); + if (open < 0) + continue; + + var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); + if (close < 0) + continue; + + var argumentList = line[(open + 1)..close]; + var firstArgument = ReferenceExtractor.GetFirstTopLevelCommaSpan(argumentList); + if (firstArgument.Length <= 0) + continue; + + var rawType = argumentList.Substring(firstArgument.Start, firstArgument.Length); + var expression = rawType.Trim(); + if (expression.Length == 0) + continue; + + var trimStart = rawType.IndexOf(expression, StringComparison.Ordinal); + var absoluteStart = open + 1 + firstArgument.Start + Math.Max(0, trimStart); + EmitGoTypeExpression(expression, absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static void EmitGoTypeAssertionReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + foreach (Match match in GoTypeAssertionRegex.Matches(line)) + { + var group = match.Groups["type"]; + var expression = group.Value.Trim(); + if (expression.Length == 0 || string.Equals(expression, "type", StringComparison.Ordinal)) + continue; + + var trimStart = group.Value.IndexOf(expression, StringComparison.Ordinal); + EmitGoTypeExpression(expression, group.Index + Math.Max(0, trimStart), references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static void EmitGoTypeSwitchCaseReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var match = GoTypeSwitchCaseRegex.Match(line); + if (!match.Success) + return; + + var group = match.Groups["types"]; + var typeList = group.ValueSpan; + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeList)) + { + var expressionSpan = TrimGoCommaSegment(typeList.Slice(segmentStart, segmentLength), out var trimStart); + var expression = expressionSpan.ToString(); + if (expression.Length == 0 + || expression is "nil" or "default" + || !IsLikelyGoTypeSwitchCaseType(expression)) + { + continue; + } + + EmitGoTypeExpression(expression, group.Index + segmentStart + Math.Max(0, trimStart), references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static bool IsLikelyGoTypeSwitchCaseType(string expression) + { + var cursor = 0; + var hasPointerPrefix = false; + while (cursor < expression.Length) + { + cursor = SkipWhitespace(expression, cursor); + if (cursor >= expression.Length || expression[cursor] != '*') + break; + + hasPointerPrefix = true; + cursor++; + } + + if (cursor >= expression.Length) + return false; + if (expression[cursor] == '[') + return true; + if (hasPointerPrefix && char.IsUpper(expression[cursor])) + return true; + if (hasPointerPrefix && expression.IndexOf('.', cursor) >= 0) + return true; + + return StartsWithKeyword(expression, cursor, "map") + || StartsWithKeyword(expression, cursor, "chan") + || StartsWithKeyword(expression, cursor, "func") + || StartsWithKeyword(expression, cursor, "interface") + || StartsWithKeyword(expression, cursor, "struct"); + } + + private static void EmitGoChannelElementTypeReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var searchStart = 0; + while (searchStart < line.Length) + { + var chanIndex = line.IndexOf("chan", searchStart, StringComparison.Ordinal); + if (chanIndex < 0) + return; + + searchStart = chanIndex + "chan".Length; + if (!IsIdentifierAt(line, chanIndex, "chan")) + continue; + + var elementStart = SkipWhitespace(line, searchStart); + if (elementStart + 1 < line.Length && line[elementStart] == '<' && line[elementStart + 1] == '-') + elementStart = SkipWhitespace(line, elementStart + 2); + + if (elementStart >= line.Length || !IsGoTypeExpressionStart(line, elementStart)) + continue; + + var elementEnd = FindGoInlineTypeExpressionEnd(line, elementStart); + if (elementEnd <= elementStart) + continue; + + EmitGoTypeExpression(line[elementStart..elementEnd], elementStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static void EmitGoFunctionLiteralSignatureTypes( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + foreach (Match match in GoFunctionLiteralRegex.Matches(line)) + { + var open = line.IndexOf('(', match.Index); + if (open < 0) + continue; + + var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); + if (close < 0) + continue; + + EmitGoParameterListTypes(line, open + 1, close, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitGoSignatureReturnTypes(line, close + 1, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static void EmitGoGenericCallTypeArgumentReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (line.IndexOf("func", StringComparison.Ordinal) >= 0 + && GoFuncRegex.IsMatch(line)) + { + return; + } + + var searchStart = 0; + while (searchStart < line.Length) + { + var open = line.IndexOf('[', searchStart); + if (open < 0) + return; + + searchStart = open + 1; + if (!HasGoIdentifierBeforeBracket(line, open)) + continue; + + var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); + if (close < 0) + continue; + + var afterClose = SkipWhitespace(line, close + 1); + if (afterClose >= line.Length || line[afterClose] != '(') + continue; + + var typeArguments = line.AsSpan(open + 1, close - open - 1); + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeArguments)) + { + var expressionSpan = TrimGoCommaSegment(typeArguments.Slice(segmentStart, segmentLength), out var trimStart); + var expression = expressionSpan.ToString(); + if (expression.Length == 0 || !ContainsLikelyGoTypeArgument(expression)) + continue; + + var absoluteStart = open + 1 + segmentStart + Math.Max(0, trimStart); + EmitGoTypeExpression(expression, absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + } + + private static void EmitGoFunctionTypeSignatureTypes( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var searchStart = 0; + while (searchStart < line.Length) + { + var funcIndex = line.IndexOf("func", searchStart, StringComparison.Ordinal); + if (funcIndex < 0) + return; + + searchStart = funcIndex + "func".Length; + if (!IsIdentifierAt(line, funcIndex, "func")) + continue; + + var open = SkipWhitespace(line, searchStart); + if (open >= line.Length || line[open] != '(') + continue; + + var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); + if (close < 0) + continue; + + EmitGoParameterListTypes(line, open + 1, close, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitGoSignatureReturnTypes(line, close + 1, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static void EmitGoGenericCompositeLiteralReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var searchStart = 0; + while (searchStart < line.Length) + { + var open = line.IndexOf('[', searchStart); + if (open < 0) + return; + + searchStart = open + 1; + if (!TryGetGoIdentifierBeforeBracket(line, open, out var nameStart, out var nameLength)) + continue; + + var typeName = line.Substring(nameStart, nameLength); + if (typeName.Length == 0 || !char.IsUpper(typeName[0])) + continue; + + var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); + if (close < 0) + continue; + + var afterClose = SkipWhitespace(line, close + 1); + if (afterClose >= line.Length || line[afterClose] != '{') + continue; + + if (!IsGoCompositeLiteralContext(line, nameStart, nameLength)) + continue; + + ReferenceExtractor.AddReference(references, seen, fileId, typeName, nameStart, "instantiate", context, lineNumber, resolveContainerForColumn(nameStart)); + + var typeArguments = line.AsSpan(open + 1, close - open - 1); + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeArguments)) + { + var expressionSpan = TrimGoCommaSegment(typeArguments.Slice(segmentStart, segmentLength), out var trimStart); + var expression = expressionSpan.ToString(); + if (expression.Length == 0 || !ContainsLikelyGoTypeArgument(expression)) + continue; + + var absoluteStart = open + 1 + segmentStart + Math.Max(0, trimStart); + EmitGoTypeExpression(expression, absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + } + + private static void EmitGoInlineStructFieldTypeReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var searchStart = 0; + while (searchStart < line.Length) + { + var structIndex = line.IndexOf("struct", searchStart, StringComparison.Ordinal); + if (structIndex < 0) + return; + + searchStart = structIndex + "struct".Length; + if (!IsIdentifierAt(line, structIndex, "struct")) + continue; + + var open = SkipWhitespace(line, searchStart); + if (open >= line.Length || line[open] != '{') + continue; + + var close = ReferenceExtractor.FindMatchingChar(line, open, '{', '}'); + if (close <= open + 1) + continue; + + var body = line[(open + 1)..close]; + var bodyStart = open + 1; + foreach (var (fieldStart, fieldLength) in SplitGoInlineStructFieldSpans(body)) + EmitGoInlineStructFieldType(body.Substring(fieldStart, fieldLength), bodyStart + fieldStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static List<(int Start, int Length)> SplitGoInlineStructFieldSpans(string body) + { + var spans = new List<(int Start, int Length)>(4); + var fieldStart = 0; + var squareDepth = 0; + var parenDepth = 0; + for (var cursor = 0; cursor < body.Length; cursor++) + { + switch (body[cursor]) + { + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) + squareDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) + parenDepth--; + break; + case ';': + if (squareDepth == 0 && parenDepth == 0) + { + spans.Add((fieldStart, cursor - fieldStart)); + fieldStart = cursor + 1; + } + break; + } + } + + spans.Add((fieldStart, body.Length - fieldStart)); + return spans; + } + + private static void EmitGoInlineStructFieldType( + string rawField, + int rawFieldStart, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var tagStart = rawField.IndexOf('`'); + if (tagStart >= 0) + rawField = rawField[..tagStart]; + + var field = rawField.Trim(); + if (field.Length == 0) + return; + + var fieldTrimStart = rawField.IndexOf(field, StringComparison.Ordinal); + var absoluteFieldStart = rawFieldStart + Math.Max(0, fieldTrimStart); + var typeStart = LastWhitespaceSeparatedTokenStart(field); + if (typeStart < 0) + return; + + var expression = typeStart == 0 ? field : field[typeStart..]; + EmitGoTypeExpression(expression, absoluteFieldStart + typeStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static void EmitGoInlineInterfaceMemberTypeReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var searchStart = 0; + while (searchStart < line.Length) + { + var interfaceIndex = line.IndexOf("interface", searchStart, StringComparison.Ordinal); + if (interfaceIndex < 0) + return; + + searchStart = interfaceIndex + "interface".Length; + if (!IsIdentifierAt(line, interfaceIndex, "interface")) + continue; + + var open = SkipWhitespace(line, searchStart); + if (open >= line.Length || line[open] != '{') + continue; + + var close = ReferenceExtractor.FindMatchingChar(line, open, '{', '}'); + if (close <= open + 1) + continue; + + var body = line[(open + 1)..close]; + var bodyStart = open + 1; + foreach (var (memberStart, memberLength) in SplitGoInlineStructFieldSpans(body)) + EmitGoInlineInterfaceMemberTypes(line, bodyStart + memberStart, bodyStart + memberStart + memberLength, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static void EmitGoInlineInterfaceMemberTypes( + string line, + int memberStart, + int memberEnd, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var cursor = SkipWhitespace(line, memberStart); + if (cursor >= memberEnd) + return; + + if (!IsIdentifierStart(line[cursor])) + { + EmitGoInlineInterfaceEmbeddedType(line, cursor, memberEnd, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + return; + } + + var nameStart = cursor; + cursor++; + while (cursor < memberEnd && IsSimpleIdentifierPart(line[cursor])) + cursor++; + + var name = line[nameStart..cursor]; + if (IsGoStatementKeyword(name)) + return; + + var open = SkipWhitespace(line, cursor); + if (open < memberEnd && line[open] == '(') + { + var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); + if (close > open && close <= memberEnd) + { + EmitGoParameterListTypes(line, open + 1, close, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitGoSignatureReturnTypesInRange(line, close + 1, memberEnd, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + return; + } + + EmitGoInlineInterfaceEmbeddedType(line, nameStart, memberEnd, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static void EmitGoInlineInterfaceEmbeddedType( + string line, + int start, + int end, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var typeStart = SkipWhitespace(line, start); + if (typeStart >= end) + return; + + var expression = line[typeStart..end].Trim(); + if (expression.Length == 0) + return; + + EmitGoTypeExpression(expression, typeStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static void EmitGoSignatureReturnTypesInRange( + string line, + int start, + int end, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var returnStart = SkipWhitespace(line, start); + if (returnStart >= end || line[returnStart] == '{') + return; + + if (line[returnStart] == '(') + { + var returnClose = ReferenceExtractor.FindMatchingChar(line, returnStart, '(', ')'); + if (returnClose > returnStart && returnClose <= end) + EmitGoParameterListTypes(line, returnStart + 1, returnClose, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + return; + } + + EmitGoTypeExpression(line, returnStart, end, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static void EmitGoMapCompositeLiteralTypeReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var searchStart = 0; + while (searchStart < line.Length) + { + var mapIndex = line.IndexOf("map", searchStart, StringComparison.Ordinal); + if (mapIndex < 0) + return; + + searchStart = mapIndex + "map".Length; + if (!IsIdentifierAt(line, mapIndex, "map")) + continue; + + var open = SkipWhitespace(line, searchStart); + if (open >= line.Length || line[open] != '[') + continue; + + var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); + if (close < 0) + continue; + + var valueStart = SkipWhitespace(line, close + 1); + if (valueStart >= line.Length || !IsGoTypeExpressionStart(line, valueStart)) + continue; + + var valueEnd = FindGoInlineTypeExpressionEnd(line, valueStart); + var literalOpen = SkipWhitespace(line, valueEnd); + if (literalOpen >= line.Length || line[literalOpen] != '{') + continue; + + var keyExpression = line[(open + 1)..close].Trim(); + if (keyExpression.Length > 0) + { + var keyStart = line.IndexOf(keyExpression, open + 1, StringComparison.Ordinal); + EmitGoTypeExpression(keyExpression, keyStart >= 0 ? keyStart : open + 1, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + EmitGoTypeExpression(line[valueStart..valueEnd], valueStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static void EmitGoArraySliceCompositeLiteralTypeReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var searchStart = 0; + while (searchStart < line.Length) + { + var open = line.IndexOf('[', searchStart); + if (open < 0) + return; + + searchStart = open + 1; + var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); + if (close < 0) + continue; + + var elementStart = SkipWhitespace(line, close + 1); + if (elementStart >= line.Length || !IsGoTypeExpressionStart(line, elementStart)) + continue; + + var elementEnd = FindGoInlineTypeExpressionEnd(line, elementStart); + var literalOpen = SkipWhitespace(line, elementEnd); + if (literalOpen >= line.Length || line[literalOpen] != '{') + continue; + + if (!IsGoCompositeLiteralContext(line, open, elementEnd - open)) + continue; + + EmitGoTypeExpression(line[elementStart..elementEnd], elementStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static void EmitGoParenthesizedTypeConversionReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var searchStart = 0; + while (searchStart < line.Length) + { + var open = line.IndexOf('(', searchStart); + if (open < 0) + return; + + searchStart = open + 1; + var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); + if (close <= open + 1) + continue; + + var afterClose = SkipWhitespace(line, close + 1); + if (afterClose >= line.Length || line[afterClose] != '(') + continue; + + var rawExpression = line[(open + 1)..close]; + var expression = rawExpression.Trim(); + if (!IsLikelyGoParenthesizedConversionType(expression)) + continue; + + var trimStart = rawExpression.IndexOf(expression, StringComparison.Ordinal); + EmitGoTypeExpression(expression, open + 1 + Math.Max(0, trimStart), references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static void EmitGoCompositeTypeConversionReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var searchStart = 0; + while (searchStart < line.Length) + { + var typeStart = NextGoCompositeConversionTypeStart(line, searchStart); + if (typeStart < 0) + return; + + searchStart = typeStart + 1; + if (!IsGoTypeExpressionValueContext(line, typeStart)) + continue; + + var typeEnd = FindGoConversionTypeExpressionEnd(line, typeStart); + if (typeEnd <= typeStart) + continue; + + var open = SkipWhitespace(line, typeEnd); + if (open >= line.Length || line[open] != '(') + continue; + if (ReferenceExtractor.FindMatchingChar(line, open, '(', ')') < 0) + continue; + + EmitGoTypeExpression(line[typeStart..typeEnd], typeStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + +} diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.GoMethodExpressions.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.GoMethodExpressions.cs new file mode 100644 index 000000000..f23c64af2 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.GoMethodExpressions.cs @@ -0,0 +1,458 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class LanguageReferenceExtractionSupport +{ + private static int NextGoCompositeConversionTypeStart(string line, int searchStart) + { + for (var cursor = searchStart; cursor < line.Length; cursor++) + { + if (line[cursor] == '[') + return cursor; + if (IsIdentifierAt(line, cursor, "map") || IsIdentifierAt(line, cursor, "chan")) + return cursor; + } + + return -1; + } + + private static int FindGoConversionTypeExpressionEnd(string line, int start) + { + var squareDepth = 0; + for (var cursor = start; cursor < line.Length; cursor++) + { + switch (line[cursor]) + { + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) + squareDepth--; + break; + case '(': + case ',': + case '{': + case '`': + case '=': + case ';': + if (squareDepth == 0) + return cursor; + break; + } + } + + return line.Length; + } + + private static bool IsGoTypeExpressionValueContext(string line, int start) + { + var previous = start - 1; + while (previous >= 0 && char.IsWhiteSpace(line[previous])) + previous--; + if (previous < 0) + return true; + if (line[previous] is '=' or ':' or '(' or '[' or '{' or ',' or '!' or '&' or '*') + return true; + + var tokenEnd = previous + 1; + while (previous >= 0 && IsSimpleIdentifierPart(line[previous])) + previous--; + var token = line[(previous + 1)..tokenEnd]; + return string.Equals(token, "return", StringComparison.Ordinal); + } + + private static bool IsLikelyGoParenthesizedConversionType(string expression) + { + if (expression.Length == 0 || expression.Contains(',')) + return false; + + var cursor = 0; + while (cursor < expression.Length && char.IsWhiteSpace(expression[cursor])) + cursor++; + var isPointerConversion = cursor < expression.Length && expression[cursor] == '*'; + while (cursor < expression.Length && expression[cursor] == '*') + cursor = SkipWhitespace(expression, cursor + 1); + + if (cursor >= expression.Length || !IsIdentifierStart(expression[cursor])) + { + return cursor < expression.Length && expression[cursor] == '['; + } + + if (StartsWithKeyword(expression, cursor, "map") + || StartsWithKeyword(expression, cursor, "chan")) + { + return true; + } + + var lastSegmentStart = cursor; + while (cursor < expression.Length) + { + if (IsSimpleIdentifierPart(expression[cursor])) + { + cursor++; + continue; + } + + if (expression[cursor] != '.') + return false; + + cursor++; + if (cursor >= expression.Length || !IsIdentifierStart(expression[cursor])) + return false; + lastSegmentStart = cursor; + cursor++; + } + + return isPointerConversion || expression.Contains('.'); + } + + private static void EmitGoMethodExpressionReceiverTypeReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + EmitGoParenthesizedMethodExpressionReceiverTypes(line, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitGoBareMethodExpressionReceiverTypes(line, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitGoGenericMethodExpressionReceiverTypes(line, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static void EmitGoParenthesizedMethodExpressionReceiverTypes( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var searchStart = 0; + while (searchStart < line.Length) + { + var open = line.IndexOf('(', searchStart); + if (open < 0) + return; + + searchStart = open + 1; + var close = ReferenceExtractor.FindMatchingChar(line, open, '(', ')'); + if (close <= open + 1) + continue; + + var dot = SkipWhitespace(line, close + 1); + if (dot >= line.Length || line[dot] != '.') + continue; + + var methodStart = dot + 1; + if (methodStart >= line.Length || !IsIdentifierStart(line[methodStart])) + continue; + + var rawExpression = line[(open + 1)..close]; + var expression = rawExpression.Trim(); + if (!IsLikelyGoMethodExpressionReceiverType(expression)) + continue; + + var trimStart = rawExpression.IndexOf(expression, StringComparison.Ordinal); + EmitGoTypeExpression(expression, open + 1 + Math.Max(0, trimStart), references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static void EmitGoBareMethodExpressionReceiverTypes( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + for (var dot = 1; dot < line.Length - 1; dot++) + { + if (line[dot] != '.') + continue; + if (!IsSimpleIdentifierPart(line[dot - 1]) || !IsIdentifierStart(line[dot + 1])) + continue; + + var receiverStart = dot - 1; + while (receiverStart >= 0 && IsSimpleIdentifierPart(line[receiverStart])) + receiverStart--; + receiverStart++; + + var receiverName = line[receiverStart..dot]; + if (receiverName.Length == 0 || !char.IsUpper(receiverName[0])) + continue; + + ReferenceExtractor.AddReference(references, seen, fileId, receiverName, receiverStart, "type_reference", context, lineNumber, resolveContainerForColumn(receiverStart)); + } + } + + private static bool IsLikelyGoMethodExpressionReceiverType(string expression) + { + if (expression.Length == 0 || expression.Contains(',')) + return false; + + var cursor = 0; + while (cursor < expression.Length && char.IsWhiteSpace(expression[cursor])) + cursor++; + if (cursor < expression.Length && expression[cursor] == '*') + cursor = SkipWhitespace(expression, cursor + 1); + + if (cursor >= expression.Length || !IsIdentifierStart(expression[cursor])) + return false; + + if (IsLikelyGoGenericReceiverTypeExpression(expression, cursor)) + return true; + + var lastSegmentStart = cursor; + while (cursor < expression.Length) + { + if (IsSimpleIdentifierPart(expression[cursor])) + { + cursor++; + continue; + } + + if (expression[cursor] != '.') + return false; + + cursor++; + if (cursor >= expression.Length || !IsIdentifierStart(expression[cursor])) + return false; + lastSegmentStart = cursor; + cursor++; + } + + return expression.Contains('.') || char.IsUpper(expression[lastSegmentStart]); + } + + private static void EmitGoGenericMethodExpressionReceiverTypes( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var searchStart = 0; + while (searchStart < line.Length) + { + var open = line.IndexOf('[', searchStart); + if (open < 0) + return; + + searchStart = open + 1; + if (!TryGetGoIdentifierBeforeBracket(line, open, out var nameStart, out var nameLength)) + continue; + if (nameLength == 0 || !char.IsUpper(line[nameStart])) + continue; + + var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); + if (close < 0) + continue; + + var dot = SkipWhitespace(line, close + 1); + if (dot >= line.Length || line[dot] != '.') + continue; + + var methodStart = dot + 1; + if (methodStart >= line.Length || !IsIdentifierStart(line[methodStart])) + continue; + + EmitGoTypeExpression(line[nameStart..(close + 1)], nameStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static bool IsLikelyGoGenericReceiverTypeExpression(string expression, int receiverStart) + { + var open = expression.IndexOf('[', receiverStart); + if (open < 0 || !ContainsLikelyGoTypeArgument(expression[open..])) + return false; + + var firstSegmentStart = receiverStart; + var firstSegmentEnd = firstSegmentStart; + while (firstSegmentEnd < expression.Length && IsSimpleIdentifierPart(expression[firstSegmentEnd])) + firstSegmentEnd++; + + if (firstSegmentEnd <= firstSegmentStart) + return false; + if (char.IsUpper(expression[firstSegmentStart])) + return true; + + var afterFirst = SkipWhitespace(expression, firstSegmentEnd); + return afterFirst < expression.Length && expression[afterFirst] == '.'; + } + + private static void EmitGoGenericInstantiationTypeArgumentReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (line.IndexOf("func", StringComparison.Ordinal) >= 0 + && GoFuncRegex.IsMatch(line)) + { + return; + } + + var searchStart = 0; + while (searchStart < line.Length) + { + var open = line.IndexOf('[', searchStart); + if (open < 0) + return; + + searchStart = open + 1; + if (!TryGetGoIdentifierBeforeBracket(line, open, out var nameStart, out var nameLength)) + continue; + if (nameLength == 0 || !char.IsUpper(line[nameStart])) + continue; + + var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); + if (close < 0) + continue; + + var afterClose = SkipWhitespace(line, close + 1); + if (afterClose < line.Length && line[afterClose] is '(' or '{') + continue; + if (afterClose < line.Length && !IsGoGenericInstantiationTerminator(line[afterClose])) + continue; + + EmitGoGenericTypeArgumentList(line, open, close, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static bool IsGoGenericInstantiationTerminator(char ch) + => char.IsWhiteSpace(ch) || ch is ',' or ')' or ']' or '}' or ';'; + + private static void EmitGoGenericTypeArgumentList( + string line, + int open, + int close, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var typeArguments = line.AsSpan(open + 1, close - open - 1); + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeArguments)) + { + var expressionSpan = TrimGoCommaSegment(typeArguments.Slice(segmentStart, segmentLength), out var trimStart); + var expression = expressionSpan.ToString(); + if (expression.Length == 0 || !ContainsLikelyGoTypeArgument(expression)) + continue; + + var absoluteStart = open + 1 + segmentStart + Math.Max(0, trimStart); + EmitGoTypeExpression(expression, absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static bool HasGoIdentifierBeforeBracket(string line, int openBracket) + => TryGetGoIdentifierBeforeBracket(line, openBracket, out _, out _); + + private static bool TryGetGoIdentifierBeforeBracket(string line, int openBracket, out int start, out int length) + { + start = -1; + length = 0; + var cursor = openBracket - 1; + while (cursor >= 0 && char.IsWhiteSpace(line[cursor])) + cursor--; + if (cursor < 0 || !IsSimpleIdentifierPart(line[cursor])) + return false; + + var end = cursor + 1; + while (cursor >= 0 && IsSimpleIdentifierPart(line[cursor])) + cursor--; + + start = cursor + 1; + length = end - start; + return true; + } + + private static bool ContainsLikelyGoTypeArgument(string expression) + { + for (var i = 0; i < expression.Length; i++) + { + if (!IsIdentifierStart(expression[i])) + continue; + + var start = i; + i++; + while (i < expression.Length && IsSimpleIdentifierPart(expression[i])) + i++; + + if (char.IsUpper(expression[start])) + return true; + } + + return false; + } + + private static bool ContainsGoUppercaseAscii(string line) + { + foreach (var ch in line) + { + if (ch is >= 'A' and <= 'Z') + return true; + } + + return false; + } + + private static bool IsGoTypeDeclarationBodyStart(string line, int index) + { + if (StartsWithKeyword(line, index, "struct") + || StartsWithKeyword(line, index, "interface") + || StartsWithKeyword(line, index, "func") + || StartsWithKeyword(line, index, "map") + || StartsWithKeyword(line, index, "chan")) + { + return true; + } + + return line[index] is '*' or '[' or '~' || IsIdentifierStart(line[index]); + } + + private static bool IsGoCompositeLiteralContext(string line, int nameIndex, int nameLength) + { + var openBraceIndex = line.IndexOf('{', nameIndex + nameLength); + if (openBraceIndex < 0) + return false; + + var trimmed = line.TrimStart(); + var firstBraceIndex = line.IndexOf('{'); + if (trimmed.StartsWith("func ", StringComparison.Ordinal) && firstBraceIndex == openBraceIndex) + return false; + + var previous = nameIndex - 1; + while (previous >= 0 && char.IsWhiteSpace(line[previous])) + previous--; + if (previous < 0) + return false; + + if (line[previous] is '=' or ':' or '(' or '[' or '{' or ',' or '!' or '&' or '*') + return true; + if (line[previous] == '.') + return previous > 0 && IsSimpleIdentifierPart(line[previous - 1]); + if (line[previous] == ']') + return !trimmed.StartsWith("func ", StringComparison.Ordinal); + + var tokenEnd = previous + 1; + while (previous >= 0 && IsSimpleIdentifierPart(line[previous])) + previous--; + var token = line[(previous + 1)..tokenEnd]; + return string.Equals(token, "return", StringComparison.Ordinal); + } + +} diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.GoSignatures.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.GoSignatures.cs new file mode 100644 index 000000000..499a485fe --- /dev/null +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.GoSignatures.cs @@ -0,0 +1,415 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class LanguageReferenceExtractionSupport +{ + internal static void EmitGoBranchLabelReferences(string preparedLine, Action addCallLikeReference) + { + foreach (Match match in GoBranchLabelRegex.Matches(preparedLine)) + addCallLikeReference(match.Groups["name"].Value, match.Groups["name"].Index); + } + + private static void EmitGoFunctionSignatureTypes( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var firstParen = preparedLine.IndexOf('('); + if (firstParen < 0) + return; + + var parameterOpen = firstParen; + var functionHeaderStart = GoFuncRegex.Match(preparedLine).Length; + var receiverClose = ReferenceExtractor.FindMatchingChar(preparedLine, firstParen, '(', ')'); + if (receiverClose >= 0) + { + var afterReceiver = receiverClose + 1; + while (afterReceiver < preparedLine.Length && char.IsWhiteSpace(preparedLine[afterReceiver])) + afterReceiver++; + if (afterReceiver < preparedLine.Length && IsIdentifierStart(preparedLine[afterReceiver])) + { + var nextParen = preparedLine.IndexOf('(', afterReceiver); + if (nextParen > afterReceiver) + { + EmitGoParameterListTypes(preparedLine, firstParen + 1, receiverClose, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + functionHeaderStart = afterReceiver; + + var afterName = afterReceiver + 1; + while (afterName < preparedLine.Length && IsSimpleIdentifierPart(preparedLine[afterName])) + afterName++; + while (afterName < preparedLine.Length && char.IsWhiteSpace(preparedLine[afterName])) + afterName++; + + if (afterName < preparedLine.Length && preparedLine[afterName] == '[') + { + var typeParameterClose = ReferenceExtractor.FindMatchingChar(preparedLine, afterName, '[', ']'); + if (typeParameterClose > afterName) + { + EmitGoTypeParameterConstraints(preparedLine, afterName, typeParameterClose + 1, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + var valueParameterOpen = preparedLine.IndexOf('(', typeParameterClose + 1); + if (valueParameterOpen < 0) + return; + + nextParen = valueParameterOpen; + } + } + + parameterOpen = nextParen; + } + } + } + + var parameterClose = ReferenceExtractor.FindMatchingChar(preparedLine, parameterOpen, '(', ')'); + if (parameterClose < 0) + return; + + EmitGoTypeParameterConstraints(preparedLine, functionHeaderStart, parameterOpen, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitGoParameterListTypes(preparedLine, parameterOpen + 1, parameterClose, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + + var returnStart = parameterClose + 1; + while (returnStart < preparedLine.Length && char.IsWhiteSpace(preparedLine[returnStart])) + returnStart++; + if (returnStart >= preparedLine.Length || preparedLine[returnStart] == '{') + return; + + if (preparedLine[returnStart] == '(') + { + var returnClose = ReferenceExtractor.FindMatchingChar(preparedLine, returnStart, '(', ')'); + if (returnClose > returnStart) + EmitGoParameterListTypes(preparedLine, returnStart + 1, returnClose, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + return; + } + + var returnEnd = returnStart; + while (returnEnd < preparedLine.Length && preparedLine[returnEnd] != '{') + returnEnd++; + + EmitGoTypeExpression(preparedLine, returnStart, returnEnd, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static void EmitGoInterfaceMethodSignatureTypes( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var nameStart = SkipWhitespace(preparedLine, 0); + if (nameStart >= preparedLine.Length || !IsIdentifierStart(preparedLine[nameStart])) + return; + + var nameEnd = nameStart + 1; + while (nameEnd < preparedLine.Length && IsSimpleIdentifierPart(preparedLine[nameEnd])) + nameEnd++; + + if (IsGoStatementKeyword(preparedLine[nameStart..nameEnd])) + return; + + var open = SkipWhitespace(preparedLine, nameEnd); + if (open >= preparedLine.Length || preparedLine[open] != '(') + return; + + var close = ReferenceExtractor.FindMatchingChar(preparedLine, open, '(', ')'); + if (close < 0) + return; + + var returnStart = SkipWhitespace(preparedLine, close + 1); + if (!IsGoSignatureReturnStart(preparedLine, returnStart)) + return; + + EmitGoParameterListTypes(preparedLine, open + 1, close, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitGoSignatureReturnTypes(preparedLine, close + 1, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static bool IsGoStatementKeyword(string value) + => value is "break" or "case" or "const" or "continue" or "default" or "defer" + or "else" or "fallthrough" or "for" or "func" or "go" or "goto" or "if" + or "import" or "package" or "range" or "return" or "select" or "switch" + or "type" or "var"; + + private static bool IsGoSignatureReturnStart(string line, int index) + { + if (index >= line.Length) + return false; + + return line[index] == '(' || IsGoTypeExpressionStart(line, index); + } + + private static bool IsGoTypeExpressionStart(string line, int index) + { + if (index >= line.Length) + return false; + + return line[index] is '*' or '[' or '~' or '<' || IsIdentifierStart(line[index]); + } + + private static int FindGoInlineTypeExpressionEnd(string line, int start) + { + var squareDepth = 0; + var parenDepth = 0; + for (var cursor = start; cursor < line.Length; cursor++) + { + switch (line[cursor]) + { + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) + squareDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth == 0) + return cursor; + parenDepth--; + break; + case ',': + case '{': + case '`': + case '=': + case ';': + if (squareDepth == 0 && parenDepth == 0) + return cursor; + break; + } + } + + return line.Length; + } + + private static void EmitGoSignatureReturnTypes( + string preparedLine, + int start, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var returnStart = SkipWhitespace(preparedLine, start); + if (returnStart >= preparedLine.Length || preparedLine[returnStart] == '{') + return; + + if (preparedLine[returnStart] == '(') + { + var returnClose = ReferenceExtractor.FindMatchingChar(preparedLine, returnStart, '(', ')'); + if (returnClose > returnStart) + EmitGoParameterListTypes(preparedLine, returnStart + 1, returnClose, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + return; + } + + var returnEnd = returnStart; + while (returnEnd < preparedLine.Length && preparedLine[returnEnd] != '{') + returnEnd++; + + EmitGoTypeExpression(preparedLine, returnStart, returnEnd, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static void EmitGoTypeParameterConstraints( + string line, + int searchStart, + int searchEnd, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (searchStart < 0 || searchStart >= searchEnd || searchEnd > line.Length) + return; + + var open = line.IndexOf('[', searchStart, searchEnd - searchStart); + if (open < 0) + return; + + var close = ReferenceExtractor.FindMatchingChar(line, open, '[', ']'); + if (close < 0 || close > searchEnd) + return; + + var list = line.AsSpan(open + 1, close - open - 1); + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(list)) + { + var fragmentSpan = TrimGoCommaSegment(list.Slice(segmentStart, segmentLength), out var fragmentTrimStart); + var fragment = fragmentSpan.ToString(); + if (fragment.Length == 0) + continue; + + var constraintStart = FirstGoTypeParameterConstraintStart(fragment); + if (constraintStart < 0) + continue; + + var absoluteStart = open + 1 + segmentStart + Math.Max(0, fragmentTrimStart) + constraintStart; + EmitGoTypeExpression(fragment[constraintStart..], absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + } + + private static int FirstGoTypeParameterConstraintStart(string fragment) + { + var cursor = 0; + while (cursor < fragment.Length && char.IsWhiteSpace(fragment[cursor])) + cursor++; + if (cursor >= fragment.Length || !IsIdentifierStart(fragment[cursor])) + return -1; + + cursor++; + while (cursor < fragment.Length && IsSimpleIdentifierPart(fragment[cursor])) + cursor++; + + var constraintStart = cursor; + while (constraintStart < fragment.Length && char.IsWhiteSpace(fragment[constraintStart])) + constraintStart++; + + return constraintStart < fragment.Length ? constraintStart : -1; + } + + private static void EmitGoParameterListTypes( + string line, + int start, + int end, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (end <= start) + return; + + var list = line.AsSpan(start, end - start); + List<(string Expression, int AbsoluteStart)>? pendingSingleExpressions = null; + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(list)) + { + var fragmentSpan = TrimGoCommaSegment(list.Slice(segmentStart, segmentLength), out var fragmentTrimStart); + var fragment = fragmentSpan.ToString(); + if (fragment.Length == 0) + continue; + + var absoluteFragmentStart = start + segmentStart + Math.Max(0, fragmentTrimStart); + var typeStartInFragment = LastWhitespaceSeparatedTokenStart(fragment); + if (typeStartInFragment < 0) + continue; + if (typeStartInFragment == 0) + { + (pendingSingleExpressions ??= []).Add((fragment, absoluteFragmentStart)); + continue; + } + + pendingSingleExpressions?.Clear(); + var expression = fragment[typeStartInFragment..]; + var absoluteStart = absoluteFragmentStart + typeStartInFragment; + EmitGoTypeExpression(expression, absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + if (pendingSingleExpressions is null) + return; + + foreach (var (expression, absoluteStart) in pendingSingleExpressions) + EmitGoTypeExpression(expression, absoluteStart, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static ReadOnlySpan TrimGoCommaSegment(ReadOnlySpan segment, out int leading) + { + leading = 0; + while (leading < segment.Length && char.IsWhiteSpace(segment[leading])) + leading++; + + var length = segment.Length - leading; + while (length > 0 && char.IsWhiteSpace(segment[leading + length - 1])) + length--; + + return segment.Slice(leading, length); + } + + private static void EmitGoTypeExpression( + string expression, + int start, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + EmitGoTypeExpressionRange( + expression, + 0, + expression.Length, + start, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + + private static void EmitGoTypeExpression( + string source, + int start, + int endExclusive, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + EmitGoTypeExpressionRange( + source, + start, + endExclusive, + 0, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + + private static void EmitGoTypeExpressionRange( + string source, + int start, + int endExclusive, + int absoluteOffset, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + start = Math.Clamp(start, 0, source.Length); + endExclusive = Math.Clamp(endExclusive, start, source.Length); + + while (start < endExclusive && char.IsWhiteSpace(source[start])) + start++; + + while (endExclusive > start && char.IsWhiteSpace(source[endExclusive - 1])) + endExclusive--; + + if (endExclusive <= start) + return; + + var normalized = start == 0 && endExclusive == source.Length + ? source + : source[start..endExclusive]; + var absoluteStart = absoluteOffset + start; + ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, normalized, absoluteStart, context, lineNumber, resolveContainerForColumn(absoluteStart), "go"); + } +} diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.Patterns.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.Patterns.cs new file mode 100644 index 000000000..8854fd6cc --- /dev/null +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.Patterns.cs @@ -0,0 +1,502 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class LanguageReferenceExtractionSupport +{ + // THREAD-SAFETY: This support surface only owns immutable post-construction Regex fields. + // Any state accumulated while extracting references must stay in caller-provided collections + // or local variables so concurrent ReferenceExtractor calls cannot share mutable state. + private static readonly string[] RazorControlDirectives = + { + "@if", + "@foreach", + "@for", + "@while", + "@switch", + "@using", + "@lock", + "@try", + "@catch", + "@finally", + "@do" + }; + private static readonly string[] RazorCodeDirectives = ["@code", "@functions"]; + private static readonly string[] RazorBareControlContinuationKeywords = ["else", "catch", "finally"]; + private static readonly string[] CTypeSpecifierKeywords = ["struct", "enum", "union"]; + private static readonly string[] PascalDeclarationKeywords = + [ + "var", "const", "type", "property", "procedure", "function", "constructor", "destructor", + ]; + private static readonly string[] CppAccessPrefixes = ["public ", "private ", "protected ", "virtual "]; + + private static readonly Regex CppIncludeRegex = new( + @"^(?:\s*#\s*(?:include(?:_next)?|import)\s*(?:<(?[^>\r\n]+)>|""(?[^""\r\n]+)""|(?[^\s]+))|\s*(?:export\s+)?import\s+(?:<(?[^>\r\n]+)>|""(?[^""\r\n]+)""|(?:?[A-Za-z_]\w*(?:[.:][A-Za-z_]\w*)*))\s*;)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppBaseListRegex = new( + @"^\s*(?:export\s+)?(?:(?:template|requires)\b[^{;]*\s+)*(?:class|struct)\s+[A-Za-z_]\w*(?:\s*final)?\s*:\s*(?[^{;]+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppNewTypeRegex = new( + @"\bnew\s+(?(?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*(?:\s*<[^;{}]+>)?)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppNamedCastTypeRegex = new( + @"\b(?:static_cast|dynamic_cast|reinterpret_cast|const_cast)\s*<(?[^;{}<>]+(?:<[^;{}<>]+>)?[^;{}<>]*)>", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppCStyleCastTypeRegex = new( + @"(?(?:(?:const|volatile|typename|class|struct|enum)\s+)*(?:[A-Z_]\w*|[A-Za-z_]\w*\s*::\s*[A-Za-z_]\w*)(?:\s*<[^;{}()]+>)?(?:\s*[*&])*)\s*\)\s*(?:[A-Za-z_]\w*|\*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefCastTypeRegex = new( + @"(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t(?:\s*\*)*)\s*\)\s*(?:[A-Za-z_]\w*|\*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefSizeofTypeRegex = new( + @"\bsizeof\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t(?:\s*\*(?:\s*(?:const|volatile|restrict|_Atomic))?)*)\s*\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedSizeofTypeRegex = new( + @"\bsizeof\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\s*\*(?:\s*(?:const|volatile|restrict|_Atomic))?)*\s*\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefAlignofTypeRegex = new( + @"\b(?:_Alignof|alignof|__alignof__|__alignof)\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t(?:\s*\*)*)\s*\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedAlignofTypeRegex = new( + @"\b(?:_Alignof|alignof|__alignof__|__alignof)\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefDeclarationTypeRegex = new( + @"(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*\s*(?=[=,;\[])", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedDeclarationTypeRegex = new( + @"(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*\s*(?=[=,;\[])", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefFunctionReturnTypeRegex = new( + @"^\s*(?:(?:static|extern|inline|const|volatile|restrict|_Atomic)\s+)*(?[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*\s*\(", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedFunctionReturnTypeRegex = new( + @"^\s*(?:(?:static|extern|inline|const|volatile|restrict|_Atomic)\s+)*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*\s*\(", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefParameterTypeRegex = new( + @"(?:\(|,)\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedParameterTypeRegex = new( + @"(?:\(|,)\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefCompoundLiteralTypeRegex = new( + @"\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)\s*\{", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedCompoundLiteralTypeRegex = new( + @"\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)\s*\{", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefTypeofTypeRegex = new( + @"\b(?:typeof|__typeof__|__typeof)\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedTypeofTypeRegex = new( + @"\b(?:typeof|__typeof__|__typeof)\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefTypeofUnqualTypeRegex = new( + @"\b(?:typeof_unqual|__typeof_unqual__|__typeof_unqual)\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedTypeofUnqualTypeRegex = new( + @"\b(?:typeof_unqual|__typeof_unqual__|__typeof_unqual)\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefBuiltinTypesCompatibleFirstTypeRegex = new( + @"\b__builtin_types_compatible_p\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*,", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefBuiltinTypesCompatibleSecondTypeRegex = new( + @"\b__builtin_types_compatible_p\s*\([^,;{}]+,\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedBuiltinTypesCompatibleFirstTypeRegex = new( + @"\b__builtin_types_compatible_p\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?,", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedBuiltinTypesCompatibleSecondTypeRegex = new( + @"\b__builtin_types_compatible_p\s*\([^,;{}]+,\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefGenericAssociationTypeRegex = new( + @"(?:_Generic\s*\([^,;{}]*,|,)\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*(?:\s*(?:const|volatile|restrict|_Atomic))?)*\s*:", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedGenericAssociationTypeRegex = new( + @"(?:_Generic\s*\([^,;{}]*,|,)\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\s*\*(?:\s*(?:const|volatile|restrict|_Atomic))?)*\s*:", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefAtomicTypeRegex = new( + @"\b_Atomic\s*\(\s*(?(?:(?:const|volatile|restrict)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedAtomicTypeRegex = new( + @"\b_Atomic\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefAlignasTypeRegex = new( + @"\b(?:_Alignas|alignas)\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedAlignasTypeRegex = new( + @"\b(?:_Alignas|alignas)\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefFunctionPointerAliasTypeRegex = new( + @"\btypedef\s+(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\(", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedFunctionPointerAliasTypeRegex = new( + @"\btypedef\s+(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\(", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefFunctionPointerDeclarationTypeRegex = new( + @"(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\(", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedFunctionPointerDeclarationTypeRegex = new( + @"(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\(", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefPointerArrayDeclarationTypeRegex = new( + @"(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\[", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedPointerArrayDeclarationTypeRegex = new( + @"(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\[", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefOffsetofTypeRegex = new( + @"\b(?:offsetof|__builtin_offsetof)\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*,", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedOffsetofTypeRegex = new( + @"\b(?:offsetof|__builtin_offsetof)\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?,", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTypedefVaArgTypeRegex = new( + @"\b(?:va_arg|__builtin_va_arg)\s*\(\s*[^,;{}]+,\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CTaggedVaArgTypeRegex = new( + @"\b(?:va_arg|__builtin_va_arg)\s*\(\s*[^,;{}]+,\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly string[] CVaArgFunctionNames = + { + "va_arg", + "__builtin_va_arg", + }; + private static readonly Regex CppTypeOperandOperatorRegex = new( + @"\b(?:sizeof|alignof)\s*\(\s*(?(?:(?:const|volatile|typename|class|struct|enum)\s+)*(?:[A-Z_]\w*|[A-Za-z_]\w*\s*::\s*[A-Za-z_]\w*)(?:\s*<[^;{}]+>)?(?:\s*[*&])*)\s*\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppTypeIdRegex = new( + @"\btypeid\s*\(\s*(?(?:(?:const|volatile|typename|class|struct|enum)\s+)*(?:[A-Z_]\w*|[A-Za-z_]\w*\s*::\s*[A-Za-z_]\w*)(?:\s*<[^;{}]+>)?(?:\s*[*&])*)\s*\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppDecltypeBraceConstructionRegex = new( + @"\bdecltype\s*\(\s*(?(?:[A-Za-z_]\w*\s*::\s*)*[A-Z_]\w*(?:\s*<[^;{}()]+>)?)\s*\{", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppFactoryTemplateArgumentRegex = new( + @"\b(?:std\s*::\s*)?(?:make_unique|make_shared|make_optional)\s*<(?[^;{}<>]+(?:<[^;{}<>]+>)?[^;{}<>]*)>\s*\(", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppTypeTraitTemplateArgumentRegex = new( + @"\b(?:std\s*::\s*)?(?:is_same|is_base_of|is_convertible|is_constructible|is_assignable|is_invocable)(?:_v)?\s*<(?[^;{}<>]+(?:<[^;{}<>]+>)?[^;{}<>]*)>", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppBraceConstructionRegex = new( + @"(?:=\s*|return\s+|co_return\s+|throw\s+)(?(?:[A-Za-z_]\w*\s*::\s*)*[A-Z_]\w*(?:\s*<[^;{}]+>)?)\s*\{", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppQualifiedTemplateBraceConstructionRegex = new( + @"(?:=\s*|return\s+|co_return\s+|throw\s+)(?:[A-Za-z_]\w*\s*::\s*)+[A-Za-z_]\w*\s*<(?[^;{}]+)>\s*\{", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppUsingAliasTargetRegex = new( + @"\b(?:template\s*<[^>]*>\s*)?using\s+[A-Za-z_]\w*\s*=\s*(?[^;]+);", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppTypedefAliasTargetRegex = new( + @"\btypedef\s+(?![^;]*\()(?.+?)\s+[A-Za-z_]\w*\s*;", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppExplicitTemplateInstantiationRegex = new( + @"\b(?:extern\s+)?template\s+(?:class|struct)\s+(?[^;]+);", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppTemplateIdDeclarationRegex = new( + @"(?(?:[A-Za-z_]\w*\s*::\s*)*[A-Z_]\w*)\s*<(?[^;{}]+)>\s*(?:[*&]\s*)?[A-Za-z_]\w*\s*(?:[=;{,)]|\[[^\]]*\])", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppTemplateParameterDefaultTypeRegex = new( + @"\b(?:typename|class)\s+[A-Za-z_]\w*\s*=\s*(?(?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*(?:\s*<[^,>]+>)?(?:\s*[*&])?)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppQualifiedMemberReceiverRegex = new( + @"(?(?:(?:[A-Za-z_]\w*)\s*::\s*)*[A-Z_]\w*)\s*::\s*[A-Za-z_]\w*", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppPointerToMemberTypeRegex = new( + @"(?(?:(?:[A-Za-z_]\w*)\s*::\s*)*[A-Z_]\w*)\s*::\s*\*", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppTrailingReturnTypeRegex = new( + @"\)\s*->\s*(?(?:(?:const|volatile|typename|class|struct|enum)\s+)*(?:[A-Z_]\w*|[A-Za-z_]\w*\s*::\s*[A-Za-z_]\w*)(?:\s*<[^;{}]+>)?(?:\s*[*&])*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppRequiresConceptTypeRegex = new( + @"\brequires\s+(?(?:(?:[A-Za-z_]\w*)\s*::\s*)*[A-Z_]\w*)\s*<", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppParenthesizedRequiresConceptTypeRegex = new( + @"\brequires\s*\(\s*(?(?:(?:[A-Za-z_]\w*)\s*::\s*)*[A-Z_]\w*)\s*<", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppQualifiedRequiresConceptConstraintRegex = new( + @"\brequires\s*\(?\s*(?(?:(?:[A-Za-z_]\w*)\s*::\s*)+[A-Za-z_]\w*)\s*<(?[^;{}<>]+(?:<[^;{}<>]+>)?[^;{}<>]*)>", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppConceptExpressionTypeRegex = new( + @"(?:=|&&|\|\|)\s*(?(?:(?:[A-Za-z_]\w*)\s*::\s*)*[A-Z_]\w*)\s*<", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppCompoundRequirementConceptRegex = new( + @"->\s*(?(?:(?:[A-Za-z_]\w*)\s*::\s*)*[A-Za-z_]\w*)\s*<(?[^;{}<>]+(?:<[^;{}<>]+>)?[^;{}<>]*)>", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppFriendTypeRegex = new( + @"\bfriend\s+(?:class|struct|union|typename|enum(?:\s+class)?)\s+(?(?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*)\s*;", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppDynamicExceptionSpecRegex = new( + @"\bthrow\s*\(\s*(?(?:(?:[A-Za-z_]\w*\s*::\s*)*[A-Z_]\w*(?:\s*[*&])?(?:\s*,\s*)?)+)\s*\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex CppDeclarationTypeRegex = new( + @"(?(?:(?:const|volatile|static|inline|constexpr|typename|class|struct|enum)\s+)*(?:[A-Z_]\w*|[A-Za-z_]\w*\s*::\s*[A-Za-z_]\w*)(?:\s*<[^;{}]+>)?(?:\s*[*&])*)\s+(?[A-Za-z_]\w*)\s*(?=[,;)=])", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex DartCtorRegex = new( + @"\b(?:new|const)\s+(?[A-Z]\w*(?:\.[A-Za-z_]\w*)?)\s*(?:<[^>]+>)?\s*\(", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex DartVariableTypeRegex = new( + @"^\s*(?:(?:final|late|const)\s+)*(?[A-Z]\w*(?:\s*<[^;=]+>)?)\s+[A-Za-z_]\w*\s*(?:=|;)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex DartFunctionSignatureRegex = new( + @"^\s*(?:(?:external|static|abstract)\s+)*(?[A-Z]\w*(?:\s*<[^;{}()]+>)?)\s+[A-Za-z_]\w*\s*\((?[^)]*)\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex DartParameterTypeRegex = new( + @"(?:^|,)\s*(?:(?:required|covariant|final)\s+)*(?[A-Z]\w*(?:\s*<[^,)=]+>)?)\s+[A-Za-z_]\w*", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private const string VbIdentifierPattern = @"(?:\[[^\]\r\n]+\]|[A-Za-z_]\w*)"; + private const string VbQualifiedIdentifierPattern = @"(?:Global\.)?(?:" + VbIdentifierPattern + @")(?:\.(?:" + VbIdentifierPattern + @"))*"; + private static readonly Regex VbTypeKeywordRegex = new( + @"\b(?:As\s+(?:New\s+)?|New\s+|Inherits\s+|Implements\s+)(?" + VbQualifiedIdentifierPattern + @")", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbGenericArgumentListRegex = new( + @"\(\s*Of\s+(?[^)\r\n]+)\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbGenericDeclarationOwnerRegex = new( + @"\b(?:Class|Structure|Interface|Delegate|Sub|Function)\s+" + VbIdentifierPattern + @"\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbGenericConstraintRegex = new( + @"^\s*(?" + VbIdentifierPattern + @")\s+As\s+(?.+)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbNewTypeRegex = new( + @"\bNew\s+(?" + VbQualifiedIdentifierPattern + @")", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbImplementsListRegex = new( + @"\bImplements\s+(?[^\r\n]+)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbImportsListRegex = new( + @"^\s*Imports\s+(?[^\r\n]+)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbCastTypeRegex = new( + @"\b(?:DirectCast|TryCast|CType)\s*\([^,\r\n]+,\s*(?" + VbQualifiedIdentifierPattern + @")", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbGetTypeRegex = new( + @"\bGetType\s*\(\s*(?" + VbQualifiedIdentifierPattern + @")", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbTypeOfRegex = new( + @"\bTypeOf\b.+?\bIs(?:Not)?\s+(?" + VbQualifiedIdentifierPattern + @")", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbNameOfRegex = new( + @"\bNameOf\s*\(\s*(?" + VbQualifiedIdentifierPattern + @")", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbGetXmlNamespaceRegex = new( + @"\bGetXmlNamespace\s*\(\s*(?[A-Za-z_]\w*)\s*\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbAddressOfRegex = new( + @"\bAddressOf\s+(?" + VbQualifiedIdentifierPattern + @")", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbHandlesTargetRegex = new( + @"(?:\bHandles|,)\s+(?" + VbQualifiedIdentifierPattern + @")", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbAddHandlerRegex = new( + @"\bAddHandler\s+(?" + VbQualifiedIdentifierPattern + @")", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbRemoveHandlerRegex = new( + @"\bRemoveHandler\s+(?" + VbQualifiedIdentifierPattern + @")", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbRaiseEventRegex = new( + @"\bRaiseEvent\s+(?" + VbQualifiedIdentifierPattern + @")", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbCallRegex = new( + @"(?" + VbQualifiedIdentifierPattern + @")\s*\(", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbBareCallRegex = new( + @"^\s*(?:Call\s+)?(?" + VbQualifiedIdentifierPattern + @")(?\s*(?:$|.*))", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbBareMemberCallRegex = new( + @"^\s*\.\s*(?" + VbIdentifierPattern + @")(?\s*(?:$|.*))", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex VbCallByNameRegex = new( + @"\bCallByName\s*\([^,\r\n]+,\s*""(?[^""\r\n]+)""", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + private static readonly Regex FortranUseRegex = new( + @"^\s*use(?:\s*,\s*(?:intrinsic|non_intrinsic))?(?:\s*::\s*|\s+)(?[A-Za-z_]\w*)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranUseOnlyRegex = new( + @"^\s*use(?:\s*,\s*(?:intrinsic|non_intrinsic))?(?:\s*::\s*|\s+)[A-Za-z_]\w*\s*,\s*only\s*:\s*(?.+)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranUseRenameListRegex = new( + @"^\s*use(?:\s*,\s*(?:intrinsic|non_intrinsic))?(?:\s*::\s*|\s+)[A-Za-z_]\w*\s*,\s*(?!only\s*:)(?.+)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranUseAliasRegex = new( + @"(?:^|,)\s*(?[A-Za-z_]\w*)\s*=>\s*[A-Za-z_]\w*", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex FortranUseAliasTargetRegex = new( + @"(?:^|,)\s*[A-Za-z_]\w*\s*=>\s*(?[A-Za-z_]\w*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex FortranImportRegex = new( + @"^\s*import(?:\s*,\s*only)?(?:\s*::\s*|\s*:\s*|\s+)(?.+)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranIncludeRegex = new( + @"^\s*include\s*['""](?[^'""]+)['""]", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranBlankCommonMemberListRegex = new( + @"^\s*common\s+(?[^/].*)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranParenthesizedNameListRegex = new( + @"\((?[^()]*)\)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex FortranDataLineRegex = new( + @"^\s*data\s+(?.+)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranDataObjectGroupRegex = new( + @"(?:^|,)\s*(?[^/]+?)\s*/", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranSaveRegex = new( + @"^\s*save(?:\s*::|\s+)(?.+)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranSlashGroupNameRegex = new( + @"/\s*(?[A-Za-z_]\w*)\s*/", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex FortranSlashGroupMemberListRegex = new( + @"/\s*[A-Za-z_]\w*\s*/(?[^/]*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex FortranSubmoduleParentRegex = new( + @"^\s*submodule\s*\(\s*(?[A-Za-z_]\w*)(?:\s*:\s*(?[A-Za-z_]\w*))?\s*\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranExternalRegex = new( + @"^\s*external(?:\s*::)?\s*(?.+)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranIntrinsicProcedureRegex = new( + @"^\s*intrinsic(?:\s*::)?\s*(?.+)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranAccessListRegex = new( + @"^\s*(?:public|private)(?:\s*::\s*|\s+)(?.+)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranFinalizerRegex = new( + @"^\s*final(?:\s*::\s*|\s+)(?.+)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranSimpleListNameRegex = new( + @"(?:^|,)\s*(?[A-Za-z_]\w*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex FortranTypeRegex = new( + @"\b(?:type|class)\s*\(\s*(?[A-Za-z_]\w*)\s*\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranTypeGuardRegex = new( + @"\b(?:type|class)\s+is\s*\(\s*(?[A-Za-z_]\w*)\s*\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranExtendsRegex = new( + @"\bextends\s*\(\s*(?[A-Za-z_]\w*)\s*\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranProcedureTypeRegex = new( + @"\bprocedure\s*\(\s*(?[A-Za-z_]\w*)\s*\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranAllocateTypeSpecRegex = new( + @"\ballocate\s*\(\s*(?[A-Za-z_]\w*)\s*::", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranAllocateListRegex = new( + @"^\s*allocate\s*\((?.*)\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranAllocateSourceKeywordRegex = new( + @"\b(?:source|mold)\s*=\s*(?[A-Za-z_]\w*)\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranAllocationStatusKeywordRegex = new( + @"\b(?:stat|errmsg)\s*=\s*(?[A-Za-z_]\w*)\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranDeallocateListRegex = new( + @"^\s*deallocate\s*\((?.*)\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranIntrinsicKeywordKindRegex = new( + @"\b(?:integer|real|complex|logical|character)\s*\([^)\r\n]*\bkind\s*=\s*(?[A-Za-z_]\w*)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranIntrinsicPositionalKindRegex = new( + @"\b(?:integer|real|complex|logical)\s*\(\s*(?[A-Za-z_]\w*)\s*\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranBindingTargetListRegex = new( + @"=>.*$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex FortranBindingTargetRegex = new( + @"(?:=>|,)\s*(?:(?:[A-Za-z_]\w*)\s*=>\s*)?(?[A-Za-z_]\w*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex FortranPointerAssignmentRegex = new( + @"^\s*(?:[A-Za-z_]\w*(?:\s*%\s*[A-Za-z_]\w*)*)\s*=>\s*(?[A-Za-z_]\w*)\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranAssociateLineRegex = new( + @"^\s*associate\s*\((?.+)\)\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex FortranAssociateTargetRegex = new( + @"(?:^|,)\s*[A-Za-z_]\w*\s*=>\s*(?[A-Za-z_]\w*)\b", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex FortranCallRegex = new( + @"^\s*call\s+(?:(?:[A-Za-z_]\w*)\s*%\s*)*(?[A-Za-z_]\w*)\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + private static readonly Regex PascalUsesRegex = new( + @"^\s*uses\s+(?.+?)(?:;|$)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex PascalTypeAfterColonRegex = new( + @":\s*(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex PascalClassBaseRegex = new( + @"=\s*(?:class|interface|object)\s*\((?[^)]+)\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex PascalBareCallRegex = new( + @"^\s*(?[A-Za-z_]\w*)\s*;", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex ObjCMessageRegex = new( + @"\[\s*(?[A-Za-z_]\w*)\s+(?[A-Za-z_]\w*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex ObjCInterfaceBaseRegex = new( + @"^\s*@(?:interface|implementation)\s+[A-Za-z_]\w+(?:\s*\([^)]+\))?\s*:\s*(?[A-Za-z_]\w*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex ObjCProtocolListRegex = new( + @"<(?[A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)>", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex ObjCDeclTypeRegex = new( + @"(?[A-Z]\w*)\s*\*+\s*[A-Za-z_]\w*", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex ObjCSelectorRegex = new( + @"@selector\s*\(\s*(?[A-Za-z_]\w*:?)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex HaskellSignatureRegex = new( + @"^\s*[a-z_]\w*\s*::\s*(?.+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex HaskellSpaceCallRegex = new( + @"^\s*(?[a-z_]\w*)\s+(?=(?:[A-Za-z_(]))", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex HaskellDefinitionRegex = new( + @"^\s*(?[a-z_]\w*)\b.*=", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex ElixirImportRegex = new( + @"^\s*(?:alias|import|require|use)\s+(?[A-Z]\w*(?:\.[A-Z]\w*)*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex ElixirBehaviourRegex = new( + @"^\s*@(?:behaviour|impl)\s+(?[A-Z]\w*(?:\.[A-Z]\w*)*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex ElixirParenlessCallRegex = new( + @"(?[a-z_]\w*[?!]?)\s+(?=(?:[A-Za-z_:@\[""']))", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex SmalltalkClassDeclarationRegex = new( + @"^\s*(?:(?:[A-Za-z_]\w*)\s+subclass:|Class\s+named:|Object\s+subclass:)\s*#", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex SmalltalkMessageSendRegex = new( + @"(?[A-Za-z_]\w*)\s+(?[a-z]\w*:?)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex SmalltalkMethodDefinitionRegex = new( + @">>\s*(?[A-Za-z_]\w*:?)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex RazorComponentTagRegex = new( + @"<(?[A-Z][A-Za-z0-9_]*(?:\.[A-Za-z_]\w*)*)(?=[\s>/])", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex RazorDirectiveTypeRegex = new( + @"^\s*@(?:inherits|implements|model)\s+(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex RazorAttributeTypeRegex = new( + @"^\s*@attribute\s+\[\s*(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex RazorInjectRegex = new( + @"^\s*@inject\s+(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s+[A-Za-z_]\w*", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex RazorEventHandlerRegex = new( + @"@on[A-Za-z_]\w*\s*=\s*""@?(?[A-Za-z_]\w*)""", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + +} diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.SecondaryCalls.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.SecondaryCalls.cs new file mode 100644 index 000000000..d60e1178c --- /dev/null +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.SecondaryCalls.cs @@ -0,0 +1,270 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class LanguageReferenceExtractionSupport +{ + private static void EmitFortranCallReferences(string preparedLine, Action addCallLikeReference) + { + if (!StartsWithKeywordIgnoringLeadingWhitespace(preparedLine, "call")) + return; + + foreach (Match match in FortranCallRegex.Matches(preparedLine)) + addCallLikeReference(match.Groups["name"].Value, match.Groups["name"].Index); + } + + private static void EmitPascalCallReferences(string preparedLine, Action addCallLikeReference, IReadOnlySet? definitionNames) + { + if (preparedLine.IndexOf(';') < 0) + return; + + var match = PascalBareCallRegex.Match(preparedLine); + if (!match.Success) + return; + + var name = match.Groups["name"].Value; + if (definitionNames?.Contains(name) == true) + return; + + addCallLikeReference(name, match.Groups["name"].Index); + } + + private static void EmitObjCMessageReferences( + string preparedLine, + Action addCallLikeReference, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (preparedLine.IndexOf('[') >= 0) + { + foreach (Match match in ObjCMessageRegex.Matches(preparedLine)) + { + var receiver = match.Groups["receiver"]; + var selector = match.Groups["name"]; + if (char.IsUpper(receiver.Value[0]) && selector.Value is "alloc" or "new") + { + ReferenceExtractor.AddReference(references, seen, fileId, receiver.Value, receiver.Index, "instantiate", context, lineNumber, resolveContainerForColumn(receiver.Index)); + } + + addCallLikeReference(selector.Value, selector.Index); + } + } + + if (preparedLine.IndexOf("@selector", StringComparison.Ordinal) >= 0 + && preparedLine.IndexOf('(') >= 0) + { + foreach (Match match in ObjCSelectorRegex.Matches(preparedLine)) + addCallLikeReference(match.Groups["name"].Value.TrimEnd(':'), match.Groups["name"].Index); + } + } + + private static void EmitHaskellSpaceCallReferences(string preparedLine, Action addCallLikeReference, IReadOnlySet? definitionNames) + { + if (!ContainsWhitespace(preparedLine)) + return; + + string? definitionName = null; + var scanStart = 0; + var scanText = preparedLine; + if (preparedLine.IndexOf('=') >= 0) + { + var definitionMatch = HaskellDefinitionRegex.Match(preparedLine); + if (definitionMatch.Success) + { + definitionName = definitionMatch.Groups["name"].Value; + var equalsIndex = preparedLine.IndexOf('='); + if (equalsIndex >= 0) + { + scanStart = equalsIndex + 1; + scanText = preparedLine[scanStart..]; + } + } + } + + foreach (Match match in HaskellSpaceCallRegex.Matches(scanText)) + { + var name = match.Groups["name"].Value; + if (definitionNames?.Contains(name) == true || string.Equals(name, definitionName, StringComparison.Ordinal)) + continue; + addCallLikeReference(name, scanStart + match.Groups["name"].Index); + } + } + + private static void EmitElixirParenlessCallReferences(string preparedLine, Action addCallLikeReference, IReadOnlySet? definitionNames) + { + if (!ContainsWhitespace(preparedLine)) + return; + + foreach (Match match in ElixirParenlessCallRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (definitionNames?.Contains(name) == true) + continue; + addCallLikeReference(name, match.Groups["name"].Index); + } + } + + private static void EmitSmalltalkMessageReferences(string preparedLine, Action addCallLikeReference, IReadOnlySet? definitionNames) + { + if (!ContainsWhitespace(preparedLine)) + return; + + var isDefinitionLine = preparedLine.IndexOf(">>", StringComparison.Ordinal) >= 0 + && SmalltalkMethodDefinitionRegex.IsMatch(preparedLine); + var hasClassDeclarationLiteralMarker = preparedLine.IndexOf('#') >= 0 + && (preparedLine.IndexOf("subclass:", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("Class", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("Object", StringComparison.Ordinal) >= 0); + if (isDefinitionLine || (hasClassDeclarationLiteralMarker && SmalltalkClassDeclarationRegex.IsMatch(preparedLine))) + return; + + var consumedUntil = 0; + foreach (Match match in SmalltalkMessageSendRegex.Matches(preparedLine)) + { + if (match.Index < consumedUntil) + continue; + + var selectorGroup = match.Groups["selector"]; + var name = ReadSmalltalkSelector(preparedLine, selectorGroup.Index, out var selectorEndIndex); + consumedUntil = Math.Max(consumedUntil, selectorEndIndex); + if (definitionNames?.Contains(name) == true) + continue; + addCallLikeReference(name, selectorGroup.Index); + } + } + + private static bool ContainsWhitespace(string value) + { + foreach (var ch in value) + { + if (char.IsWhiteSpace(ch)) + return true; + } + + return false; + } + + private static string ReadSmalltalkSelector(string line, int selectorIndex, out int endIndex) + { + if (!TryReadSmalltalkSelectorPart(line, selectorIndex, out var firstPart, out var cursor)) + { + endIndex = selectorIndex; + return string.Empty; + } + + if (!firstPart.EndsWith(':')) + { + endIndex = cursor; + return firstPart; + } + + var selector = firstPart; + while (true) + { + var argumentStart = SkipWhitespace(line, cursor); + if (argumentStart >= line.Length || !IsIdentifierStart(line[argumentStart])) + break; + + var argumentEnd = argumentStart + 1; + while (argumentEnd < line.Length && IsSimpleIdentifierPart(line[argumentEnd])) + argumentEnd++; + + var nextSelectorStart = SkipWhitespace(line, argumentEnd); + if (!TryReadSmalltalkSelectorPart(line, nextSelectorStart, out var nextPart, out var nextEnd) + || !nextPart.EndsWith(':')) + { + break; + } + + selector += nextPart; + cursor = nextEnd; + } + + endIndex = cursor; + return selector; + } + + private static bool TryReadSmalltalkSelectorPart(string line, int start, out string part, out int end) + { + part = string.Empty; + end = start; + if (start >= line.Length || !IsIdentifierStart(line[start])) + return false; + + end = start + 1; + while (end < line.Length && IsSimpleIdentifierPart(line[end])) + end++; + if (end < line.Length && line[end] == ':') + end++; + + part = line[start..end]; + return true; + } + + private static void EmitCommaSeparatedNames( + string list, + int listStart, + string language, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(list)) + { + var leading = ReferenceExtractor.CountLeadingWhitespace(list, segmentStart, segmentLength); + var trimmedLength = segmentLength - leading; + while (trimmedLength > 0 && char.IsWhiteSpace(list[segmentStart + leading + trimmedLength - 1])) + trimmedLength--; + if (trimmedLength == 0) + continue; + var expressionStart = segmentStart + leading; + var raw = list.Substring(expressionStart, trimmedLength); + if (language == "vb") + { + var equalsIndex = list.IndexOf('=', segmentStart, segmentLength); + if (equalsIndex >= 0) + { + var rhsStart = equalsIndex + 1; + var rhsLength = segmentStart + segmentLength - rhsStart; + var rhsLeading = ReferenceExtractor.CountLeadingWhitespace(list, rhsStart, rhsLength); + expressionStart = rhsStart + rhsLeading; + var rhsTrimmedLength = rhsLength - rhsLeading; + while (rhsTrimmedLength > 0 && char.IsWhiteSpace(list[expressionStart + rhsTrimmedLength - 1])) + rhsTrimmedLength--; + if (rhsTrimmedLength == 0) + continue; + + raw = list.Substring(expressionStart, rhsTrimmedLength); + } + } + + var name = GetLastWhitespaceSeparatedToken(raw); + var offset = list.IndexOf(name, expressionStart, StringComparison.Ordinal); + if (offset < 0) + offset = expressionStart; + ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, name, listStart + offset, context, lineNumber, container, language); + } + } + + private static string GetLastWhitespaceSeparatedToken(string value) + { + var end = value.Length; + while (end > 0 && (value[end - 1] == ' ' || value[end - 1] == '\t')) + end--; + var start = end; + while (start > 0 && value[start - 1] != ' ' && value[start - 1] != '\t') + start--; + + return start == 0 && end == value.Length ? value : value[start..end]; + } + +} diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.SecondaryTypes.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.SecondaryTypes.cs new file mode 100644 index 000000000..08c9bbaf3 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.SecondaryTypes.cs @@ -0,0 +1,212 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class LanguageReferenceExtractionSupport +{ + private static void EmitPascalTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + SymbolRecord? container) + { + if (StartsWithKeywordIgnoringLeadingWhitespace(preparedLine, "uses")) + { + var usesMatch = PascalUsesRegex.Match(preparedLine); + if (usesMatch.Success) + EmitCommaSeparatedNames(usesMatch.Groups["list"].Value, usesMatch.Groups["list"].Index, "pascal", references, seen, fileId, context, lineNumber, container); + } + + var hasPascalBaseMarker = preparedLine.IndexOf('=') >= 0 + && preparedLine.IndexOf('(') >= 0 + && preparedLine.IndexOf(')') >= 0 + && (ContainsKeywordIgnoringCase(preparedLine, "class") + || ContainsKeywordIgnoringCase(preparedLine, "interface") + || ContainsKeywordIgnoringCase(preparedLine, "object")); + if (hasPascalBaseMarker) + { + foreach (Match match in PascalClassBaseRegex.Matches(preparedLine)) + EmitCommaSeparatedNames(match.Groups["bases"].Value, match.Groups["bases"].Index, "pascal", references, seen, fileId, context, lineNumber, resolveContainerForColumn(match.Groups["bases"].Index)); + } + + if (preparedLine.IndexOf(':') < 0) + return; + + foreach (Match match in PascalTypeAfterColonRegex.Matches(preparedLine)) + { + if (!IsPascalColonTypeReferenceContext(preparedLine, lineNumber, container)) + continue; + + var group = match.Groups["type"]; + ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), "pascal"); + } + } + + private static bool IsPascalColonTypeReferenceContext(string preparedLine, int lineNumber, SymbolRecord? container) + { + var trimmed = preparedLine.TrimStart(); + if (container?.Kind != "function" + || !container.BodyStartLine.HasValue + || lineNumber < container.BodyStartLine.Value) + { + return true; + } + + return StartsWithPascalDeclarationKeyword(trimmed); + } + + private static bool StartsWithPascalDeclarationKeyword(string trimmedLine) + { + foreach (var keyword in PascalDeclarationKeywords) + { + if (trimmedLine.StartsWith(keyword, StringComparison.OrdinalIgnoreCase) + && (trimmedLine.Length == keyword.Length || !IsSimpleIdentifierPart(trimmedLine[keyword.Length]))) + { + return true; + } + } + + return false; + } + + private static void EmitObjCTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + SymbolRecord? container) + { + if (StartsWithCharIgnoringLeadingWhitespace(preparedLine, '@') && preparedLine.IndexOf(':') >= 0) + { + foreach (Match match in ObjCInterfaceBaseRegex.Matches(preparedLine)) + { + var group = match.Groups["type"]; + ReferenceExtractor.AddReference(references, seen, fileId, group.Value, group.Index, "type_reference", context, lineNumber, container); + } + } + + if (preparedLine.IndexOf('<') >= 0 && preparedLine.IndexOf('>') >= 0) + { + foreach (Match match in ObjCProtocolListRegex.Matches(preparedLine)) + EmitCommaSeparatedNames(match.Groups["list"].Value, match.Groups["list"].Index, "objc", references, seen, fileId, context, lineNumber, container); + } + + if (preparedLine.IndexOf('*') < 0) + return; + + foreach (Match match in ObjCDeclTypeRegex.Matches(preparedLine)) + { + var group = match.Groups["type"]; + ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), "objc"); + } + } + + private static void EmitHaskellTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("::", StringComparison.Ordinal) < 0) + return; + + var match = HaskellSignatureRegex.Match(preparedLine); + if (!match.Success) + return; + + var group = match.Groups["types"]; + ReferenceExtractor.AddTypeExpressionSegments( + references, + seen, + fileId, + group.Value, + group.Index, + context, + lineNumber, + container, + "haskell", + BuildHaskellIgnoredTypeVariables(group.Value)); + } + + private static IReadOnlySet? BuildHaskellIgnoredTypeVariables(string expression) + { + HashSet? ignored = null; + for (var cursor = 0; cursor < expression.Length; cursor++) + { + if (!IsSimpleIdentifierPart(expression[cursor])) + continue; + + var start = cursor; + while (cursor < expression.Length && IsSimpleIdentifierPart(expression[cursor])) + cursor++; + + if (char.IsLower(expression[start])) + { + ignored ??= new HashSet(StringComparer.Ordinal); + ignored.Add(expression[start..cursor]); + } + + cursor--; + } + + return ignored; + } + + private static void EmitElixirTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + var hasImportMarker = StartsWithOrdinalKeywordIgnoringLeadingWhitespace(preparedLine, "alias") + || StartsWithOrdinalKeywordIgnoringLeadingWhitespace(preparedLine, "import") + || StartsWithOrdinalKeywordIgnoringLeadingWhitespace(preparedLine, "require") + || StartsWithOrdinalKeywordIgnoringLeadingWhitespace(preparedLine, "use"); + if (hasImportMarker) + { + foreach (var match in EnumerateMatches(ElixirImportRegex, preparedLine)) + ReferenceExtractor.AddReference(references, seen, fileId, match, "type_reference", context, lineNumber, container); + } + + var hasBehaviourMarker = StartsWithCharIgnoringLeadingWhitespace(preparedLine, '@') + && (ContainsOrdinalKeyword(preparedLine, "behaviour") + || ContainsOrdinalKeyword(preparedLine, "impl")); + if (hasBehaviourMarker) + { + foreach (var match in EnumerateMatches(ElixirBehaviourRegex, preparedLine)) + ReferenceExtractor.AddReference(references, seen, fileId, match, "type_reference", context, lineNumber, container); + } + } + + private static bool IsIdentifierAt(string line, int index, string identifier) + { + if (index < 0 || index + identifier.Length > line.Length) + return false; + if (string.CompareOrdinal(line, index, identifier, 0, identifier.Length) != 0) + return false; + if (index > 0 && IsSimpleIdentifierPart(line[index - 1])) + return false; + + var after = index + identifier.Length; + return after >= line.Length || !IsSimpleIdentifierPart(line[after]); + } + + private static bool IsSimpleIdentifierPart(char ch) => + ch == '_' || char.IsLetterOrDigit(ch); + +} diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.Utilities.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.Utilities.cs new file mode 100644 index 000000000..9560c47ca --- /dev/null +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.Utilities.cs @@ -0,0 +1,167 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class LanguageReferenceExtractionSupport +{ + private static void EmitVbGenericConstraintReferences( + string list, + int listStart, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var ignoredSegments = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "As", "Class", "New", "Structure", + }; + + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(list)) + { + var segment = list.Substring(segmentStart, segmentLength); + var match = VbGenericConstraintRegex.Match(segment); + if (match.Success) + { + ignoredSegments.Add(match.Groups["param"].Value); + ignoredSegments.Add(NormalizeVbIdentifierSegment(match.Groups["param"].Value)); + } + } + + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(list)) + { + var segment = list.Substring(segmentStart, segmentLength); + var match = VbGenericConstraintRegex.Match(segment); + if (!match.Success) + continue; + + var constraintGroup = match.Groups["constraint"]; + // The generic-list regex is shallow; skip nested constraints rather than emit type parameters as concrete types. + if (constraintGroup.Value.Contains("(Of", StringComparison.OrdinalIgnoreCase)) + continue; + + var absoluteConstraintStart = listStart + segmentStart + constraintGroup.Index; + ReferenceExtractor.AddTypeExpressionSegments( + references, + seen, + fileId, + constraintGroup.Value, + absoluteConstraintStart, + context, + lineNumber, + resolveContainerForColumn(absoluteConstraintStart), + "vb", + ignoredSegments); + } + } + + private static string StripCppAccessPrefix(string value) + { + var text = value.Trim(); + bool removed; + do + { + removed = false; + foreach (var prefix in CppAccessPrefixes) + { + if (text.StartsWith(prefix, StringComparison.Ordinal)) + { + text = text[prefix.Length..].TrimStart(); + removed = true; + } + } + } while (removed); + + return text; + } + + private static string LastCppQualifiedSegment(string value) + { + var text = value.Trim(); + var genericIndex = text.IndexOf('<'); + if (genericIndex >= 0) + text = text[..genericIndex].TrimEnd(); + var separator = text.LastIndexOf("::", StringComparison.Ordinal); + return separator >= 0 ? text[(separator + 2)..].Trim() : text; + } + + private static bool ContainsAsciiUppercase(string value) + { + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c is >= 'A' and <= 'Z') + return true; + } + + return false; + } + + private static bool IsCppTemplateDeclarationOrSpecializationLine(string line, int matchIndex) + { + var prefix = line[..Math.Clamp(matchIndex, 0, line.Length)].TrimStart(); + return prefix.StartsWith("template", StringComparison.Ordinal) + || prefix.StartsWith("export template", StringComparison.Ordinal); + } + + private static string LastQualifiedSegment(string value) + { + var dot = value.LastIndexOf('.'); + return dot >= 0 && dot + 1 < value.Length ? value[(dot + 1)..] : value; + } + + private static string NormalizeVbIdentifierSegment(string value) + { + var trimmed = value.Trim(); + if (trimmed.Length >= 2 && trimmed[0] == '[' && trimmed[^1] == ']') + return trimmed[1..^1]; + + return trimmed; + } + + private static string LastPathSegment(string value) + { + var slash = value.LastIndexOf('/'); + return slash >= 0 && slash + 1 < value.Length ? value[(slash + 1)..] : value; + } + + private static int LastWhitespaceSeparatedTokenStart(string value) + { + var end = value.Length - 1; + while (end >= 0 && char.IsWhiteSpace(value[end])) + end--; + if (end < 0) + return -1; + + var start = end; + while (start >= 0 && !char.IsWhiteSpace(value[start])) + start--; + return start + 1; + } + + private static IEnumerable EnumerateMatches(Regex regex, string input) + { + foreach (Match match in BoundedRegex.EnumerateMatches(regex, input)) + yield return match; + } + + private static void MaskRange(char[] chars, int start, int end) + { + for (var i = start; i < end && i < chars.Length; i++) + chars[i] = ' '; + } + + private static int SkipWhitespace(string line, int start) + { + while (start < line.Length && char.IsWhiteSpace(line[start])) + start++; + return start; + } + + private static bool IsIdentifierStart(char ch) => + ch == '_' || char.IsLetter(ch); +} diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs index 59999b931..22bc08ceb 100644 --- a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.cs @@ -6,499 +6,6 @@ namespace CodeIndex.Indexer; internal static partial class LanguageReferenceExtractionSupport { - // THREAD-SAFETY: This support surface only owns immutable post-construction Regex fields. - // Any state accumulated while extracting references must stay in caller-provided collections - // or local variables so concurrent ReferenceExtractor calls cannot share mutable state. - private static readonly string[] RazorControlDirectives = - { - "@if", - "@foreach", - "@for", - "@while", - "@switch", - "@using", - "@lock", - "@try", - "@catch", - "@finally", - "@do" - }; - private static readonly string[] RazorCodeDirectives = ["@code", "@functions"]; - private static readonly string[] RazorBareControlContinuationKeywords = ["else", "catch", "finally"]; - private static readonly string[] CTypeSpecifierKeywords = ["struct", "enum", "union"]; - private static readonly string[] PascalDeclarationKeywords = - [ - "var", "const", "type", "property", "procedure", "function", "constructor", "destructor", - ]; - private static readonly string[] CppAccessPrefixes = ["public ", "private ", "protected ", "virtual "]; - - private static readonly Regex CppIncludeRegex = new( - @"^(?:\s*#\s*(?:include(?:_next)?|import)\s*(?:<(?[^>\r\n]+)>|""(?[^""\r\n]+)""|(?[^\s]+))|\s*(?:export\s+)?import\s+(?:<(?[^>\r\n]+)>|""(?[^""\r\n]+)""|(?:?[A-Za-z_]\w*(?:[.:][A-Za-z_]\w*)*))\s*;)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppBaseListRegex = new( - @"^\s*(?:export\s+)?(?:(?:template|requires)\b[^{;]*\s+)*(?:class|struct)\s+[A-Za-z_]\w*(?:\s*final)?\s*:\s*(?[^{;]+)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppNewTypeRegex = new( - @"\bnew\s+(?(?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*(?:\s*<[^;{}]+>)?)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppNamedCastTypeRegex = new( - @"\b(?:static_cast|dynamic_cast|reinterpret_cast|const_cast)\s*<(?[^;{}<>]+(?:<[^;{}<>]+>)?[^;{}<>]*)>", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppCStyleCastTypeRegex = new( - @"(?(?:(?:const|volatile|typename|class|struct|enum)\s+)*(?:[A-Z_]\w*|[A-Za-z_]\w*\s*::\s*[A-Za-z_]\w*)(?:\s*<[^;{}()]+>)?(?:\s*[*&])*)\s*\)\s*(?:[A-Za-z_]\w*|\*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefCastTypeRegex = new( - @"(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t(?:\s*\*)*)\s*\)\s*(?:[A-Za-z_]\w*|\*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefSizeofTypeRegex = new( - @"\bsizeof\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t(?:\s*\*(?:\s*(?:const|volatile|restrict|_Atomic))?)*)\s*\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedSizeofTypeRegex = new( - @"\bsizeof\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\s*\*(?:\s*(?:const|volatile|restrict|_Atomic))?)*\s*\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefAlignofTypeRegex = new( - @"\b(?:_Alignof|alignof|__alignof__|__alignof)\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t(?:\s*\*)*)\s*\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedAlignofTypeRegex = new( - @"\b(?:_Alignof|alignof|__alignof__|__alignof)\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefDeclarationTypeRegex = new( - @"(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*\s*(?=[=,;\[])", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedDeclarationTypeRegex = new( - @"(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*\s*(?=[=,;\[])", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefFunctionReturnTypeRegex = new( - @"^\s*(?:(?:static|extern|inline|const|volatile|restrict|_Atomic)\s+)*(?[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*\s*\(", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedFunctionReturnTypeRegex = new( - @"^\s*(?:(?:static|extern|inline|const|volatile|restrict|_Atomic)\s+)*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*\s*\(", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefParameterTypeRegex = new( - @"(?:\(|,)\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedParameterTypeRegex = new( - @"(?:\(|,)\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefCompoundLiteralTypeRegex = new( - @"\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)\s*\{", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedCompoundLiteralTypeRegex = new( - @"\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)\s*\{", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefTypeofTypeRegex = new( - @"\b(?:typeof|__typeof__|__typeof)\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedTypeofTypeRegex = new( - @"\b(?:typeof|__typeof__|__typeof)\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefTypeofUnqualTypeRegex = new( - @"\b(?:typeof_unqual|__typeof_unqual__|__typeof_unqual)\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedTypeofUnqualTypeRegex = new( - @"\b(?:typeof_unqual|__typeof_unqual__|__typeof_unqual)\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefBuiltinTypesCompatibleFirstTypeRegex = new( - @"\b__builtin_types_compatible_p\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*,", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefBuiltinTypesCompatibleSecondTypeRegex = new( - @"\b__builtin_types_compatible_p\s*\([^,;{}]+,\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedBuiltinTypesCompatibleFirstTypeRegex = new( - @"\b__builtin_types_compatible_p\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?,", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedBuiltinTypesCompatibleSecondTypeRegex = new( - @"\b__builtin_types_compatible_p\s*\([^,;{}]+,\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefGenericAssociationTypeRegex = new( - @"(?:_Generic\s*\([^,;{}]*,|,)\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*(?:\s*(?:const|volatile|restrict|_Atomic))?)*\s*:", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedGenericAssociationTypeRegex = new( - @"(?:_Generic\s*\([^,;{}]*,|,)\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\s*\*(?:\s*(?:const|volatile|restrict|_Atomic))?)*\s*:", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefAtomicTypeRegex = new( - @"\b_Atomic\s*\(\s*(?(?:(?:const|volatile|restrict)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedAtomicTypeRegex = new( - @"\b_Atomic\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefAlignasTypeRegex = new( - @"\b(?:_Alignas|alignas)\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedAlignasTypeRegex = new( - @"\b(?:_Alignas|alignas)\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefFunctionPointerAliasTypeRegex = new( - @"\btypedef\s+(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\(", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedFunctionPointerAliasTypeRegex = new( - @"\btypedef\s+(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\(", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefFunctionPointerDeclarationTypeRegex = new( - @"(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\(", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedFunctionPointerDeclarationTypeRegex = new( - @"(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\(", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefPointerArrayDeclarationTypeRegex = new( - @"(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\[", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedPointerArrayDeclarationTypeRegex = new( - @"(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\(\s*\*\s*[A-Za-z_]\w*\s*\)\s*\[", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefOffsetofTypeRegex = new( - @"\b(?:offsetof|__builtin_offsetof)\s*\(\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*,", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedOffsetofTypeRegex = new( - @"\b(?:offsetof|__builtin_offsetof)\s*\(\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?,", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTypedefVaArgTypeRegex = new( - @"\b(?:va_arg|__builtin_va_arg)\s*\(\s*[^,;{}]+,\s*(?(?:(?:const|volatile|restrict|_Atomic)\s+)*[A-Za-z_]\w*_t\b)(?:\s*\*)*\s*\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CTaggedVaArgTypeRegex = new( - @"\b(?:va_arg|__builtin_va_arg)\s*\(\s*[^,;{}]+,\s*(?(?:struct|enum|union)\s+[A-Za-z_]\w*)\s*(?:\*+\s*)?\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly string[] CVaArgFunctionNames = - { - "va_arg", - "__builtin_va_arg", - }; - private static readonly Regex CppTypeOperandOperatorRegex = new( - @"\b(?:sizeof|alignof)\s*\(\s*(?(?:(?:const|volatile|typename|class|struct|enum)\s+)*(?:[A-Z_]\w*|[A-Za-z_]\w*\s*::\s*[A-Za-z_]\w*)(?:\s*<[^;{}]+>)?(?:\s*[*&])*)\s*\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppTypeIdRegex = new( - @"\btypeid\s*\(\s*(?(?:(?:const|volatile|typename|class|struct|enum)\s+)*(?:[A-Z_]\w*|[A-Za-z_]\w*\s*::\s*[A-Za-z_]\w*)(?:\s*<[^;{}]+>)?(?:\s*[*&])*)\s*\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppDecltypeBraceConstructionRegex = new( - @"\bdecltype\s*\(\s*(?(?:[A-Za-z_]\w*\s*::\s*)*[A-Z_]\w*(?:\s*<[^;{}()]+>)?)\s*\{", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppFactoryTemplateArgumentRegex = new( - @"\b(?:std\s*::\s*)?(?:make_unique|make_shared|make_optional)\s*<(?[^;{}<>]+(?:<[^;{}<>]+>)?[^;{}<>]*)>\s*\(", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppTypeTraitTemplateArgumentRegex = new( - @"\b(?:std\s*::\s*)?(?:is_same|is_base_of|is_convertible|is_constructible|is_assignable|is_invocable)(?:_v)?\s*<(?[^;{}<>]+(?:<[^;{}<>]+>)?[^;{}<>]*)>", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppBraceConstructionRegex = new( - @"(?:=\s*|return\s+|co_return\s+|throw\s+)(?(?:[A-Za-z_]\w*\s*::\s*)*[A-Z_]\w*(?:\s*<[^;{}]+>)?)\s*\{", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppQualifiedTemplateBraceConstructionRegex = new( - @"(?:=\s*|return\s+|co_return\s+|throw\s+)(?:[A-Za-z_]\w*\s*::\s*)+[A-Za-z_]\w*\s*<(?[^;{}]+)>\s*\{", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppUsingAliasTargetRegex = new( - @"\b(?:template\s*<[^>]*>\s*)?using\s+[A-Za-z_]\w*\s*=\s*(?[^;]+);", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppTypedefAliasTargetRegex = new( - @"\btypedef\s+(?![^;]*\()(?.+?)\s+[A-Za-z_]\w*\s*;", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppExplicitTemplateInstantiationRegex = new( - @"\b(?:extern\s+)?template\s+(?:class|struct)\s+(?[^;]+);", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppTemplateIdDeclarationRegex = new( - @"(?(?:[A-Za-z_]\w*\s*::\s*)*[A-Z_]\w*)\s*<(?[^;{}]+)>\s*(?:[*&]\s*)?[A-Za-z_]\w*\s*(?:[=;{,)]|\[[^\]]*\])", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppTemplateParameterDefaultTypeRegex = new( - @"\b(?:typename|class)\s+[A-Za-z_]\w*\s*=\s*(?(?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*(?:\s*<[^,>]+>)?(?:\s*[*&])?)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppQualifiedMemberReceiverRegex = new( - @"(?(?:(?:[A-Za-z_]\w*)\s*::\s*)*[A-Z_]\w*)\s*::\s*[A-Za-z_]\w*", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppPointerToMemberTypeRegex = new( - @"(?(?:(?:[A-Za-z_]\w*)\s*::\s*)*[A-Z_]\w*)\s*::\s*\*", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppTrailingReturnTypeRegex = new( - @"\)\s*->\s*(?(?:(?:const|volatile|typename|class|struct|enum)\s+)*(?:[A-Z_]\w*|[A-Za-z_]\w*\s*::\s*[A-Za-z_]\w*)(?:\s*<[^;{}]+>)?(?:\s*[*&])*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppRequiresConceptTypeRegex = new( - @"\brequires\s+(?(?:(?:[A-Za-z_]\w*)\s*::\s*)*[A-Z_]\w*)\s*<", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppParenthesizedRequiresConceptTypeRegex = new( - @"\brequires\s*\(\s*(?(?:(?:[A-Za-z_]\w*)\s*::\s*)*[A-Z_]\w*)\s*<", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppQualifiedRequiresConceptConstraintRegex = new( - @"\brequires\s*\(?\s*(?(?:(?:[A-Za-z_]\w*)\s*::\s*)+[A-Za-z_]\w*)\s*<(?[^;{}<>]+(?:<[^;{}<>]+>)?[^;{}<>]*)>", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppConceptExpressionTypeRegex = new( - @"(?:=|&&|\|\|)\s*(?(?:(?:[A-Za-z_]\w*)\s*::\s*)*[A-Z_]\w*)\s*<", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppCompoundRequirementConceptRegex = new( - @"->\s*(?(?:(?:[A-Za-z_]\w*)\s*::\s*)*[A-Za-z_]\w*)\s*<(?[^;{}<>]+(?:<[^;{}<>]+>)?[^;{}<>]*)>", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppFriendTypeRegex = new( - @"\bfriend\s+(?:class|struct|union|typename|enum(?:\s+class)?)\s+(?(?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*)\s*;", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppDynamicExceptionSpecRegex = new( - @"\bthrow\s*\(\s*(?(?:(?:[A-Za-z_]\w*\s*::\s*)*[A-Z_]\w*(?:\s*[*&])?(?:\s*,\s*)?)+)\s*\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex CppDeclarationTypeRegex = new( - @"(?(?:(?:const|volatile|static|inline|constexpr|typename|class|struct|enum)\s+)*(?:[A-Z_]\w*|[A-Za-z_]\w*\s*::\s*[A-Za-z_]\w*)(?:\s*<[^;{}]+>)?(?:\s*[*&])*)\s+(?[A-Za-z_]\w*)\s*(?=[,;)=])", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - private static readonly Regex DartCtorRegex = new( - @"\b(?:new|const)\s+(?[A-Z]\w*(?:\.[A-Za-z_]\w*)?)\s*(?:<[^>]+>)?\s*\(", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex DartVariableTypeRegex = new( - @"^\s*(?:(?:final|late|const)\s+)*(?[A-Z]\w*(?:\s*<[^;=]+>)?)\s+[A-Za-z_]\w*\s*(?:=|;)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex DartFunctionSignatureRegex = new( - @"^\s*(?:(?:external|static|abstract)\s+)*(?[A-Z]\w*(?:\s*<[^;{}()]+>)?)\s+[A-Za-z_]\w*\s*\((?[^)]*)\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex DartParameterTypeRegex = new( - @"(?:^|,)\s*(?:(?:required|covariant|final)\s+)*(?[A-Z]\w*(?:\s*<[^,)=]+>)?)\s+[A-Za-z_]\w*", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - private const string VbIdentifierPattern = @"(?:\[[^\]\r\n]+\]|[A-Za-z_]\w*)"; - private const string VbQualifiedIdentifierPattern = @"(?:Global\.)?(?:" + VbIdentifierPattern + @")(?:\.(?:" + VbIdentifierPattern + @"))*"; - private static readonly Regex VbTypeKeywordRegex = new( - @"\b(?:As\s+(?:New\s+)?|New\s+|Inherits\s+|Implements\s+)(?" + VbQualifiedIdentifierPattern + @")", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbGenericArgumentListRegex = new( - @"\(\s*Of\s+(?[^)\r\n]+)\)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbGenericDeclarationOwnerRegex = new( - @"\b(?:Class|Structure|Interface|Delegate|Sub|Function)\s+" + VbIdentifierPattern + @"\s*$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbGenericConstraintRegex = new( - @"^\s*(?" + VbIdentifierPattern + @")\s+As\s+(?.+)$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbNewTypeRegex = new( - @"\bNew\s+(?" + VbQualifiedIdentifierPattern + @")", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbImplementsListRegex = new( - @"\bImplements\s+(?[^\r\n]+)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbImportsListRegex = new( - @"^\s*Imports\s+(?[^\r\n]+)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbCastTypeRegex = new( - @"\b(?:DirectCast|TryCast|CType)\s*\([^,\r\n]+,\s*(?" + VbQualifiedIdentifierPattern + @")", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbGetTypeRegex = new( - @"\bGetType\s*\(\s*(?" + VbQualifiedIdentifierPattern + @")", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbTypeOfRegex = new( - @"\bTypeOf\b.+?\bIs(?:Not)?\s+(?" + VbQualifiedIdentifierPattern + @")", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbNameOfRegex = new( - @"\bNameOf\s*\(\s*(?" + VbQualifiedIdentifierPattern + @")", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbGetXmlNamespaceRegex = new( - @"\bGetXmlNamespace\s*\(\s*(?[A-Za-z_]\w*)\s*\)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbAddressOfRegex = new( - @"\bAddressOf\s+(?" + VbQualifiedIdentifierPattern + @")", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbHandlesTargetRegex = new( - @"(?:\bHandles|,)\s+(?" + VbQualifiedIdentifierPattern + @")", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbAddHandlerRegex = new( - @"\bAddHandler\s+(?" + VbQualifiedIdentifierPattern + @")", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbRemoveHandlerRegex = new( - @"\bRemoveHandler\s+(?" + VbQualifiedIdentifierPattern + @")", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbRaiseEventRegex = new( - @"\bRaiseEvent\s+(?" + VbQualifiedIdentifierPattern + @")", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbCallRegex = new( - @"(?" + VbQualifiedIdentifierPattern + @")\s*\(", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbBareCallRegex = new( - @"^\s*(?:Call\s+)?(?" + VbQualifiedIdentifierPattern + @")(?\s*(?:$|.*))", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbBareMemberCallRegex = new( - @"^\s*\.\s*(?" + VbIdentifierPattern + @")(?\s*(?:$|.*))", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex VbCallByNameRegex = new( - @"\bCallByName\s*\([^,\r\n]+,\s*""(?[^""\r\n]+)""", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - - private static readonly Regex FortranUseRegex = new( - @"^\s*use(?:\s*,\s*(?:intrinsic|non_intrinsic))?(?:\s*::\s*|\s+)(?[A-Za-z_]\w*)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranUseOnlyRegex = new( - @"^\s*use(?:\s*,\s*(?:intrinsic|non_intrinsic))?(?:\s*::\s*|\s+)[A-Za-z_]\w*\s*,\s*only\s*:\s*(?.+)$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranUseRenameListRegex = new( - @"^\s*use(?:\s*,\s*(?:intrinsic|non_intrinsic))?(?:\s*::\s*|\s+)[A-Za-z_]\w*\s*,\s*(?!only\s*:)(?.+)$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranUseAliasRegex = new( - @"(?:^|,)\s*(?[A-Za-z_]\w*)\s*=>\s*[A-Za-z_]\w*", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex FortranUseAliasTargetRegex = new( - @"(?:^|,)\s*[A-Za-z_]\w*\s*=>\s*(?[A-Za-z_]\w*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex FortranImportRegex = new( - @"^\s*import(?:\s*,\s*only)?(?:\s*::\s*|\s*:\s*|\s+)(?.+)$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranIncludeRegex = new( - @"^\s*include\s*['""](?[^'""]+)['""]", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranBlankCommonMemberListRegex = new( - @"^\s*common\s+(?[^/].*)$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranParenthesizedNameListRegex = new( - @"\((?[^()]*)\)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex FortranDataLineRegex = new( - @"^\s*data\s+(?.+)$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranDataObjectGroupRegex = new( - @"(?:^|,)\s*(?[^/]+?)\s*/", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranSaveRegex = new( - @"^\s*save(?:\s*::|\s+)(?.+)$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranSlashGroupNameRegex = new( - @"/\s*(?[A-Za-z_]\w*)\s*/", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex FortranSlashGroupMemberListRegex = new( - @"/\s*[A-Za-z_]\w*\s*/(?[^/]*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex FortranSubmoduleParentRegex = new( - @"^\s*submodule\s*\(\s*(?[A-Za-z_]\w*)(?:\s*:\s*(?[A-Za-z_]\w*))?\s*\)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranExternalRegex = new( - @"^\s*external(?:\s*::)?\s*(?.+)$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranIntrinsicProcedureRegex = new( - @"^\s*intrinsic(?:\s*::)?\s*(?.+)$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranAccessListRegex = new( - @"^\s*(?:public|private)(?:\s*::\s*|\s+)(?.+)$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranFinalizerRegex = new( - @"^\s*final(?:\s*::\s*|\s+)(?.+)$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranSimpleListNameRegex = new( - @"(?:^|,)\s*(?[A-Za-z_]\w*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex FortranTypeRegex = new( - @"\b(?:type|class)\s*\(\s*(?[A-Za-z_]\w*)\s*\)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranTypeGuardRegex = new( - @"\b(?:type|class)\s+is\s*\(\s*(?[A-Za-z_]\w*)\s*\)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranExtendsRegex = new( - @"\bextends\s*\(\s*(?[A-Za-z_]\w*)\s*\)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranProcedureTypeRegex = new( - @"\bprocedure\s*\(\s*(?[A-Za-z_]\w*)\s*\)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranAllocateTypeSpecRegex = new( - @"\ballocate\s*\(\s*(?[A-Za-z_]\w*)\s*::", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranAllocateListRegex = new( - @"^\s*allocate\s*\((?.*)\)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranAllocateSourceKeywordRegex = new( - @"\b(?:source|mold)\s*=\s*(?[A-Za-z_]\w*)\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranAllocationStatusKeywordRegex = new( - @"\b(?:stat|errmsg)\s*=\s*(?[A-Za-z_]\w*)\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranDeallocateListRegex = new( - @"^\s*deallocate\s*\((?.*)\)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranIntrinsicKeywordKindRegex = new( - @"\b(?:integer|real|complex|logical|character)\s*\([^)\r\n]*\bkind\s*=\s*(?[A-Za-z_]\w*)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranIntrinsicPositionalKindRegex = new( - @"\b(?:integer|real|complex|logical)\s*\(\s*(?[A-Za-z_]\w*)\s*\)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranBindingTargetListRegex = new( - @"=>.*$", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex FortranBindingTargetRegex = new( - @"(?:=>|,)\s*(?:(?:[A-Za-z_]\w*)\s*=>\s*)?(?[A-Za-z_]\w*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex FortranPointerAssignmentRegex = new( - @"^\s*(?:[A-Za-z_]\w*(?:\s*%\s*[A-Za-z_]\w*)*)\s*=>\s*(?[A-Za-z_]\w*)\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranAssociateLineRegex = new( - @"^\s*associate\s*\((?.+)\)\s*$", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex FortranAssociateTargetRegex = new( - @"(?:^|,)\s*[A-Za-z_]\w*\s*=>\s*(?[A-Za-z_]\w*)\b", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex FortranCallRegex = new( - @"^\s*call\s+(?:(?:[A-Za-z_]\w*)\s*%\s*)*(?[A-Za-z_]\w*)\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - - private static readonly Regex PascalUsesRegex = new( - @"^\s*uses\s+(?.+?)(?:;|$)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex PascalTypeAfterColonRegex = new( - @":\s*(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex PascalClassBaseRegex = new( - @"=\s*(?:class|interface|object)\s*\((?[^)]+)\)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly Regex PascalBareCallRegex = new( - @"^\s*(?[A-Za-z_]\w*)\s*;", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - private static readonly Regex ObjCMessageRegex = new( - @"\[\s*(?[A-Za-z_]\w*)\s+(?[A-Za-z_]\w*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex ObjCInterfaceBaseRegex = new( - @"^\s*@(?:interface|implementation)\s+[A-Za-z_]\w+(?:\s*\([^)]+\))?\s*:\s*(?[A-Za-z_]\w*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex ObjCProtocolListRegex = new( - @"<(?[A-Za-z_]\w*(?:\s*,\s*[A-Za-z_]\w*)*)>", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex ObjCDeclTypeRegex = new( - @"(?[A-Z]\w*)\s*\*+\s*[A-Za-z_]\w*", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex ObjCSelectorRegex = new( - @"@selector\s*\(\s*(?[A-Za-z_]\w*:?)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - private static readonly Regex HaskellSignatureRegex = new( - @"^\s*[a-z_]\w*\s*::\s*(?.+)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex HaskellSpaceCallRegex = new( - @"^\s*(?[a-z_]\w*)\s+(?=(?:[A-Za-z_(]))", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex HaskellDefinitionRegex = new( - @"^\s*(?[a-z_]\w*)\b.*=", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - private static readonly Regex ElixirImportRegex = new( - @"^\s*(?:alias|import|require|use)\s+(?[A-Z]\w*(?:\.[A-Z]\w*)*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex ElixirBehaviourRegex = new( - @"^\s*@(?:behaviour|impl)\s+(?[A-Z]\w*(?:\.[A-Z]\w*)*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex ElixirParenlessCallRegex = new( - @"(?[a-z_]\w*[?!]?)\s+(?=(?:[A-Za-z_:@\[""']))", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - private static readonly Regex SmalltalkClassDeclarationRegex = new( - @"^\s*(?:(?:[A-Za-z_]\w*)\s+subclass:|Class\s+named:|Object\s+subclass:)\s*#", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex SmalltalkMessageSendRegex = new( - @"(?[A-Za-z_]\w*)\s+(?[a-z]\w*:?)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex SmalltalkMethodDefinitionRegex = new( - @">>\s*(?[A-Za-z_]\w*:?)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - private static readonly Regex RazorComponentTagRegex = new( - @"<(?[A-Z][A-Za-z0-9_]*(?:\.[A-Za-z_]\w*)*)(?=[\s>/])", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex RazorDirectiveTypeRegex = new( - @"^\s*@(?:inherits|implements|model)\s+(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex RazorAttributeTypeRegex = new( - @"^\s*@attribute\s+\[\s*(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex RazorInjectRegex = new( - @"^\s*@inject\s+(?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s+[A-Za-z_]\w*", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - private static readonly Regex RazorEventHandlerRegex = new( - @"@on[A-Za-z_]\w*\s*=\s*""@?(?[A-Za-z_]\w*)""", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - public static void EmitTypePositionReferences( string language, string preparedLine, @@ -1097,626 +604,4 @@ private static string GetPreviousSimpleWord(string line, int index) return line[(cursor + 1)..end]; } - private static void EmitPascalTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - SymbolRecord? container) - { - if (StartsWithKeywordIgnoringLeadingWhitespace(preparedLine, "uses")) - { - var usesMatch = PascalUsesRegex.Match(preparedLine); - if (usesMatch.Success) - EmitCommaSeparatedNames(usesMatch.Groups["list"].Value, usesMatch.Groups["list"].Index, "pascal", references, seen, fileId, context, lineNumber, container); - } - - var hasPascalBaseMarker = preparedLine.IndexOf('=') >= 0 - && preparedLine.IndexOf('(') >= 0 - && preparedLine.IndexOf(')') >= 0 - && (ContainsKeywordIgnoringCase(preparedLine, "class") - || ContainsKeywordIgnoringCase(preparedLine, "interface") - || ContainsKeywordIgnoringCase(preparedLine, "object")); - if (hasPascalBaseMarker) - { - foreach (Match match in PascalClassBaseRegex.Matches(preparedLine)) - EmitCommaSeparatedNames(match.Groups["bases"].Value, match.Groups["bases"].Index, "pascal", references, seen, fileId, context, lineNumber, resolveContainerForColumn(match.Groups["bases"].Index)); - } - - if (preparedLine.IndexOf(':') < 0) - return; - - foreach (Match match in PascalTypeAfterColonRegex.Matches(preparedLine)) - { - if (!IsPascalColonTypeReferenceContext(preparedLine, lineNumber, container)) - continue; - - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), "pascal"); - } - } - - private static bool IsPascalColonTypeReferenceContext(string preparedLine, int lineNumber, SymbolRecord? container) - { - var trimmed = preparedLine.TrimStart(); - if (container?.Kind != "function" - || !container.BodyStartLine.HasValue - || lineNumber < container.BodyStartLine.Value) - { - return true; - } - - return StartsWithPascalDeclarationKeyword(trimmed); - } - - private static bool StartsWithPascalDeclarationKeyword(string trimmedLine) - { - foreach (var keyword in PascalDeclarationKeywords) - { - if (trimmedLine.StartsWith(keyword, StringComparison.OrdinalIgnoreCase) - && (trimmedLine.Length == keyword.Length || !IsSimpleIdentifierPart(trimmedLine[keyword.Length]))) - { - return true; - } - } - - return false; - } - - private static void EmitObjCTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - SymbolRecord? container) - { - if (StartsWithCharIgnoringLeadingWhitespace(preparedLine, '@') && preparedLine.IndexOf(':') >= 0) - { - foreach (Match match in ObjCInterfaceBaseRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddReference(references, seen, fileId, group.Value, group.Index, "type_reference", context, lineNumber, container); - } - } - - if (preparedLine.IndexOf('<') >= 0 && preparedLine.IndexOf('>') >= 0) - { - foreach (Match match in ObjCProtocolListRegex.Matches(preparedLine)) - EmitCommaSeparatedNames(match.Groups["list"].Value, match.Groups["list"].Index, "objc", references, seen, fileId, context, lineNumber, container); - } - - if (preparedLine.IndexOf('*') < 0) - return; - - foreach (Match match in ObjCDeclTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), "objc"); - } - } - - private static void EmitHaskellTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("::", StringComparison.Ordinal) < 0) - return; - - var match = HaskellSignatureRegex.Match(preparedLine); - if (!match.Success) - return; - - var group = match.Groups["types"]; - ReferenceExtractor.AddTypeExpressionSegments( - references, - seen, - fileId, - group.Value, - group.Index, - context, - lineNumber, - container, - "haskell", - BuildHaskellIgnoredTypeVariables(group.Value)); - } - - private static IReadOnlySet? BuildHaskellIgnoredTypeVariables(string expression) - { - HashSet? ignored = null; - for (var cursor = 0; cursor < expression.Length; cursor++) - { - if (!IsSimpleIdentifierPart(expression[cursor])) - continue; - - var start = cursor; - while (cursor < expression.Length && IsSimpleIdentifierPart(expression[cursor])) - cursor++; - - if (char.IsLower(expression[start])) - { - ignored ??= new HashSet(StringComparer.Ordinal); - ignored.Add(expression[start..cursor]); - } - - cursor--; - } - - return ignored; - } - - private static void EmitElixirTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - var hasImportMarker = StartsWithOrdinalKeywordIgnoringLeadingWhitespace(preparedLine, "alias") - || StartsWithOrdinalKeywordIgnoringLeadingWhitespace(preparedLine, "import") - || StartsWithOrdinalKeywordIgnoringLeadingWhitespace(preparedLine, "require") - || StartsWithOrdinalKeywordIgnoringLeadingWhitespace(preparedLine, "use"); - if (hasImportMarker) - { - foreach (var match in EnumerateMatches(ElixirImportRegex, preparedLine)) - ReferenceExtractor.AddReference(references, seen, fileId, match, "type_reference", context, lineNumber, container); - } - - var hasBehaviourMarker = StartsWithCharIgnoringLeadingWhitespace(preparedLine, '@') - && (ContainsOrdinalKeyword(preparedLine, "behaviour") - || ContainsOrdinalKeyword(preparedLine, "impl")); - if (hasBehaviourMarker) - { - foreach (var match in EnumerateMatches(ElixirBehaviourRegex, preparedLine)) - ReferenceExtractor.AddReference(references, seen, fileId, match, "type_reference", context, lineNumber, container); - } - } - - private static bool IsIdentifierAt(string line, int index, string identifier) - { - if (index < 0 || index + identifier.Length > line.Length) - return false; - if (string.CompareOrdinal(line, index, identifier, 0, identifier.Length) != 0) - return false; - if (index > 0 && IsSimpleIdentifierPart(line[index - 1])) - return false; - - var after = index + identifier.Length; - return after >= line.Length || !IsSimpleIdentifierPart(line[after]); - } - - private static bool IsSimpleIdentifierPart(char ch) => - ch == '_' || char.IsLetterOrDigit(ch); - - private static void EmitFortranCallReferences(string preparedLine, Action addCallLikeReference) - { - if (!StartsWithKeywordIgnoringLeadingWhitespace(preparedLine, "call")) - return; - - foreach (Match match in FortranCallRegex.Matches(preparedLine)) - addCallLikeReference(match.Groups["name"].Value, match.Groups["name"].Index); - } - - private static void EmitPascalCallReferences(string preparedLine, Action addCallLikeReference, IReadOnlySet? definitionNames) - { - if (preparedLine.IndexOf(';') < 0) - return; - - var match = PascalBareCallRegex.Match(preparedLine); - if (!match.Success) - return; - - var name = match.Groups["name"].Value; - if (definitionNames?.Contains(name) == true) - return; - - addCallLikeReference(name, match.Groups["name"].Index); - } - - private static void EmitObjCMessageReferences( - string preparedLine, - Action addCallLikeReference, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (preparedLine.IndexOf('[') >= 0) - { - foreach (Match match in ObjCMessageRegex.Matches(preparedLine)) - { - var receiver = match.Groups["receiver"]; - var selector = match.Groups["name"]; - if (char.IsUpper(receiver.Value[0]) && selector.Value is "alloc" or "new") - { - ReferenceExtractor.AddReference(references, seen, fileId, receiver.Value, receiver.Index, "instantiate", context, lineNumber, resolveContainerForColumn(receiver.Index)); - } - - addCallLikeReference(selector.Value, selector.Index); - } - } - - if (preparedLine.IndexOf("@selector", StringComparison.Ordinal) >= 0 - && preparedLine.IndexOf('(') >= 0) - { - foreach (Match match in ObjCSelectorRegex.Matches(preparedLine)) - addCallLikeReference(match.Groups["name"].Value.TrimEnd(':'), match.Groups["name"].Index); - } - } - - private static void EmitHaskellSpaceCallReferences(string preparedLine, Action addCallLikeReference, IReadOnlySet? definitionNames) - { - if (!ContainsWhitespace(preparedLine)) - return; - - string? definitionName = null; - var scanStart = 0; - var scanText = preparedLine; - if (preparedLine.IndexOf('=') >= 0) - { - var definitionMatch = HaskellDefinitionRegex.Match(preparedLine); - if (definitionMatch.Success) - { - definitionName = definitionMatch.Groups["name"].Value; - var equalsIndex = preparedLine.IndexOf('='); - if (equalsIndex >= 0) - { - scanStart = equalsIndex + 1; - scanText = preparedLine[scanStart..]; - } - } - } - - foreach (Match match in HaskellSpaceCallRegex.Matches(scanText)) - { - var name = match.Groups["name"].Value; - if (definitionNames?.Contains(name) == true || string.Equals(name, definitionName, StringComparison.Ordinal)) - continue; - addCallLikeReference(name, scanStart + match.Groups["name"].Index); - } - } - - private static void EmitElixirParenlessCallReferences(string preparedLine, Action addCallLikeReference, IReadOnlySet? definitionNames) - { - if (!ContainsWhitespace(preparedLine)) - return; - - foreach (Match match in ElixirParenlessCallRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (definitionNames?.Contains(name) == true) - continue; - addCallLikeReference(name, match.Groups["name"].Index); - } - } - - private static void EmitSmalltalkMessageReferences(string preparedLine, Action addCallLikeReference, IReadOnlySet? definitionNames) - { - if (!ContainsWhitespace(preparedLine)) - return; - - var isDefinitionLine = preparedLine.IndexOf(">>", StringComparison.Ordinal) >= 0 - && SmalltalkMethodDefinitionRegex.IsMatch(preparedLine); - var hasClassDeclarationLiteralMarker = preparedLine.IndexOf('#') >= 0 - && (preparedLine.IndexOf("subclass:", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("Class", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("Object", StringComparison.Ordinal) >= 0); - if (isDefinitionLine || (hasClassDeclarationLiteralMarker && SmalltalkClassDeclarationRegex.IsMatch(preparedLine))) - return; - - var consumedUntil = 0; - foreach (Match match in SmalltalkMessageSendRegex.Matches(preparedLine)) - { - if (match.Index < consumedUntil) - continue; - - var selectorGroup = match.Groups["selector"]; - var name = ReadSmalltalkSelector(preparedLine, selectorGroup.Index, out var selectorEndIndex); - consumedUntil = Math.Max(consumedUntil, selectorEndIndex); - if (definitionNames?.Contains(name) == true) - continue; - addCallLikeReference(name, selectorGroup.Index); - } - } - - private static bool ContainsWhitespace(string value) - { - foreach (var ch in value) - { - if (char.IsWhiteSpace(ch)) - return true; - } - - return false; - } - - private static string ReadSmalltalkSelector(string line, int selectorIndex, out int endIndex) - { - if (!TryReadSmalltalkSelectorPart(line, selectorIndex, out var firstPart, out var cursor)) - { - endIndex = selectorIndex; - return string.Empty; - } - - if (!firstPart.EndsWith(':')) - { - endIndex = cursor; - return firstPart; - } - - var selector = firstPart; - while (true) - { - var argumentStart = SkipWhitespace(line, cursor); - if (argumentStart >= line.Length || !IsIdentifierStart(line[argumentStart])) - break; - - var argumentEnd = argumentStart + 1; - while (argumentEnd < line.Length && IsSimpleIdentifierPart(line[argumentEnd])) - argumentEnd++; - - var nextSelectorStart = SkipWhitespace(line, argumentEnd); - if (!TryReadSmalltalkSelectorPart(line, nextSelectorStart, out var nextPart, out var nextEnd) - || !nextPart.EndsWith(':')) - { - break; - } - - selector += nextPart; - cursor = nextEnd; - } - - endIndex = cursor; - return selector; - } - - private static bool TryReadSmalltalkSelectorPart(string line, int start, out string part, out int end) - { - part = string.Empty; - end = start; - if (start >= line.Length || !IsIdentifierStart(line[start])) - return false; - - end = start + 1; - while (end < line.Length && IsSimpleIdentifierPart(line[end])) - end++; - if (end < line.Length && line[end] == ':') - end++; - - part = line[start..end]; - return true; - } - - private static void EmitCommaSeparatedNames( - string list, - int listStart, - string language, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(list)) - { - var leading = ReferenceExtractor.CountLeadingWhitespace(list, segmentStart, segmentLength); - var trimmedLength = segmentLength - leading; - while (trimmedLength > 0 && char.IsWhiteSpace(list[segmentStart + leading + trimmedLength - 1])) - trimmedLength--; - if (trimmedLength == 0) - continue; - var expressionStart = segmentStart + leading; - var raw = list.Substring(expressionStart, trimmedLength); - if (language == "vb") - { - var equalsIndex = list.IndexOf('=', segmentStart, segmentLength); - if (equalsIndex >= 0) - { - var rhsStart = equalsIndex + 1; - var rhsLength = segmentStart + segmentLength - rhsStart; - var rhsLeading = ReferenceExtractor.CountLeadingWhitespace(list, rhsStart, rhsLength); - expressionStart = rhsStart + rhsLeading; - var rhsTrimmedLength = rhsLength - rhsLeading; - while (rhsTrimmedLength > 0 && char.IsWhiteSpace(list[expressionStart + rhsTrimmedLength - 1])) - rhsTrimmedLength--; - if (rhsTrimmedLength == 0) - continue; - - raw = list.Substring(expressionStart, rhsTrimmedLength); - } - } - - var name = GetLastWhitespaceSeparatedToken(raw); - var offset = list.IndexOf(name, expressionStart, StringComparison.Ordinal); - if (offset < 0) - offset = expressionStart; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, name, listStart + offset, context, lineNumber, container, language); - } - } - - private static string GetLastWhitespaceSeparatedToken(string value) - { - var end = value.Length; - while (end > 0 && (value[end - 1] == ' ' || value[end - 1] == '\t')) - end--; - var start = end; - while (start > 0 && value[start - 1] != ' ' && value[start - 1] != '\t') - start--; - - return start == 0 && end == value.Length ? value : value[start..end]; - } - - private static void EmitVbGenericConstraintReferences( - string list, - int listStart, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var ignoredSegments = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "As", "Class", "New", "Structure", - }; - - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(list)) - { - var segment = list.Substring(segmentStart, segmentLength); - var match = VbGenericConstraintRegex.Match(segment); - if (match.Success) - { - ignoredSegments.Add(match.Groups["param"].Value); - ignoredSegments.Add(NormalizeVbIdentifierSegment(match.Groups["param"].Value)); - } - } - - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(list)) - { - var segment = list.Substring(segmentStart, segmentLength); - var match = VbGenericConstraintRegex.Match(segment); - if (!match.Success) - continue; - - var constraintGroup = match.Groups["constraint"]; - // The generic-list regex is shallow; skip nested constraints rather than emit type parameters as concrete types. - if (constraintGroup.Value.Contains("(Of", StringComparison.OrdinalIgnoreCase)) - continue; - - var absoluteConstraintStart = listStart + segmentStart + constraintGroup.Index; - ReferenceExtractor.AddTypeExpressionSegments( - references, - seen, - fileId, - constraintGroup.Value, - absoluteConstraintStart, - context, - lineNumber, - resolveContainerForColumn(absoluteConstraintStart), - "vb", - ignoredSegments); - } - } - - private static string StripCppAccessPrefix(string value) - { - var text = value.Trim(); - bool removed; - do - { - removed = false; - foreach (var prefix in CppAccessPrefixes) - { - if (text.StartsWith(prefix, StringComparison.Ordinal)) - { - text = text[prefix.Length..].TrimStart(); - removed = true; - } - } - } while (removed); - - return text; - } - - private static string LastCppQualifiedSegment(string value) - { - var text = value.Trim(); - var genericIndex = text.IndexOf('<'); - if (genericIndex >= 0) - text = text[..genericIndex].TrimEnd(); - var separator = text.LastIndexOf("::", StringComparison.Ordinal); - return separator >= 0 ? text[(separator + 2)..].Trim() : text; - } - - private static bool ContainsAsciiUppercase(string value) - { - for (var i = 0; i < value.Length; i++) - { - var c = value[i]; - if (c is >= 'A' and <= 'Z') - return true; - } - - return false; - } - - private static bool IsCppTemplateDeclarationOrSpecializationLine(string line, int matchIndex) - { - var prefix = line[..Math.Clamp(matchIndex, 0, line.Length)].TrimStart(); - return prefix.StartsWith("template", StringComparison.Ordinal) - || prefix.StartsWith("export template", StringComparison.Ordinal); - } - - private static string LastQualifiedSegment(string value) - { - var dot = value.LastIndexOf('.'); - return dot >= 0 && dot + 1 < value.Length ? value[(dot + 1)..] : value; - } - - private static string NormalizeVbIdentifierSegment(string value) - { - var trimmed = value.Trim(); - if (trimmed.Length >= 2 && trimmed[0] == '[' && trimmed[^1] == ']') - return trimmed[1..^1]; - - return trimmed; - } - - private static string LastPathSegment(string value) - { - var slash = value.LastIndexOf('/'); - return slash >= 0 && slash + 1 < value.Length ? value[(slash + 1)..] : value; - } - - private static int LastWhitespaceSeparatedTokenStart(string value) - { - var end = value.Length - 1; - while (end >= 0 && char.IsWhiteSpace(value[end])) - end--; - if (end < 0) - return -1; - - var start = end; - while (start >= 0 && !char.IsWhiteSpace(value[start])) - start--; - return start + 1; - } - - private static IEnumerable EnumerateMatches(Regex regex, string input) - { - foreach (Match match in BoundedRegex.EnumerateMatches(regex, input)) - yield return match; - } - - private static void MaskRange(char[] chars, int start, int end) - { - for (var i = start; i < end && i < chars.Length; i++) - chars[i] = ' '; - } - - private static int SkipWhitespace(string line, int start) - { - while (start < line.Length && char.IsWhiteSpace(line[start])) - start++; - return start; - } - - private static bool IsIdentifierStart(char ch) => - ch == '_' || char.IsLetter(ch); } From ae90e7531efd6bf0efff07422f65a094fcd0d0ab Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:32:27 +0900 Subject: [PATCH 048/101] Split JVM and scripting reference extractors --- .../JavaReferenceExtractor.Modules.cs | 149 ++++ .../Languages/JavaReferenceExtractor.Types.cs | 423 +++++++++++ .../Languages/JavaReferenceExtractor.cs | 556 +------------- .../KotlinReferenceExtractor.Constructors.cs | 189 +++++ .../KotlinReferenceExtractor.Types.cs | 492 +++++++++++++ .../Languages/KotlinReferenceExtractor.cs | 665 +---------------- .../PhpReferenceExtractor.LanguageTypes.cs | 567 ++++++++++++++ .../PhpReferenceExtractor.Members.cs | 141 ++++ .../Languages/PhpReferenceExtractor.cs | 692 +----------------- .../PythonReferenceExtractor.RuntimeTypes.cs | 370 ++++++++++ ...ythonReferenceExtractor.TypingFactories.cs | 281 +++++++ .../Languages/PythonReferenceExtractor.cs | 633 ---------------- 12 files changed, 2615 insertions(+), 2543 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.Modules.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.Types.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.Constructors.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.Types.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/PhpReferenceExtractor.LanguageTypes.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/PhpReferenceExtractor.Members.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/PythonReferenceExtractor.RuntimeTypes.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/PythonReferenceExtractor.TypingFactories.cs diff --git a/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.Modules.cs b/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.Modules.cs new file mode 100644 index 000000000..2216acc69 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.Modules.cs @@ -0,0 +1,149 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class JavaReferenceExtractor +{ + public static void EmitMethodReferenceReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + => JvmMethodReferenceExtractor.EmitMethodReferenceReferences( + "java", + preparedLine, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + + public static void EmitDotClassTypeLiteralReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + foreach (Match match in DotClassArgRegex.Matches(preparedLine)) + { + var argGroup = match.Groups["arg"]; + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + argGroup.Value, + argGroup.Index, + context, + lineNumber, + container, + "java"); + } + } + + public static void EmitModuleDirectiveReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + EmitModuleDirectiveReference( + preparedLine, + ModuleRequiresDirectiveReferenceRegex, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + + EmitModuleDirectiveReference( + preparedLine, + ModuleUsesDirectiveReferenceRegex, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + + foreach (Match match in BoundedRegex.EnumerateMatches(ModuleProvidesDirectiveReferenceRegex, preparedLine)) + { + var serviceGroup = match.Groups["service"]; + ReferenceExtractor.AddTypeReferenceSegment( + references, + seen, + fileId, + serviceGroup.Value, + serviceGroup.Index, + context, + lineNumber, + resolveContainerForColumn(serviceGroup.Index), + "java"); + + var implementationsGroup = match.Groups["implementations"]; + var implementations = implementationsGroup.ValueSpan; + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(implementations)) + { + var segmentLeading = ReferenceExtractor.CountLeadingWhitespace(implementations, segmentStart, segmentLength); + var rawSegmentLength = segmentLength - segmentLeading; + while (rawSegmentLength > 0 && char.IsWhiteSpace(implementations[segmentStart + segmentLeading + rawSegmentLength - 1])) + rawSegmentLength--; + if (rawSegmentLength == 0) + continue; + + var rawSegment = implementations.Slice(segmentStart + segmentLeading, rawSegmentLength); + var absoluteStart = implementationsGroup.Index + + segmentStart + + segmentLeading; + ReferenceExtractor.AddTypeReferenceSegment( + references, + seen, + fileId, + rawSegment.ToString(), + absoluteStart, + context, + lineNumber, + resolveContainerForColumn(absoluteStart), + "java"); + } + } + } + + private static void EmitModuleDirectiveReference( + string preparedLine, + Regex regex, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + foreach (Match match in BoundedRegex.EnumerateMatches(regex, preparedLine)) + { + var nameGroup = match.Groups["name"]; + ReferenceExtractor.AddTypeReferenceSegment( + references, + seen, + fileId, + nameGroup.Value, + nameGroup.Index, + context, + lineNumber, + resolveContainerForColumn(nameGroup.Index), + "java"); + } + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.Types.cs b/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.Types.cs new file mode 100644 index 000000000..97cdd8305 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.Types.cs @@ -0,0 +1,423 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class JavaReferenceExtractor +{ + public static void EmitTypePositionReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + SymbolRecord? container) + { + var genericParameterNames = CollectGenericParameterNamesForDeclaration(preparedLine); + EmitKeywordTypeListReferences( + preparedLine, + "extends", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + genericParameterNames); + EmitKeywordTypeListReferences( + preparedLine, + "implements", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + genericParameterNames); + EmitKeywordTypeListReferences( + preparedLine, + "permits", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + genericParameterNames); + EmitGenericBoundReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitThrowsReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, genericParameterNames); + ReferenceExtractor.EmitDeclarationTypeReferences( + "java", + preparedLine, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + genericParameterNames); + + foreach (Match match in InstanceofRegex.Matches(preparedLine)) + { + var typeGroup = match.Groups["type"]; + ReferenceExtractor.AddTypeExpressionSegments( + references, + seen, + fileId, + typeGroup.Value, + typeGroup.Index, + context, + lineNumber, + resolveContainerForColumn(typeGroup.Index), + "java"); + } + } + + private static void EmitKeywordTypeListReferences( + string line, + string keyword, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlySet? ignoredSegments = null) + { + int keywordIndex = ReferenceExtractor.FindTopLevelKeyword(line, keyword); + if (keywordIndex < 0) + return; + + int listStart = keywordIndex + keyword.Length; + while (listStart < line.Length && char.IsWhiteSpace(line[listStart])) + listStart++; + + var remaining = line.AsSpan(listStart); + int listEnd = ReferenceExtractor.FindJavaTypeListTerminator(remaining); + if (listEnd < 0) + listEnd = remaining.Length; + var typeList = remaining[..listEnd]; + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeList)) + { + var leading = ReferenceExtractor.CountLeadingWhitespace(typeList, segmentStart, segmentLength); + var trimmedLength = segmentLength - leading; + while (trimmedLength > 0 && char.IsWhiteSpace(typeList[segmentStart + leading + trimmedLength - 1])) + trimmedLength--; + if (trimmedLength == 0) + continue; + var absoluteStart = listStart + segmentStart + leading; + var rawSegment = typeList.Slice(segmentStart + leading, trimmedLength); + ReferenceExtractor.AddTypeExpressionSegments( + references, + seen, + fileId, + rawSegment.ToString(), + absoluteStart, + context, + lineNumber, + resolveContainerForColumn(absoluteStart), + "java", + ignoredSegments: ignoredSegments); + } + } + + private static void EmitGenericBoundReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + EmitCallableGenericBoundReferences(line, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + EmitNamedTypeGenericBoundReferences(line, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static IReadOnlySet CollectGenericParameterNamesForDeclaration(string line) + { + if (ReferenceExtractor.TryFindCallableParameterList(line, "java", out var callableNameStart, out _, out _)) + { + var headerEnd = callableNameStart; + if (ReferenceExtractor.TryGetCallableReturnTypeSpan(line, callableNameStart, "java", out var typeStart, out _)) + headerEnd = typeStart; + + if (headerEnd > 0) + return CollectGenericParameterNamesFromHeader(line.Substring(0, headerEnd)); + } + + var tokens = ReferenceExtractor.GetTopLevelTokenSpans(line); + if (tokens.Count < 2) + return EmptyGenericParameterNames; + + for (int i = 0; i < tokens.Count; i++) + { + if (!IsNamedTypeKeyword(line.AsSpan(tokens[i].Start, tokens[i].Length))) + continue; + var nameIndex = i + 1; + if (nameIndex >= tokens.Count) + return EmptyGenericParameterNames; + return CollectGenericParameterNamesFromHeader(line.Substring(tokens[nameIndex].Start, tokens[nameIndex].Length)); + } + + return EmptyGenericParameterNames; + } + + private static IReadOnlySet CollectGenericParameterNamesFromHeader(string header) + { + int openAngle = header.IndexOf('<'); + if (openAngle < 0) + return EmptyGenericParameterNames; + + int closeAngle = ReferenceExtractor.FindMatchingChar(header, openAngle, '<', '>'); + if (closeAngle < 0) + return EmptyGenericParameterNames; + + return CollectGenericParameterNames(header.Substring(openAngle + 1, closeAngle - openAngle - 1)); + } + + private static void EmitCallableGenericBoundReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + if (!ReferenceExtractor.TryFindCallableParameterList(line, "java", out var callableNameStart, out _, out _)) + return; + + var headerEnd = callableNameStart; + if (ReferenceExtractor.TryGetCallableReturnTypeSpan(line, callableNameStart, "java", out var typeStart, out _)) + headerEnd = typeStart; + + if (headerEnd <= 0) + return; + + EmitGenericBoundReferencesFromHeader( + line.Substring(0, headerEnd), + 0, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + + private static void EmitNamedTypeGenericBoundReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var tokens = ReferenceExtractor.GetTopLevelTokenSpans(line); + if (tokens.Count < 2) + return; + + int keywordIndex = -1; + int nameIndex = -1; + for (int i = 0; i < tokens.Count; i++) + { + if (IsNamedTypeKeyword(line.AsSpan(tokens[i].Start, tokens[i].Length))) + { + keywordIndex = i; + nameIndex = i + 1; + break; + } + } + + if (keywordIndex < 0 || nameIndex < 0 || nameIndex >= tokens.Count) + return; + + var nameToken = line.AsSpan(tokens[nameIndex].Start, tokens[nameIndex].Length); + EmitGenericBoundReferencesFromHeader( + nameToken, + tokens[nameIndex].Start, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + + private static void EmitGenericBoundReferencesFromHeader( + ReadOnlySpan header, + int headerStartInLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + int openAngle = header.IndexOf('<'); + if (openAngle < 0) + return; + + int closeAngle = ReferenceExtractor.FindMatchingChar(header, openAngle, '<', '>'); + if (closeAngle < 0) + return; + + var parameterClauseText = header.Slice(openAngle + 1, closeAngle - openAngle - 1); + var genericParameterNames = CollectGenericParameterNames(parameterClauseText); + + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(parameterClauseText)) + { + var parameterLeading = ReferenceExtractor.CountLeadingWhitespace(parameterClauseText, segmentStart, segmentLength); + var parameterLength = segmentLength - parameterLeading; + while (parameterLength > 0 && char.IsWhiteSpace(parameterClauseText[segmentStart + parameterLeading + parameterLength - 1])) + parameterLength--; + if (parameterLength == 0) + continue; + + var rawParameter = parameterClauseText.Slice(segmentStart + parameterLeading, parameterLength); + int extendsIndex = ReferenceExtractor.FindTopLevelKeyword(rawParameter, "extends"); + if (extendsIndex < 0) + continue; + + var boundsStart = extendsIndex + "extends".Length; + var boundsLeading = ReferenceExtractor.CountLeadingWhitespace(rawParameter, boundsStart, rawParameter.Length - boundsStart); + var boundsLength = rawParameter.Length - boundsStart - boundsLeading; + while (boundsLength > 0 && char.IsWhiteSpace(rawParameter[boundsStart + boundsLeading + boundsLength - 1])) + boundsLength--; + if (boundsLength == 0) + continue; + + var boundsText = rawParameter.Slice(boundsStart + boundsLeading, boundsLength); + foreach (var (boundStart, boundLength) in ReferenceExtractor.SplitTopLevelAmpersandSpans(boundsText)) + { + var boundLeading = ReferenceExtractor.CountLeadingWhitespace(boundsText, boundStart, boundLength); + var rawBoundLength = boundLength - boundLeading; + while (rawBoundLength > 0 && char.IsWhiteSpace(boundsText[boundStart + boundLeading + rawBoundLength - 1])) + rawBoundLength--; + if (rawBoundLength == 0) + continue; + + var rawBound = boundsText.Slice(boundStart + boundLeading, rawBoundLength).ToString(); + var absoluteStart = headerStartInLine + openAngle + 1 + segmentStart + extendsIndex + "extends".Length + boundStart + boundLeading; + ReferenceExtractor.AddTypeExpressionSegments( + references, + seen, + fileId, + rawBound, + absoluteStart, + context, + lineNumber, + resolveContainerForColumn(absoluteStart), + "java", + genericParameterNames); + } + } + } + + private static bool IsNamedTypeKeyword(ReadOnlySpan token) => + token is "class" or "interface" or "enum" or "record"; + + private static IReadOnlySet CollectGenericParameterNames(ReadOnlySpan parameterClause) + { + HashSet? names = null; + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(parameterClause)) + { + var parameterLeading = ReferenceExtractor.CountLeadingWhitespace(parameterClause, segmentStart, segmentLength); + var parameterLength = segmentLength - parameterLeading; + while (parameterLength > 0 && char.IsWhiteSpace(parameterClause[segmentStart + parameterLeading + parameterLength - 1])) + parameterLength--; + if (parameterLength == 0) + continue; + + var rawParameter = parameterClause.Slice(segmentStart + parameterLeading, parameterLength); + int extendsIndex = ReferenceExtractor.FindTopLevelKeyword(rawParameter, "extends"); + var nameFragment = extendsIndex >= 0 ? rawParameter[..extendsIndex] : rawParameter; + if (TryReadGenericParameterName(nameFragment, out var name)) + (names ??= new HashSet(StringComparer.Ordinal)).Add(name); + } + + return names ?? EmptyGenericParameterNames; + } + + private static bool TryReadGenericParameterName(ReadOnlySpan text, out string name) + { + name = string.Empty; + int i = 0; + while (i < text.Length) + { + while (i < text.Length && char.IsWhiteSpace(text[i])) + i++; + if (i >= text.Length) + return false; + if (text[i] == '@') + { + i = ReferenceExtractor.SkipJavaAnnotation(text, i); + continue; + } + break; + } + + int start = i; + if (start >= text.Length || !ReferenceExtractor.IsJavaIdentifierPart(text[start])) + return false; + + i++; + while (i < text.Length && ReferenceExtractor.IsJavaIdentifierPart(text[i])) + i++; + + name = text.Slice(start, i - start).ToString(); + return name.Length > 0; + } + + private static void EmitThrowsReferences( + string line, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlySet? ignoredSegments = null) + { + int keywordIndex = ReferenceExtractor.FindTopLevelKeyword(line, "throws"); + if (keywordIndex < 0) + return; + + int listStart = keywordIndex + "throws".Length; + while (listStart < line.Length && char.IsWhiteSpace(line[listStart])) + listStart++; + var remaining = line.AsSpan(listStart); + int listEnd = ReferenceExtractor.FindTypeListTerminator(remaining, allowArrow: false); + if (listEnd < 0) + listEnd = remaining.Length; + var typeList = remaining[..listEnd]; + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeList)) + { + var leading = ReferenceExtractor.CountLeadingWhitespace(typeList, segmentStart, segmentLength); + var trimmedLength = segmentLength - leading; + while (trimmedLength > 0 && char.IsWhiteSpace(typeList[segmentStart + leading + trimmedLength - 1])) + trimmedLength--; + if (trimmedLength == 0) + continue; + var absoluteStart = listStart + segmentStart + leading; + var rawSegment = typeList.Slice(segmentStart + leading, trimmedLength); + ReferenceExtractor.AddTypeExpressionSegments( + references, + seen, + fileId, + rawSegment.ToString(), + absoluteStart, + context, + lineNumber, + resolveContainerForColumn(absoluteStart), + "java", + ignoredSegments); + } + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.cs index 586cc6578..3263bfc97 100644 --- a/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/JavaReferenceExtractor.cs @@ -4,7 +4,7 @@ namespace CodeIndex.Indexer; -internal static class JavaReferenceExtractor +internal static partial class JavaReferenceExtractor { private static readonly IReadOnlySet EmptyGenericParameterNames = new HashSet(StringComparer.Ordinal); @@ -681,558 +681,4 @@ private static bool SkipBalancedAngles(string text, ref int i) return false; } - public static void EmitTypePositionReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - SymbolRecord? container) - { - var genericParameterNames = CollectGenericParameterNamesForDeclaration(preparedLine); - EmitKeywordTypeListReferences( - preparedLine, - "extends", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - genericParameterNames); - EmitKeywordTypeListReferences( - preparedLine, - "implements", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - genericParameterNames); - EmitKeywordTypeListReferences( - preparedLine, - "permits", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - genericParameterNames); - EmitGenericBoundReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitThrowsReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, genericParameterNames); - ReferenceExtractor.EmitDeclarationTypeReferences( - "java", - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - genericParameterNames); - - foreach (Match match in InstanceofRegex.Matches(preparedLine)) - { - var typeGroup = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments( - references, - seen, - fileId, - typeGroup.Value, - typeGroup.Index, - context, - lineNumber, - resolveContainerForColumn(typeGroup.Index), - "java"); - } - } - - private static void EmitKeywordTypeListReferences( - string line, - string keyword, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - IReadOnlySet? ignoredSegments = null) - { - int keywordIndex = ReferenceExtractor.FindTopLevelKeyword(line, keyword); - if (keywordIndex < 0) - return; - - int listStart = keywordIndex + keyword.Length; - while (listStart < line.Length && char.IsWhiteSpace(line[listStart])) - listStart++; - - var remaining = line.AsSpan(listStart); - int listEnd = ReferenceExtractor.FindJavaTypeListTerminator(remaining); - if (listEnd < 0) - listEnd = remaining.Length; - var typeList = remaining[..listEnd]; - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeList)) - { - var leading = ReferenceExtractor.CountLeadingWhitespace(typeList, segmentStart, segmentLength); - var trimmedLength = segmentLength - leading; - while (trimmedLength > 0 && char.IsWhiteSpace(typeList[segmentStart + leading + trimmedLength - 1])) - trimmedLength--; - if (trimmedLength == 0) - continue; - var absoluteStart = listStart + segmentStart + leading; - var rawSegment = typeList.Slice(segmentStart + leading, trimmedLength); - ReferenceExtractor.AddTypeExpressionSegments( - references, - seen, - fileId, - rawSegment.ToString(), - absoluteStart, - context, - lineNumber, - resolveContainerForColumn(absoluteStart), - "java", - ignoredSegments: ignoredSegments); - } - } - - private static void EmitGenericBoundReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - EmitCallableGenericBoundReferences(line, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - EmitNamedTypeGenericBoundReferences(line, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static IReadOnlySet CollectGenericParameterNamesForDeclaration(string line) - { - if (ReferenceExtractor.TryFindCallableParameterList(line, "java", out var callableNameStart, out _, out _)) - { - var headerEnd = callableNameStart; - if (ReferenceExtractor.TryGetCallableReturnTypeSpan(line, callableNameStart, "java", out var typeStart, out _)) - headerEnd = typeStart; - - if (headerEnd > 0) - return CollectGenericParameterNamesFromHeader(line.Substring(0, headerEnd)); - } - - var tokens = ReferenceExtractor.GetTopLevelTokenSpans(line); - if (tokens.Count < 2) - return EmptyGenericParameterNames; - - for (int i = 0; i < tokens.Count; i++) - { - if (!IsNamedTypeKeyword(line.AsSpan(tokens[i].Start, tokens[i].Length))) - continue; - var nameIndex = i + 1; - if (nameIndex >= tokens.Count) - return EmptyGenericParameterNames; - return CollectGenericParameterNamesFromHeader(line.Substring(tokens[nameIndex].Start, tokens[nameIndex].Length)); - } - - return EmptyGenericParameterNames; - } - - private static IReadOnlySet CollectGenericParameterNamesFromHeader(string header) - { - int openAngle = header.IndexOf('<'); - if (openAngle < 0) - return EmptyGenericParameterNames; - - int closeAngle = ReferenceExtractor.FindMatchingChar(header, openAngle, '<', '>'); - if (closeAngle < 0) - return EmptyGenericParameterNames; - - return CollectGenericParameterNames(header.Substring(openAngle + 1, closeAngle - openAngle - 1)); - } - - private static void EmitCallableGenericBoundReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - if (!ReferenceExtractor.TryFindCallableParameterList(line, "java", out var callableNameStart, out _, out _)) - return; - - var headerEnd = callableNameStart; - if (ReferenceExtractor.TryGetCallableReturnTypeSpan(line, callableNameStart, "java", out var typeStart, out _)) - headerEnd = typeStart; - - if (headerEnd <= 0) - return; - - EmitGenericBoundReferencesFromHeader( - line.Substring(0, headerEnd), - 0, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - - private static void EmitNamedTypeGenericBoundReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var tokens = ReferenceExtractor.GetTopLevelTokenSpans(line); - if (tokens.Count < 2) - return; - - int keywordIndex = -1; - int nameIndex = -1; - for (int i = 0; i < tokens.Count; i++) - { - if (IsNamedTypeKeyword(line.AsSpan(tokens[i].Start, tokens[i].Length))) - { - keywordIndex = i; - nameIndex = i + 1; - break; - } - } - - if (keywordIndex < 0 || nameIndex < 0 || nameIndex >= tokens.Count) - return; - - var nameToken = line.AsSpan(tokens[nameIndex].Start, tokens[nameIndex].Length); - EmitGenericBoundReferencesFromHeader( - nameToken, - tokens[nameIndex].Start, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - - private static void EmitGenericBoundReferencesFromHeader( - ReadOnlySpan header, - int headerStartInLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - int openAngle = header.IndexOf('<'); - if (openAngle < 0) - return; - - int closeAngle = ReferenceExtractor.FindMatchingChar(header, openAngle, '<', '>'); - if (closeAngle < 0) - return; - - var parameterClauseText = header.Slice(openAngle + 1, closeAngle - openAngle - 1); - var genericParameterNames = CollectGenericParameterNames(parameterClauseText); - - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(parameterClauseText)) - { - var parameterLeading = ReferenceExtractor.CountLeadingWhitespace(parameterClauseText, segmentStart, segmentLength); - var parameterLength = segmentLength - parameterLeading; - while (parameterLength > 0 && char.IsWhiteSpace(parameterClauseText[segmentStart + parameterLeading + parameterLength - 1])) - parameterLength--; - if (parameterLength == 0) - continue; - - var rawParameter = parameterClauseText.Slice(segmentStart + parameterLeading, parameterLength); - int extendsIndex = ReferenceExtractor.FindTopLevelKeyword(rawParameter, "extends"); - if (extendsIndex < 0) - continue; - - var boundsStart = extendsIndex + "extends".Length; - var boundsLeading = ReferenceExtractor.CountLeadingWhitespace(rawParameter, boundsStart, rawParameter.Length - boundsStart); - var boundsLength = rawParameter.Length - boundsStart - boundsLeading; - while (boundsLength > 0 && char.IsWhiteSpace(rawParameter[boundsStart + boundsLeading + boundsLength - 1])) - boundsLength--; - if (boundsLength == 0) - continue; - - var boundsText = rawParameter.Slice(boundsStart + boundsLeading, boundsLength); - foreach (var (boundStart, boundLength) in ReferenceExtractor.SplitTopLevelAmpersandSpans(boundsText)) - { - var boundLeading = ReferenceExtractor.CountLeadingWhitespace(boundsText, boundStart, boundLength); - var rawBoundLength = boundLength - boundLeading; - while (rawBoundLength > 0 && char.IsWhiteSpace(boundsText[boundStart + boundLeading + rawBoundLength - 1])) - rawBoundLength--; - if (rawBoundLength == 0) - continue; - - var rawBound = boundsText.Slice(boundStart + boundLeading, rawBoundLength).ToString(); - var absoluteStart = headerStartInLine + openAngle + 1 + segmentStart + extendsIndex + "extends".Length + boundStart + boundLeading; - ReferenceExtractor.AddTypeExpressionSegments( - references, - seen, - fileId, - rawBound, - absoluteStart, - context, - lineNumber, - resolveContainerForColumn(absoluteStart), - "java", - genericParameterNames); - } - } - } - - private static bool IsNamedTypeKeyword(ReadOnlySpan token) => - token is "class" or "interface" or "enum" or "record"; - - private static IReadOnlySet CollectGenericParameterNames(ReadOnlySpan parameterClause) - { - HashSet? names = null; - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(parameterClause)) - { - var parameterLeading = ReferenceExtractor.CountLeadingWhitespace(parameterClause, segmentStart, segmentLength); - var parameterLength = segmentLength - parameterLeading; - while (parameterLength > 0 && char.IsWhiteSpace(parameterClause[segmentStart + parameterLeading + parameterLength - 1])) - parameterLength--; - if (parameterLength == 0) - continue; - - var rawParameter = parameterClause.Slice(segmentStart + parameterLeading, parameterLength); - int extendsIndex = ReferenceExtractor.FindTopLevelKeyword(rawParameter, "extends"); - var nameFragment = extendsIndex >= 0 ? rawParameter[..extendsIndex] : rawParameter; - if (TryReadGenericParameterName(nameFragment, out var name)) - (names ??= new HashSet(StringComparer.Ordinal)).Add(name); - } - - return names ?? EmptyGenericParameterNames; - } - - private static bool TryReadGenericParameterName(ReadOnlySpan text, out string name) - { - name = string.Empty; - int i = 0; - while (i < text.Length) - { - while (i < text.Length && char.IsWhiteSpace(text[i])) - i++; - if (i >= text.Length) - return false; - if (text[i] == '@') - { - i = ReferenceExtractor.SkipJavaAnnotation(text, i); - continue; - } - break; - } - - int start = i; - if (start >= text.Length || !ReferenceExtractor.IsJavaIdentifierPart(text[start])) - return false; - - i++; - while (i < text.Length && ReferenceExtractor.IsJavaIdentifierPart(text[i])) - i++; - - name = text.Slice(start, i - start).ToString(); - return name.Length > 0; - } - - private static void EmitThrowsReferences( - string line, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - IReadOnlySet? ignoredSegments = null) - { - int keywordIndex = ReferenceExtractor.FindTopLevelKeyword(line, "throws"); - if (keywordIndex < 0) - return; - - int listStart = keywordIndex + "throws".Length; - while (listStart < line.Length && char.IsWhiteSpace(line[listStart])) - listStart++; - var remaining = line.AsSpan(listStart); - int listEnd = ReferenceExtractor.FindTypeListTerminator(remaining, allowArrow: false); - if (listEnd < 0) - listEnd = remaining.Length; - var typeList = remaining[..listEnd]; - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeList)) - { - var leading = ReferenceExtractor.CountLeadingWhitespace(typeList, segmentStart, segmentLength); - var trimmedLength = segmentLength - leading; - while (trimmedLength > 0 && char.IsWhiteSpace(typeList[segmentStart + leading + trimmedLength - 1])) - trimmedLength--; - if (trimmedLength == 0) - continue; - var absoluteStart = listStart + segmentStart + leading; - var rawSegment = typeList.Slice(segmentStart + leading, trimmedLength); - ReferenceExtractor.AddTypeExpressionSegments( - references, - seen, - fileId, - rawSegment.ToString(), - absoluteStart, - context, - lineNumber, - resolveContainerForColumn(absoluteStart), - "java", - ignoredSegments); - } - } - - public static void EmitMethodReferenceReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - => JvmMethodReferenceExtractor.EmitMethodReferenceReferences( - "java", - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - - public static void EmitDotClassTypeLiteralReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - foreach (Match match in DotClassArgRegex.Matches(preparedLine)) - { - var argGroup = match.Groups["arg"]; - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - argGroup.Value, - argGroup.Index, - context, - lineNumber, - container, - "java"); - } - } - - public static void EmitModuleDirectiveReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - EmitModuleDirectiveReference( - preparedLine, - ModuleRequiresDirectiveReferenceRegex, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - - EmitModuleDirectiveReference( - preparedLine, - ModuleUsesDirectiveReferenceRegex, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - - foreach (Match match in BoundedRegex.EnumerateMatches(ModuleProvidesDirectiveReferenceRegex, preparedLine)) - { - var serviceGroup = match.Groups["service"]; - ReferenceExtractor.AddTypeReferenceSegment( - references, - seen, - fileId, - serviceGroup.Value, - serviceGroup.Index, - context, - lineNumber, - resolveContainerForColumn(serviceGroup.Index), - "java"); - - var implementationsGroup = match.Groups["implementations"]; - var implementations = implementationsGroup.ValueSpan; - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(implementations)) - { - var segmentLeading = ReferenceExtractor.CountLeadingWhitespace(implementations, segmentStart, segmentLength); - var rawSegmentLength = segmentLength - segmentLeading; - while (rawSegmentLength > 0 && char.IsWhiteSpace(implementations[segmentStart + segmentLeading + rawSegmentLength - 1])) - rawSegmentLength--; - if (rawSegmentLength == 0) - continue; - - var rawSegment = implementations.Slice(segmentStart + segmentLeading, rawSegmentLength); - var absoluteStart = implementationsGroup.Index - + segmentStart - + segmentLeading; - ReferenceExtractor.AddTypeReferenceSegment( - references, - seen, - fileId, - rawSegment.ToString(), - absoluteStart, - context, - lineNumber, - resolveContainerForColumn(absoluteStart), - "java"); - } - } - } - - private static void EmitModuleDirectiveReference( - string preparedLine, - Regex regex, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - foreach (Match match in BoundedRegex.EnumerateMatches(regex, preparedLine)) - { - var nameGroup = match.Groups["name"]; - ReferenceExtractor.AddTypeReferenceSegment( - references, - seen, - fileId, - nameGroup.Value, - nameGroup.Index, - context, - lineNumber, - resolveContainerForColumn(nameGroup.Index), - "java"); - } - } } diff --git a/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.Constructors.cs b/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.Constructors.cs new file mode 100644 index 000000000..22e4764fe --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.Constructors.cs @@ -0,0 +1,189 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class KotlinReferenceExtractor +{ + private static SymbolRecord? FindEnclosingKotlinConstructor( + IReadOnlyList symbols, + SymbolRecord enclosingType, + int lineNumber) + { + SymbolRecord? best = null; + foreach (var symbol in symbols) + { + if (symbol.Kind != "function") + continue; + if (!string.Equals(symbol.ContainerName, enclosingType.Name, StringComparison.Ordinal) + && !IsWithinSymbolRange(enclosingType, symbol.StartLine)) + { + continue; + } + + var signature = symbol.Signature?.TrimStart(); + var isSecondaryConstructor = !string.IsNullOrWhiteSpace(signature) + && (signature.StartsWith("constructor", StringComparison.Ordinal) + || signature.StartsWith("public constructor", StringComparison.Ordinal) + || signature.StartsWith("private constructor", StringComparison.Ordinal) + || signature.StartsWith("protected constructor", StringComparison.Ordinal) + || signature.StartsWith("internal constructor", StringComparison.Ordinal)); + if (!isSecondaryConstructor + && !string.Equals(symbol.Name, enclosingType.Name, StringComparison.Ordinal)) + { + continue; + } + + if (symbol.StartLine > lineNumber) + continue; + var symbolEnd = symbol.BodyEndLine ?? symbol.EndLine; + if (symbolEnd < lineNumber) + continue; + + if (best == null || symbol.StartLine >= best.StartLine) + best = symbol; + } + + return best; + } + + private static bool IsWithinSymbolRange(SymbolRecord container, int lineNumber) + { + var start = container.BodyStartLine ?? container.StartLine; + var end = container.BodyEndLine ?? container.EndLine; + return lineNumber >= start && lineNumber <= end; + } + + private static string? ParseKotlinBaseType(string? signature) + { + if (string.IsNullOrWhiteSpace(signature)) + return null; + + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(signature, ':'); + if (colonIndex < 0) + return null; + + var listStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(signature, colonIndex + 1); + var listEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd( + signature, + listStart, + stopAtComma: false); + if (listEnd <= listStart) + return null; + + var typeList = signature.AsSpan(listStart, listEnd - listStart); + string? fallback = null; + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeList)) + { + var segment = typeList.Slice(segmentStart, segmentLength).Trim(); + if (segment.IsEmpty) + continue; + + var segmentText = segment.ToString(); + var typeName = ExtractKotlinBareTypeName(segmentText); + if (string.IsNullOrWhiteSpace(typeName)) + continue; + + if (TypedLanguageReferenceExtractor.FindTopLevelChar(segmentText, '(') >= 0) + return typeName; + + fallback ??= typeName; + } + + return fallback; + } + + private static string? ExtractKotlinBareTypeName(string segment) + { + var trimmed = TrimTopLevelByClause(segment.Trim()); + if (trimmed.Length == 0) + return null; + + var callIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(trimmed, '('); + if (callIndex > 0) + { + var callEnd = callIndex; + while (callEnd > 0 && char.IsWhiteSpace(trimmed[callEnd - 1])) + callEnd--; + trimmed = trimmed.Substring(0, callEnd); + } + + var lastSegmentStart = 0; + var endIndex = trimmed.Length; + var angleDepth = 0; + for (var i = 0; i < trimmed.Length; i++) + { + var ch = trimmed[i]; + if (ch == '<') + { + if (angleDepth == 0) + endIndex = Math.Min(endIndex, i); + angleDepth++; + } + else if (ch == '>') + { + if (angleDepth > 0) + angleDepth--; + } + else if (angleDepth == 0 && ch == '.') + { + lastSegmentStart = i + 1; + } + } + + if (endIndex < lastSegmentStart) + endIndex = trimmed.Length; + + var typeNameLeading = 0; + while (lastSegmentStart + typeNameLeading < endIndex && char.IsWhiteSpace(trimmed[lastSegmentStart + typeNameLeading])) + typeNameLeading++; + + var typeNameEnd = endIndex; + while (typeNameEnd > lastSegmentStart + typeNameLeading && char.IsWhiteSpace(trimmed[typeNameEnd - 1])) + typeNameEnd--; + + var typeName = trimmed.Substring(lastSegmentStart + typeNameLeading, typeNameEnd - lastSegmentStart - typeNameLeading); + return typeName.Length > 0 ? typeName : null; + } + + private static string TrimTopLevelByClause(string segment) + { + var angleDepth = 0; + var parenDepth = 0; + for (var i = 0; i < segment.Length; i++) + { + var ch = segment[i]; + if (ch == '<') + { + angleDepth++; + } + else if (ch == '>') + { + if (angleDepth > 0) + angleDepth--; + } + else if (ch == '(') + { + parenDepth++; + } + else if (ch == ')') + { + if (parenDepth > 0) + parenDepth--; + } + else if (angleDepth == 0 + && parenDepth == 0 + && i + 4 <= segment.Length + && string.CompareOrdinal(segment, i, " by ", 0, 4) == 0) + { + var end = i; + while (end > 0 && char.IsWhiteSpace(segment[end - 1])) + end--; + return segment.Substring(0, end); + } + } + + return segment; + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.Types.cs b/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.Types.cs new file mode 100644 index 000000000..4c815a809 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.Types.cs @@ -0,0 +1,492 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class KotlinReferenceExtractor +{ + public static void EmitTypePositionReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var genericParameterNames = CollectGenericParameterNames(preparedLine); + EmitCallableSignatureTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, genericParameterNames); + EmitPrimaryConstructorTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, genericParameterNames); + EmitHeritageTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, genericParameterNames); + EmitGenericBoundReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, genericParameterNames); + EmitExtensionPropertyReceiverTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, genericParameterNames); + TypedLanguageReferenceExtractor.EmitColonVariableTypeReferences( + preparedLine, + DeclarationKeywords, + "kotlin", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + genericParameterNames); + if (!preparedLine.TrimStart().StartsWith("import ", StringComparison.Ordinal)) + { + TypedLanguageReferenceExtractor.EmitKeywordFollowingTypeReferences( + preparedLine, + TypeOperatorKeywords, + "kotlin", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + genericParameterNames); + } + } + + private static void EmitCallableSignatureTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlySet? ignoredSegments) + { + var funIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "fun"); + if (funIndex < 0) + return; + + var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '(', funIndex + "fun".Length); + if (openParen <= funIndex) + return; + + var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); + if (closeParen < 0) + return; + + EmitExtensionFunctionReceiverTypeReferences( + preparedLine, + funIndex, + openParen, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + ignoredSegments); + + TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( + preparedLine, + openParen + 1, + closeParen, + "kotlin", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + ignoredSegments); + + var returnColon = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, closeParen + 1); + if (returnColon >= preparedLine.Length || preparedLine[returnColon] != ':') + return; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, returnColon + 1); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); + if (typeEnd <= typeStart) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "kotlin", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart), + ignoredSegments); + } + + private static void EmitExtensionFunctionReceiverTypeReferences( + string preparedLine, + int funIndex, + int openParen, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlySet? ignoredSegments) + { + var headStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, funIndex + "fun".Length); + if (headStart >= openParen) + return; + + if (preparedLine[headStart] == '<') + { + var genericClose = ReferenceExtractor.FindMatchingChar(preparedLine, headStart, '<', '>'); + if (genericClose < 0 || genericClose >= openParen) + return; + headStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, genericClose + 1); + if (headStart >= openParen) + return; + } + + var receiverDot = FindLastTopLevelChar(preparedLine, '.', headStart, openParen); + if (receiverDot <= headStart) + return; + + var receiverEnd = receiverDot; + while (receiverEnd > headStart && char.IsWhiteSpace(preparedLine[receiverEnd - 1])) + receiverEnd--; + if (receiverEnd <= headStart) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(headStart, receiverEnd - headStart), + headStart, + "kotlin", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(headStart), + ignoredSegments); + } + + private static void EmitExtensionPropertyReceiverTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlySet? ignoredSegments) + { + foreach (var keyword in DeclarationKeywords) + { + foreach (var keywordIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, keyword)) + { + var declarationStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, keywordIndex + keyword.Length); + if (declarationStart >= preparedLine.Length) + continue; + + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', declarationStart); + var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', declarationStart); + var declarationEnd = preparedLine.Length; + if (colonIndex >= 0) + declarationEnd = Math.Min(declarationEnd, colonIndex); + if (assignmentIndex >= 0) + declarationEnd = Math.Min(declarationEnd, assignmentIndex); + if (declarationEnd <= declarationStart) + continue; + + var receiverDot = FindLastTopLevelChar(preparedLine, '.', declarationStart, declarationEnd); + if (receiverDot <= declarationStart) + continue; + + var receiverEnd = receiverDot; + while (receiverEnd > declarationStart && char.IsWhiteSpace(preparedLine[receiverEnd - 1])) + receiverEnd--; + if (receiverEnd <= declarationStart) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(declarationStart, receiverEnd - declarationStart), + declarationStart, + "kotlin", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(declarationStart), + ignoredSegments); + } + } + } + + private static int FindLastTopLevelChar(string text, char target, int startIndex, int endIndex) + { + var angleDepth = 0; + var parenDepth = 0; + var squareDepth = 0; + var braceDepth = 0; + var last = -1; + var end = Math.Min(text.Length, endIndex); + for (var i = Math.Max(0, startIndex); i < end; i++) + { + var ch = text[i]; + if (angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0 && ch == target) + last = i; + + switch (ch) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) parenDepth--; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) squareDepth--; + break; + case '{': + braceDepth++; + break; + case '}': + if (braceDepth > 0) braceDepth--; + break; + } + } + + return last; + } + + private static void EmitHeritageTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlySet? ignoredSegments) + { + var trimmed = preparedLine.TrimStart(); + if (!(trimmed.StartsWith("class ", StringComparison.Ordinal) + || trimmed.StartsWith("data class ", StringComparison.Ordinal) + || trimmed.StartsWith("sealed class ", StringComparison.Ordinal) + || trimmed.StartsWith("interface ", StringComparison.Ordinal) + || trimmed.StartsWith("object ", StringComparison.Ordinal) + || trimmed.StartsWith("enum class ", StringComparison.Ordinal))) + { + return; + } + + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':'); + if (colonIndex < 0) + return; + + var listStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); + var listEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, listStart, stopAtComma: false); + if (listEnd <= listStart) + return; + + TypedLanguageReferenceExtractor.EmitCommaSeparatedTypeListReferences( + preparedLine, + listStart, + listEnd, + "kotlin", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + trimTopLevelCallArguments: true, + ignoredSegments: ignoredSegments); + } + + private static void EmitPrimaryConstructorTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlySet? ignoredSegments) + { + var trimmed = preparedLine.TrimStart(); + if (!(trimmed.StartsWith("class ", StringComparison.Ordinal) + || trimmed.StartsWith("data class ", StringComparison.Ordinal) + || trimmed.StartsWith("sealed class ", StringComparison.Ordinal) + || trimmed.StartsWith("enum class ", StringComparison.Ordinal))) + { + return; + } + + var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '('); + if (openParen < 0) + return; + + var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); + if (closeParen < 0) + return; + + TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( + preparedLine, + openParen + 1, + closeParen, + "kotlin", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + ignoredSegments); + } + + private static void EmitGenericBoundReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlySet? genericParameterNames) + { + var genericOpenIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '<'); + if (genericOpenIndex >= 0) + { + TypedLanguageReferenceExtractor.EmitGenericColonBoundReferences( + preparedLine, + genericOpenIndex, + "kotlin", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + genericParameterNames); + } + + TypedLanguageReferenceExtractor.EmitWhereClauseTypeReferences( + preparedLine, + "kotlin", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + genericParameterNames); + } + + private static IReadOnlySet CollectGenericParameterNames(string preparedLine) + { + foreach (var funIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "fun")) + { + var genericOpenIndex = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, funIndex + "fun".Length); + if (genericOpenIndex < preparedLine.Length && preparedLine[genericOpenIndex] == '<') + return CollectGenericParameterNamesFromClause(preparedLine, genericOpenIndex); + } + + foreach (var keyword in GenericOwnerKeywords) + { + foreach (var keywordIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, keyword)) + { + var nameStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, keywordIndex + keyword.Length); + var nameEnd = ConsumeKotlinDeclarationName(preparedLine, nameStart); + if (nameEnd <= nameStart) + continue; + + var genericOpenIndex = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, nameEnd); + if (genericOpenIndex < preparedLine.Length && preparedLine[genericOpenIndex] == '<') + return CollectGenericParameterNamesFromClause(preparedLine, genericOpenIndex); + } + } + + return EmptyGenericParameterNames; + } + + private static IReadOnlySet CollectGenericParameterNamesFromClause(string preparedLine, int genericOpenIndex) + { + var genericCloseIndex = ReferenceExtractor.FindMatchingChar(preparedLine, genericOpenIndex, '<', '>'); + if (genericCloseIndex <= genericOpenIndex) + return EmptyGenericParameterNames; + + HashSet? names = null; + var clause = preparedLine.AsSpan(genericOpenIndex + 1, genericCloseIndex - genericOpenIndex - 1); + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(clause)) + { + var fragment = clause.Slice(segmentStart, segmentLength).ToString(); + if (TryReadGenericParameterName(fragment, out var name)) + (names ??= new HashSet(StringComparer.Ordinal)).Add(name); + } + + return names ?? EmptyGenericParameterNames; + } + + private static int ConsumeKotlinDeclarationName(string preparedLine, int startIndex) + { + if (startIndex >= preparedLine.Length) + return startIndex; + + if (preparedLine[startIndex] == '`') + { + var close = preparedLine.IndexOf('`', startIndex + 1); + return close < 0 ? startIndex : close + 1; + } + + var index = startIndex; + while (index < preparedLine.Length && ReferenceExtractor.IsJavaIdentifierPart(preparedLine[index])) + index++; + + return index; + } + + private static bool TryReadGenericParameterName(string fragment, out string name) + { + name = string.Empty; + var index = 0; + while (index < fragment.Length) + { + while (index < fragment.Length && char.IsWhiteSpace(fragment[index])) + index++; + + if (index >= fragment.Length) + return false; + + if (fragment[index] == '@') + { + index = ReferenceExtractor.SkipJavaAnnotation(fragment, index); + continue; + } + + var tokenStart = index; + if (!ReferenceExtractor.IsJavaIdentifierPart(fragment[index])) + return false; + + index++; + while (index < fragment.Length && ReferenceExtractor.IsJavaIdentifierPart(fragment[index])) + index++; + + var token = fragment.Substring(tokenStart, index - tokenStart); + if (token is "reified" or "in" or "out") + continue; + + name = token; + return true; + } + + return false; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.cs index 939c3f4f9..97c0a5b63 100644 --- a/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/KotlinReferenceExtractor.cs @@ -4,7 +4,7 @@ namespace CodeIndex.Indexer; -internal static class KotlinReferenceExtractor +internal static partial class KotlinReferenceExtractor { // Kotlin secondary constructor delegation: `constructor(x: Int) : this(x)` / `: super(x)`. // Kotlin セカンダリコンストラクタ委譲。 @@ -430,667 +430,4 @@ public static void EmitCtorDelegationReferences( } } - public static void EmitTypePositionReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var genericParameterNames = CollectGenericParameterNames(preparedLine); - EmitCallableSignatureTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, genericParameterNames); - EmitPrimaryConstructorTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, genericParameterNames); - EmitHeritageTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, genericParameterNames); - EmitGenericBoundReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, genericParameterNames); - EmitExtensionPropertyReceiverTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, genericParameterNames); - TypedLanguageReferenceExtractor.EmitColonVariableTypeReferences( - preparedLine, - DeclarationKeywords, - "kotlin", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - genericParameterNames); - if (!preparedLine.TrimStart().StartsWith("import ", StringComparison.Ordinal)) - { - TypedLanguageReferenceExtractor.EmitKeywordFollowingTypeReferences( - preparedLine, - TypeOperatorKeywords, - "kotlin", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - genericParameterNames); - } - } - - private static void EmitCallableSignatureTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - IReadOnlySet? ignoredSegments) - { - var funIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "fun"); - if (funIndex < 0) - return; - - var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '(', funIndex + "fun".Length); - if (openParen <= funIndex) - return; - - var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); - if (closeParen < 0) - return; - - EmitExtensionFunctionReceiverTypeReferences( - preparedLine, - funIndex, - openParen, - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - ignoredSegments); - - TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( - preparedLine, - openParen + 1, - closeParen, - "kotlin", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - ignoredSegments); - - var returnColon = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, closeParen + 1); - if (returnColon >= preparedLine.Length || preparedLine[returnColon] != ':') - return; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, returnColon + 1); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); - if (typeEnd <= typeStart) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "kotlin", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart), - ignoredSegments); - } - - private static void EmitExtensionFunctionReceiverTypeReferences( - string preparedLine, - int funIndex, - int openParen, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - IReadOnlySet? ignoredSegments) - { - var headStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, funIndex + "fun".Length); - if (headStart >= openParen) - return; - - if (preparedLine[headStart] == '<') - { - var genericClose = ReferenceExtractor.FindMatchingChar(preparedLine, headStart, '<', '>'); - if (genericClose < 0 || genericClose >= openParen) - return; - headStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, genericClose + 1); - if (headStart >= openParen) - return; - } - - var receiverDot = FindLastTopLevelChar(preparedLine, '.', headStart, openParen); - if (receiverDot <= headStart) - return; - - var receiverEnd = receiverDot; - while (receiverEnd > headStart && char.IsWhiteSpace(preparedLine[receiverEnd - 1])) - receiverEnd--; - if (receiverEnd <= headStart) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(headStart, receiverEnd - headStart), - headStart, - "kotlin", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(headStart), - ignoredSegments); - } - - private static void EmitExtensionPropertyReceiverTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - IReadOnlySet? ignoredSegments) - { - foreach (var keyword in DeclarationKeywords) - { - foreach (var keywordIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, keyword)) - { - var declarationStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, keywordIndex + keyword.Length); - if (declarationStart >= preparedLine.Length) - continue; - - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', declarationStart); - var assignmentIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', declarationStart); - var declarationEnd = preparedLine.Length; - if (colonIndex >= 0) - declarationEnd = Math.Min(declarationEnd, colonIndex); - if (assignmentIndex >= 0) - declarationEnd = Math.Min(declarationEnd, assignmentIndex); - if (declarationEnd <= declarationStart) - continue; - - var receiverDot = FindLastTopLevelChar(preparedLine, '.', declarationStart, declarationEnd); - if (receiverDot <= declarationStart) - continue; - - var receiverEnd = receiverDot; - while (receiverEnd > declarationStart && char.IsWhiteSpace(preparedLine[receiverEnd - 1])) - receiverEnd--; - if (receiverEnd <= declarationStart) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(declarationStart, receiverEnd - declarationStart), - declarationStart, - "kotlin", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(declarationStart), - ignoredSegments); - } - } - } - - private static int FindLastTopLevelChar(string text, char target, int startIndex, int endIndex) - { - var angleDepth = 0; - var parenDepth = 0; - var squareDepth = 0; - var braceDepth = 0; - var last = -1; - var end = Math.Min(text.Length, endIndex); - for (var i = Math.Max(0, startIndex); i < end; i++) - { - var ch = text[i]; - if (angleDepth == 0 && parenDepth == 0 && squareDepth == 0 && braceDepth == 0 && ch == target) - last = i; - - switch (ch) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) parenDepth--; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) squareDepth--; - break; - case '{': - braceDepth++; - break; - case '}': - if (braceDepth > 0) braceDepth--; - break; - } - } - - return last; - } - - private static void EmitHeritageTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - IReadOnlySet? ignoredSegments) - { - var trimmed = preparedLine.TrimStart(); - if (!(trimmed.StartsWith("class ", StringComparison.Ordinal) - || trimmed.StartsWith("data class ", StringComparison.Ordinal) - || trimmed.StartsWith("sealed class ", StringComparison.Ordinal) - || trimmed.StartsWith("interface ", StringComparison.Ordinal) - || trimmed.StartsWith("object ", StringComparison.Ordinal) - || trimmed.StartsWith("enum class ", StringComparison.Ordinal))) - { - return; - } - - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':'); - if (colonIndex < 0) - return; - - var listStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); - var listEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, listStart, stopAtComma: false); - if (listEnd <= listStart) - return; - - TypedLanguageReferenceExtractor.EmitCommaSeparatedTypeListReferences( - preparedLine, - listStart, - listEnd, - "kotlin", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - trimTopLevelCallArguments: true, - ignoredSegments: ignoredSegments); - } - - private static void EmitPrimaryConstructorTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - IReadOnlySet? ignoredSegments) - { - var trimmed = preparedLine.TrimStart(); - if (!(trimmed.StartsWith("class ", StringComparison.Ordinal) - || trimmed.StartsWith("data class ", StringComparison.Ordinal) - || trimmed.StartsWith("sealed class ", StringComparison.Ordinal) - || trimmed.StartsWith("enum class ", StringComparison.Ordinal))) - { - return; - } - - var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '('); - if (openParen < 0) - return; - - var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); - if (closeParen < 0) - return; - - TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( - preparedLine, - openParen + 1, - closeParen, - "kotlin", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - ignoredSegments); - } - - private static void EmitGenericBoundReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn, - IReadOnlySet? genericParameterNames) - { - var genericOpenIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '<'); - if (genericOpenIndex >= 0) - { - TypedLanguageReferenceExtractor.EmitGenericColonBoundReferences( - preparedLine, - genericOpenIndex, - "kotlin", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - genericParameterNames); - } - - TypedLanguageReferenceExtractor.EmitWhereClauseTypeReferences( - preparedLine, - "kotlin", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn, - genericParameterNames); - } - - private static IReadOnlySet CollectGenericParameterNames(string preparedLine) - { - foreach (var funIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "fun")) - { - var genericOpenIndex = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, funIndex + "fun".Length); - if (genericOpenIndex < preparedLine.Length && preparedLine[genericOpenIndex] == '<') - return CollectGenericParameterNamesFromClause(preparedLine, genericOpenIndex); - } - - foreach (var keyword in GenericOwnerKeywords) - { - foreach (var keywordIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, keyword)) - { - var nameStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, keywordIndex + keyword.Length); - var nameEnd = ConsumeKotlinDeclarationName(preparedLine, nameStart); - if (nameEnd <= nameStart) - continue; - - var genericOpenIndex = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, nameEnd); - if (genericOpenIndex < preparedLine.Length && preparedLine[genericOpenIndex] == '<') - return CollectGenericParameterNamesFromClause(preparedLine, genericOpenIndex); - } - } - - return EmptyGenericParameterNames; - } - - private static IReadOnlySet CollectGenericParameterNamesFromClause(string preparedLine, int genericOpenIndex) - { - var genericCloseIndex = ReferenceExtractor.FindMatchingChar(preparedLine, genericOpenIndex, '<', '>'); - if (genericCloseIndex <= genericOpenIndex) - return EmptyGenericParameterNames; - - HashSet? names = null; - var clause = preparedLine.AsSpan(genericOpenIndex + 1, genericCloseIndex - genericOpenIndex - 1); - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(clause)) - { - var fragment = clause.Slice(segmentStart, segmentLength).ToString(); - if (TryReadGenericParameterName(fragment, out var name)) - (names ??= new HashSet(StringComparer.Ordinal)).Add(name); - } - - return names ?? EmptyGenericParameterNames; - } - - private static int ConsumeKotlinDeclarationName(string preparedLine, int startIndex) - { - if (startIndex >= preparedLine.Length) - return startIndex; - - if (preparedLine[startIndex] == '`') - { - var close = preparedLine.IndexOf('`', startIndex + 1); - return close < 0 ? startIndex : close + 1; - } - - var index = startIndex; - while (index < preparedLine.Length && ReferenceExtractor.IsJavaIdentifierPart(preparedLine[index])) - index++; - - return index; - } - - private static bool TryReadGenericParameterName(string fragment, out string name) - { - name = string.Empty; - var index = 0; - while (index < fragment.Length) - { - while (index < fragment.Length && char.IsWhiteSpace(fragment[index])) - index++; - - if (index >= fragment.Length) - return false; - - if (fragment[index] == '@') - { - index = ReferenceExtractor.SkipJavaAnnotation(fragment, index); - continue; - } - - var tokenStart = index; - if (!ReferenceExtractor.IsJavaIdentifierPart(fragment[index])) - return false; - - index++; - while (index < fragment.Length && ReferenceExtractor.IsJavaIdentifierPart(fragment[index])) - index++; - - var token = fragment.Substring(tokenStart, index - tokenStart); - if (token is "reified" or "in" or "out") - continue; - - name = token; - return true; - } - - return false; - } - - private static SymbolRecord? FindEnclosingKotlinConstructor( - IReadOnlyList symbols, - SymbolRecord enclosingType, - int lineNumber) - { - SymbolRecord? best = null; - foreach (var symbol in symbols) - { - if (symbol.Kind != "function") - continue; - if (!string.Equals(symbol.ContainerName, enclosingType.Name, StringComparison.Ordinal) - && !IsWithinSymbolRange(enclosingType, symbol.StartLine)) - { - continue; - } - - var signature = symbol.Signature?.TrimStart(); - var isSecondaryConstructor = !string.IsNullOrWhiteSpace(signature) - && (signature.StartsWith("constructor", StringComparison.Ordinal) - || signature.StartsWith("public constructor", StringComparison.Ordinal) - || signature.StartsWith("private constructor", StringComparison.Ordinal) - || signature.StartsWith("protected constructor", StringComparison.Ordinal) - || signature.StartsWith("internal constructor", StringComparison.Ordinal)); - if (!isSecondaryConstructor - && !string.Equals(symbol.Name, enclosingType.Name, StringComparison.Ordinal)) - { - continue; - } - - if (symbol.StartLine > lineNumber) - continue; - var symbolEnd = symbol.BodyEndLine ?? symbol.EndLine; - if (symbolEnd < lineNumber) - continue; - - if (best == null || symbol.StartLine >= best.StartLine) - best = symbol; - } - - return best; - } - - private static bool IsWithinSymbolRange(SymbolRecord container, int lineNumber) - { - var start = container.BodyStartLine ?? container.StartLine; - var end = container.BodyEndLine ?? container.EndLine; - return lineNumber >= start && lineNumber <= end; - } - - private static string? ParseKotlinBaseType(string? signature) - { - if (string.IsNullOrWhiteSpace(signature)) - return null; - - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(signature, ':'); - if (colonIndex < 0) - return null; - - var listStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(signature, colonIndex + 1); - var listEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd( - signature, - listStart, - stopAtComma: false); - if (listEnd <= listStart) - return null; - - var typeList = signature.AsSpan(listStart, listEnd - listStart); - string? fallback = null; - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(typeList)) - { - var segment = typeList.Slice(segmentStart, segmentLength).Trim(); - if (segment.IsEmpty) - continue; - - var segmentText = segment.ToString(); - var typeName = ExtractKotlinBareTypeName(segmentText); - if (string.IsNullOrWhiteSpace(typeName)) - continue; - - if (TypedLanguageReferenceExtractor.FindTopLevelChar(segmentText, '(') >= 0) - return typeName; - - fallback ??= typeName; - } - - return fallback; - } - - private static string? ExtractKotlinBareTypeName(string segment) - { - var trimmed = TrimTopLevelByClause(segment.Trim()); - if (trimmed.Length == 0) - return null; - - var callIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(trimmed, '('); - if (callIndex > 0) - { - var callEnd = callIndex; - while (callEnd > 0 && char.IsWhiteSpace(trimmed[callEnd - 1])) - callEnd--; - trimmed = trimmed.Substring(0, callEnd); - } - - var lastSegmentStart = 0; - var endIndex = trimmed.Length; - var angleDepth = 0; - for (var i = 0; i < trimmed.Length; i++) - { - var ch = trimmed[i]; - if (ch == '<') - { - if (angleDepth == 0) - endIndex = Math.Min(endIndex, i); - angleDepth++; - } - else if (ch == '>') - { - if (angleDepth > 0) - angleDepth--; - } - else if (angleDepth == 0 && ch == '.') - { - lastSegmentStart = i + 1; - } - } - - if (endIndex < lastSegmentStart) - endIndex = trimmed.Length; - - var typeNameLeading = 0; - while (lastSegmentStart + typeNameLeading < endIndex && char.IsWhiteSpace(trimmed[lastSegmentStart + typeNameLeading])) - typeNameLeading++; - - var typeNameEnd = endIndex; - while (typeNameEnd > lastSegmentStart + typeNameLeading && char.IsWhiteSpace(trimmed[typeNameEnd - 1])) - typeNameEnd--; - - var typeName = trimmed.Substring(lastSegmentStart + typeNameLeading, typeNameEnd - lastSegmentStart - typeNameLeading); - return typeName.Length > 0 ? typeName : null; - } - - private static string TrimTopLevelByClause(string segment) - { - var angleDepth = 0; - var parenDepth = 0; - for (var i = 0; i < segment.Length; i++) - { - var ch = segment[i]; - if (ch == '<') - { - angleDepth++; - } - else if (ch == '>') - { - if (angleDepth > 0) - angleDepth--; - } - else if (ch == '(') - { - parenDepth++; - } - else if (ch == ')') - { - if (parenDepth > 0) - parenDepth--; - } - else if (angleDepth == 0 - && parenDepth == 0 - && i + 4 <= segment.Length - && string.CompareOrdinal(segment, i, " by ", 0, 4) == 0) - { - var end = i; - while (end > 0 && char.IsWhiteSpace(segment[end - 1])) - end--; - return segment.Substring(0, end); - } - } - - return segment; - } } diff --git a/src/CodeIndex/Indexer/References/Languages/PhpReferenceExtractor.LanguageTypes.cs b/src/CodeIndex/Indexer/References/Languages/PhpReferenceExtractor.LanguageTypes.cs new file mode 100644 index 000000000..a3eaed9ab --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/PhpReferenceExtractor.LanguageTypes.cs @@ -0,0 +1,567 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class PhpReferenceExtractor +{ + public static void EmitAttributeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (!preparedLine.Contains("#[", StringComparison.Ordinal)) + return; + + foreach (Match match in AttributeRegex.Matches(preparedLine)) + { + var nameGroup = match.Groups["name"]; + var rawName = nameGroup.Value; + var leadingBackslashCount = 0; + while (leadingBackslashCount < rawName.Length && rawName[leadingBackslashCount] == '\\') + leadingBackslashCount++; + if (leadingBackslashCount == rawName.Length) + continue; + + var trimmedName = rawName.Substring(leadingBackslashCount); + var qualifiedNameIndex = nameGroup.Index + leadingBackslashCount; + if (trimmedName.Contains('\\', StringComparison.Ordinal)) + { + ReferenceExtractor.AddReference( + references, + seen, + fileId, + trimmedName, + qualifiedNameIndex, + "type_reference", + context, + lineNumber, + container); + } + + var shortNameStart = trimmedName.LastIndexOf('\\') + 1; + var shortName = trimmedName[shortNameStart..]; + if (shortName.Length == 0) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + shortName, + qualifiedNameIndex + shortNameStart, + "type_reference", + context, + lineNumber, + container); + } + } + + public static void EmitStaticAccessReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("::", StringComparison.Ordinal) < 0) + return; + + foreach (Match match in StaticAccessRegex.Matches(preparedLine)) + { + var nameGroup = match.Groups["name"]; + var rawName = nameGroup.Value; + var leadingBackslashCount = 0; + while (leadingBackslashCount < rawName.Length && rawName[leadingBackslashCount] == '\\') + leadingBackslashCount++; + if (leadingBackslashCount == rawName.Length) + continue; + + var trimmedName = rawName.Substring(leadingBackslashCount); + var shortNameStart = trimmedName.LastIndexOf('\\') + 1; + var shortName = trimmedName[shortNameStart..]; + if (shortName.Length == 0) + continue; + + var qualifiedNameIndex = nameGroup.Index + leadingBackslashCount; + if (trimmedName.Length > shortName.Length) + { + ReferenceExtractor.AddReference( + references, + seen, + fileId, + trimmedName, + qualifiedNameIndex, + "type_reference", + context, + lineNumber, + container); + } + + if (!string.Equals(shortName, "self", StringComparison.OrdinalIgnoreCase) + && !string.Equals(shortName, "static", StringComparison.OrdinalIgnoreCase) + && !string.Equals(shortName, "parent", StringComparison.OrdinalIgnoreCase)) + { + var shortNameIndex = qualifiedNameIndex + shortNameStart; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + shortName, + shortNameIndex, + "type_reference", + context, + lineNumber, + container); + } + + var memberGroup = match.Groups["member"]; + if (memberGroup.Success + && !memberGroup.Value.Equals("class", StringComparison.OrdinalIgnoreCase) + && !IsPhpCallAfterStaticMember(preparedLine, memberGroup.Index + memberGroup.Length)) + { + ReferenceExtractor.AddReference( + references, + seen, + fileId, + memberGroup.Value, + memberGroup.Index, + "reference", + context, + lineNumber, + container); + } + } + } + + public static void EmitInstanceofReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("instanceof", StringComparison.OrdinalIgnoreCase) < 0) + return; + + foreach (Match match in InstanceofRegex.Matches(preparedLine)) + { + AddPhpTypeReferenceFromQualifiedName( + match.Groups["name"], + references, + seen, + fileId, + context, + lineNumber, + container); + } + } + + public static void EmitCatchTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("catch", StringComparison.OrdinalIgnoreCase) < 0) + return; + + foreach (Match match in CatchTypeRegex.Matches(preparedLine)) + { + foreach (Capture capture in match.Groups["name"].Captures) + { + AddPhpTypeReferenceFromQualifiedName( + capture, + references, + seen, + fileId, + context, + lineNumber, + container); + } + } + } + + public static void EmitReturnTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf(':') < 0 + || preparedLine.IndexOf(')') < 0) + { + return; + } + + foreach (Match match in ReturnTypeRegex.Matches(preparedLine)) + { + foreach (Capture capture in match.Groups["name"].Captures) + { + if (IsPhpBuiltinTypeName(capture.Value)) + continue; + + AddPhpTypeReferenceFromQualifiedName( + capture, + references, + seen, + fileId, + context, + lineNumber, + container); + } + } + } + + public static void EmitParameterTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf('$') < 0) + return; + + foreach (Match match in ParameterTypeRegex.Matches(preparedLine)) + { + foreach (Capture capture in match.Groups["name"].Captures) + { + if (IsPhpBuiltinTypeName(capture.Value)) + continue; + + AddPhpTypeReferenceFromQualifiedName( + capture, + references, + seen, + fileId, + context, + lineNumber, + container); + } + } + } + + public static void EmitPropertyTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf('$') < 0 + || (preparedLine.IndexOf("public", StringComparison.OrdinalIgnoreCase) < 0 + && preparedLine.IndexOf("private", StringComparison.OrdinalIgnoreCase) < 0 + && preparedLine.IndexOf("protected", StringComparison.OrdinalIgnoreCase) < 0 + && preparedLine.IndexOf("var", StringComparison.OrdinalIgnoreCase) < 0)) + { + return; + } + + foreach (Match match in PropertyTypeRegex.Matches(preparedLine)) + { + foreach (Capture capture in match.Groups["name"].Captures) + { + if (IsPhpBuiltinTypeName(capture.Value)) + continue; + + AddPhpTypeReferenceFromQualifiedName( + capture, + references, + seen, + fileId, + context, + lineNumber, + container); + } + } + } + + public static void EmitInheritanceTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("extends", StringComparison.OrdinalIgnoreCase) < 0 + && preparedLine.IndexOf("implements", StringComparison.OrdinalIgnoreCase) < 0) + { + return; + } + + foreach (Match match in InheritanceTypeRegex.Matches(preparedLine)) + { + foreach (Capture capture in match.Groups["name"].Captures) + { + AddPhpTypeReferenceFromQualifiedName( + capture, + references, + seen, + fileId, + context, + lineNumber, + container); + } + } + } + + public static void EmitUseTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("use", StringComparison.OrdinalIgnoreCase) < 0) + return; + + var groupMatch = GroupUseTypeRegex.Match(preparedLine); + if (groupMatch.Success) + { + EmitGroupUseTypeReferences(groupMatch, references, seen, fileId, context, lineNumber, container); + return; + } + + var match = UseTypeRegex.Match(preparedLine); + if (!match.Success) + return; + + foreach (Capture capture in match.Groups["name"].Captures) + { + AddPhpTypeReferenceFromQualifiedName( + capture, + references, + seen, + fileId, + context, + lineNumber, + container); + } + } + + public static void EmitUseFunctionReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("use", StringComparison.OrdinalIgnoreCase) < 0 + || preparedLine.IndexOf("function", StringComparison.OrdinalIgnoreCase) < 0) + { + return; + } + + var groupFunctionMatch = GroupUseFunctionRegex.Match(preparedLine); + if (groupFunctionMatch.Success) + { + EmitGroupUseImportReferences(groupFunctionMatch, references, seen, fileId, context, lineNumber, container, "function", requireImportKind: false); + return; + } + + var groupMatch = GroupUseTypeRegex.Match(preparedLine); + if (groupMatch.Success) + { + EmitGroupUseImportReferences(groupMatch, references, seen, fileId, context, lineNumber, container, "function", requireImportKind: true); + return; + } + + var match = UseFunctionRegex.Match(preparedLine); + if (!match.Success) + return; + + var importsGroup = match.Groups["imports"]; + foreach (Match itemMatch in UseImportItemRegex.Matches(importsGroup.Value)) + { + var itemGroup = itemMatch.Groups["name"]; + AddPhpReferenceFromName( + itemGroup.Value, + importsGroup.Index + itemGroup.Index, + "reference", + references, + seen, + fileId, + context, + lineNumber, + container); + } + } + + public static void EmitUseConstReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("use", StringComparison.OrdinalIgnoreCase) < 0 + || preparedLine.IndexOf("const", StringComparison.OrdinalIgnoreCase) < 0) + { + return; + } + + var groupConstMatch = GroupUseConstRegex.Match(preparedLine); + if (groupConstMatch.Success) + { + EmitGroupUseImportReferences(groupConstMatch, references, seen, fileId, context, lineNumber, container, "const", requireImportKind: false); + return; + } + + var groupMatch = GroupUseTypeRegex.Match(preparedLine); + if (groupMatch.Success) + { + EmitGroupUseImportReferences(groupMatch, references, seen, fileId, context, lineNumber, container, "const", requireImportKind: true); + return; + } + + var match = UseConstRegex.Match(preparedLine); + if (!match.Success) + return; + + var importsGroup = match.Groups["imports"]; + foreach (Match itemMatch in UseImportItemRegex.Matches(importsGroup.Value)) + { + var itemGroup = itemMatch.Groups["name"]; + AddPhpReferenceFromName( + itemGroup.Value, + importsGroup.Index + itemGroup.Index, + "reference", + references, + seen, + fileId, + context, + lineNumber, + container); + } + } + + private static void EmitGroupUseTypeReferences( + Match groupMatch, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + var prefixGroup = groupMatch.Groups["prefix"]; + var rawPrefix = prefixGroup.Value; + var prefixEnd = rawPrefix.Length; + while (prefixEnd > 0 && rawPrefix[prefixEnd - 1] == '\\') + prefixEnd--; + if (prefixEnd == 0) + return; + + var prefix = prefixEnd == rawPrefix.Length ? rawPrefix : rawPrefix.Substring(0, prefixEnd); + var itemsGroup = groupMatch.Groups["items"]; + foreach (Match itemMatch in GroupUseTypeItemRegex.Matches(itemsGroup.Value)) + { + if (itemMatch.Groups["kind"].Success) + continue; + + var itemGroup = itemMatch.Groups["name"]; + var rawItemName = itemGroup.Value; + var leadingBackslashCount = 0; + while (leadingBackslashCount < rawItemName.Length && rawItemName[leadingBackslashCount] == '\\') + leadingBackslashCount++; + if (leadingBackslashCount == rawItemName.Length) + continue; + + var trimmedItemName = rawItemName.Substring(leadingBackslashCount); + var itemShortNameStart = trimmedItemName.LastIndexOf('\\') + 1; + var shortNameIndex = itemsGroup.Index + itemGroup.Index + leadingBackslashCount + itemShortNameStart; + AddPhpTypeReferenceFromName( + prefix + "\\" + trimmedItemName, + prefixGroup.Index, + references, + seen, + fileId, + context, + lineNumber, + container, + shortNameIndex); + } + } + + private static void EmitGroupUseImportReferences( + Match groupMatch, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + string importKind, + bool requireImportKind) + { + var prefixGroup = groupMatch.Groups["prefix"]; + var rawPrefix = prefixGroup.Value; + var prefixEnd = rawPrefix.Length; + while (prefixEnd > 0 && rawPrefix[prefixEnd - 1] == '\\') + prefixEnd--; + if (prefixEnd == 0) + return; + + var prefix = prefixEnd == rawPrefix.Length ? rawPrefix : rawPrefix.Substring(0, prefixEnd); + var itemsGroup = groupMatch.Groups["items"]; + foreach (Match itemMatch in GroupUseTypeItemRegex.Matches(itemsGroup.Value)) + { + var isTargetKind = itemMatch.Groups["kind"].Success + && itemMatch.Groups["kind"].Value.Equals(importKind, StringComparison.OrdinalIgnoreCase); + if (requireImportKind != isTargetKind) + continue; + + var itemGroup = itemMatch.Groups["name"]; + var rawItemName = itemGroup.Value; + var leadingBackslashCount = 0; + while (leadingBackslashCount < rawItemName.Length && rawItemName[leadingBackslashCount] == '\\') + leadingBackslashCount++; + if (leadingBackslashCount == rawItemName.Length) + continue; + + var trimmedItemName = rawItemName.Substring(leadingBackslashCount); + var itemShortNameStart = trimmedItemName.LastIndexOf('\\') + 1; + var shortNameIndex = itemsGroup.Index + itemGroup.Index + leadingBackslashCount + itemShortNameStart; + AddPhpReferenceFromName( + prefix + "\\" + trimmedItemName, + prefixGroup.Index, + "reference", + references, + seen, + fileId, + context, + lineNumber, + container, + shortNameIndex); + } + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/PhpReferenceExtractor.Members.cs b/src/CodeIndex/Indexer/References/Languages/PhpReferenceExtractor.Members.cs new file mode 100644 index 000000000..5124ba396 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/PhpReferenceExtractor.Members.cs @@ -0,0 +1,141 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class PhpReferenceExtractor +{ + private static bool IsPhpCallAfterStaticMember(string line, int index) + { + while (index < line.Length && char.IsWhiteSpace(line[index])) + index++; + + return index < line.Length && line[index] == '('; + } + + private static bool IsPhpBuiltinTypeName(string name) + => !name.Contains('\\', StringComparison.Ordinal) + && BuiltinTypeNames.Contains(name); + + private static void AddPhpTypeReferenceFromQualifiedName( + Capture nameGroup, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + => AddPhpTypeReferenceFromName( + nameGroup.Value, + nameGroup.Index, + references, + seen, + fileId, + context, + lineNumber, + container); + + private static void AddPhpTypeReferenceFromName( + string rawName, + int nameIndex, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + int? shortNameIndexOverride = null) + => AddPhpReferenceFromName( + rawName, + nameIndex, + "type_reference", + references, + seen, + fileId, + context, + lineNumber, + container, + shortNameIndexOverride); + + private static void AddPhpReferenceFromName( + string rawName, + int nameIndex, + string referenceKind, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + int? shortNameIndexOverride = null) + { + var leadingBackslashCount = 0; + while (leadingBackslashCount < rawName.Length && rawName[leadingBackslashCount] == '\\') + leadingBackslashCount++; + if (leadingBackslashCount == rawName.Length) + return; + + var trimmedName = rawName.Substring(leadingBackslashCount); + var qualifiedNameIndex = nameIndex + leadingBackslashCount; + if (trimmedName.Contains('\\', StringComparison.Ordinal)) + { + ReferenceExtractor.AddReference( + references, + seen, + fileId, + trimmedName, + qualifiedNameIndex, + referenceKind, + context, + lineNumber, + container); + } + + var shortNameStart = trimmedName.LastIndexOf('\\') + 1; + var shortName = trimmedName[shortNameStart..]; + if (shortName.Length == 0) + return; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + shortName, + shortNameIndexOverride ?? qualifiedNameIndex + shortNameStart, + referenceKind, + context, + lineNumber, + container); + } + + public static void EmitObjectMemberAccessReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("->", StringComparison.Ordinal) < 0) + { + return; + } + + foreach (Match match in ObjectMemberAccessRegex.Matches(preparedLine)) + { + var nameGroup = match.Groups["name"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + nameGroup.Value, + nameGroup.Index, + "reference", + context, + lineNumber, + container); + } + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/PhpReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/PhpReferenceExtractor.cs index 1eeade83a..e3c330187 100644 --- a/src/CodeIndex/Indexer/References/Languages/PhpReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/PhpReferenceExtractor.cs @@ -4,7 +4,7 @@ namespace CodeIndex.Indexer; -internal static class PhpReferenceExtractor +internal static partial class PhpReferenceExtractor { private static readonly Regex StaticAccessRegex = new( @"(?(?:\\?[A-Za-z_]\w*(?:\\[A-Za-z_]\w*)*))::\$?(?[A-Za-z_]\w*)", @@ -474,694 +474,4 @@ private static void EmitDocblockTypeGroupReferences( } } - public static void EmitAttributeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (!preparedLine.Contains("#[", StringComparison.Ordinal)) - return; - - foreach (Match match in AttributeRegex.Matches(preparedLine)) - { - var nameGroup = match.Groups["name"]; - var rawName = nameGroup.Value; - var leadingBackslashCount = 0; - while (leadingBackslashCount < rawName.Length && rawName[leadingBackslashCount] == '\\') - leadingBackslashCount++; - if (leadingBackslashCount == rawName.Length) - continue; - - var trimmedName = rawName.Substring(leadingBackslashCount); - var qualifiedNameIndex = nameGroup.Index + leadingBackslashCount; - if (trimmedName.Contains('\\', StringComparison.Ordinal)) - { - ReferenceExtractor.AddReference( - references, - seen, - fileId, - trimmedName, - qualifiedNameIndex, - "type_reference", - context, - lineNumber, - container); - } - - var shortNameStart = trimmedName.LastIndexOf('\\') + 1; - var shortName = trimmedName[shortNameStart..]; - if (shortName.Length == 0) - continue; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - shortName, - qualifiedNameIndex + shortNameStart, - "type_reference", - context, - lineNumber, - container); - } - } - - public static void EmitStaticAccessReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("::", StringComparison.Ordinal) < 0) - return; - - foreach (Match match in StaticAccessRegex.Matches(preparedLine)) - { - var nameGroup = match.Groups["name"]; - var rawName = nameGroup.Value; - var leadingBackslashCount = 0; - while (leadingBackslashCount < rawName.Length && rawName[leadingBackslashCount] == '\\') - leadingBackslashCount++; - if (leadingBackslashCount == rawName.Length) - continue; - - var trimmedName = rawName.Substring(leadingBackslashCount); - var shortNameStart = trimmedName.LastIndexOf('\\') + 1; - var shortName = trimmedName[shortNameStart..]; - if (shortName.Length == 0) - continue; - - var qualifiedNameIndex = nameGroup.Index + leadingBackslashCount; - if (trimmedName.Length > shortName.Length) - { - ReferenceExtractor.AddReference( - references, - seen, - fileId, - trimmedName, - qualifiedNameIndex, - "type_reference", - context, - lineNumber, - container); - } - - if (!string.Equals(shortName, "self", StringComparison.OrdinalIgnoreCase) - && !string.Equals(shortName, "static", StringComparison.OrdinalIgnoreCase) - && !string.Equals(shortName, "parent", StringComparison.OrdinalIgnoreCase)) - { - var shortNameIndex = qualifiedNameIndex + shortNameStart; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - shortName, - shortNameIndex, - "type_reference", - context, - lineNumber, - container); - } - - var memberGroup = match.Groups["member"]; - if (memberGroup.Success - && !memberGroup.Value.Equals("class", StringComparison.OrdinalIgnoreCase) - && !IsPhpCallAfterStaticMember(preparedLine, memberGroup.Index + memberGroup.Length)) - { - ReferenceExtractor.AddReference( - references, - seen, - fileId, - memberGroup.Value, - memberGroup.Index, - "reference", - context, - lineNumber, - container); - } - } - } - - public static void EmitInstanceofReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("instanceof", StringComparison.OrdinalIgnoreCase) < 0) - return; - - foreach (Match match in InstanceofRegex.Matches(preparedLine)) - { - AddPhpTypeReferenceFromQualifiedName( - match.Groups["name"], - references, - seen, - fileId, - context, - lineNumber, - container); - } - } - - public static void EmitCatchTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("catch", StringComparison.OrdinalIgnoreCase) < 0) - return; - - foreach (Match match in CatchTypeRegex.Matches(preparedLine)) - { - foreach (Capture capture in match.Groups["name"].Captures) - { - AddPhpTypeReferenceFromQualifiedName( - capture, - references, - seen, - fileId, - context, - lineNumber, - container); - } - } - } - - public static void EmitReturnTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf(':') < 0 - || preparedLine.IndexOf(')') < 0) - { - return; - } - - foreach (Match match in ReturnTypeRegex.Matches(preparedLine)) - { - foreach (Capture capture in match.Groups["name"].Captures) - { - if (IsPhpBuiltinTypeName(capture.Value)) - continue; - - AddPhpTypeReferenceFromQualifiedName( - capture, - references, - seen, - fileId, - context, - lineNumber, - container); - } - } - } - - public static void EmitParameterTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf('$') < 0) - return; - - foreach (Match match in ParameterTypeRegex.Matches(preparedLine)) - { - foreach (Capture capture in match.Groups["name"].Captures) - { - if (IsPhpBuiltinTypeName(capture.Value)) - continue; - - AddPhpTypeReferenceFromQualifiedName( - capture, - references, - seen, - fileId, - context, - lineNumber, - container); - } - } - } - - public static void EmitPropertyTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf('$') < 0 - || (preparedLine.IndexOf("public", StringComparison.OrdinalIgnoreCase) < 0 - && preparedLine.IndexOf("private", StringComparison.OrdinalIgnoreCase) < 0 - && preparedLine.IndexOf("protected", StringComparison.OrdinalIgnoreCase) < 0 - && preparedLine.IndexOf("var", StringComparison.OrdinalIgnoreCase) < 0)) - { - return; - } - - foreach (Match match in PropertyTypeRegex.Matches(preparedLine)) - { - foreach (Capture capture in match.Groups["name"].Captures) - { - if (IsPhpBuiltinTypeName(capture.Value)) - continue; - - AddPhpTypeReferenceFromQualifiedName( - capture, - references, - seen, - fileId, - context, - lineNumber, - container); - } - } - } - - public static void EmitInheritanceTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("extends", StringComparison.OrdinalIgnoreCase) < 0 - && preparedLine.IndexOf("implements", StringComparison.OrdinalIgnoreCase) < 0) - { - return; - } - - foreach (Match match in InheritanceTypeRegex.Matches(preparedLine)) - { - foreach (Capture capture in match.Groups["name"].Captures) - { - AddPhpTypeReferenceFromQualifiedName( - capture, - references, - seen, - fileId, - context, - lineNumber, - container); - } - } - } - - public static void EmitUseTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("use", StringComparison.OrdinalIgnoreCase) < 0) - return; - - var groupMatch = GroupUseTypeRegex.Match(preparedLine); - if (groupMatch.Success) - { - EmitGroupUseTypeReferences(groupMatch, references, seen, fileId, context, lineNumber, container); - return; - } - - var match = UseTypeRegex.Match(preparedLine); - if (!match.Success) - return; - - foreach (Capture capture in match.Groups["name"].Captures) - { - AddPhpTypeReferenceFromQualifiedName( - capture, - references, - seen, - fileId, - context, - lineNumber, - container); - } - } - - public static void EmitUseFunctionReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("use", StringComparison.OrdinalIgnoreCase) < 0 - || preparedLine.IndexOf("function", StringComparison.OrdinalIgnoreCase) < 0) - { - return; - } - - var groupFunctionMatch = GroupUseFunctionRegex.Match(preparedLine); - if (groupFunctionMatch.Success) - { - EmitGroupUseImportReferences(groupFunctionMatch, references, seen, fileId, context, lineNumber, container, "function", requireImportKind: false); - return; - } - - var groupMatch = GroupUseTypeRegex.Match(preparedLine); - if (groupMatch.Success) - { - EmitGroupUseImportReferences(groupMatch, references, seen, fileId, context, lineNumber, container, "function", requireImportKind: true); - return; - } - - var match = UseFunctionRegex.Match(preparedLine); - if (!match.Success) - return; - - var importsGroup = match.Groups["imports"]; - foreach (Match itemMatch in UseImportItemRegex.Matches(importsGroup.Value)) - { - var itemGroup = itemMatch.Groups["name"]; - AddPhpReferenceFromName( - itemGroup.Value, - importsGroup.Index + itemGroup.Index, - "reference", - references, - seen, - fileId, - context, - lineNumber, - container); - } - } - - public static void EmitUseConstReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("use", StringComparison.OrdinalIgnoreCase) < 0 - || preparedLine.IndexOf("const", StringComparison.OrdinalIgnoreCase) < 0) - { - return; - } - - var groupConstMatch = GroupUseConstRegex.Match(preparedLine); - if (groupConstMatch.Success) - { - EmitGroupUseImportReferences(groupConstMatch, references, seen, fileId, context, lineNumber, container, "const", requireImportKind: false); - return; - } - - var groupMatch = GroupUseTypeRegex.Match(preparedLine); - if (groupMatch.Success) - { - EmitGroupUseImportReferences(groupMatch, references, seen, fileId, context, lineNumber, container, "const", requireImportKind: true); - return; - } - - var match = UseConstRegex.Match(preparedLine); - if (!match.Success) - return; - - var importsGroup = match.Groups["imports"]; - foreach (Match itemMatch in UseImportItemRegex.Matches(importsGroup.Value)) - { - var itemGroup = itemMatch.Groups["name"]; - AddPhpReferenceFromName( - itemGroup.Value, - importsGroup.Index + itemGroup.Index, - "reference", - references, - seen, - fileId, - context, - lineNumber, - container); - } - } - - private static void EmitGroupUseTypeReferences( - Match groupMatch, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - var prefixGroup = groupMatch.Groups["prefix"]; - var rawPrefix = prefixGroup.Value; - var prefixEnd = rawPrefix.Length; - while (prefixEnd > 0 && rawPrefix[prefixEnd - 1] == '\\') - prefixEnd--; - if (prefixEnd == 0) - return; - - var prefix = prefixEnd == rawPrefix.Length ? rawPrefix : rawPrefix.Substring(0, prefixEnd); - var itemsGroup = groupMatch.Groups["items"]; - foreach (Match itemMatch in GroupUseTypeItemRegex.Matches(itemsGroup.Value)) - { - if (itemMatch.Groups["kind"].Success) - continue; - - var itemGroup = itemMatch.Groups["name"]; - var rawItemName = itemGroup.Value; - var leadingBackslashCount = 0; - while (leadingBackslashCount < rawItemName.Length && rawItemName[leadingBackslashCount] == '\\') - leadingBackslashCount++; - if (leadingBackslashCount == rawItemName.Length) - continue; - - var trimmedItemName = rawItemName.Substring(leadingBackslashCount); - var itemShortNameStart = trimmedItemName.LastIndexOf('\\') + 1; - var shortNameIndex = itemsGroup.Index + itemGroup.Index + leadingBackslashCount + itemShortNameStart; - AddPhpTypeReferenceFromName( - prefix + "\\" + trimmedItemName, - prefixGroup.Index, - references, - seen, - fileId, - context, - lineNumber, - container, - shortNameIndex); - } - } - - private static void EmitGroupUseImportReferences( - Match groupMatch, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - string importKind, - bool requireImportKind) - { - var prefixGroup = groupMatch.Groups["prefix"]; - var rawPrefix = prefixGroup.Value; - var prefixEnd = rawPrefix.Length; - while (prefixEnd > 0 && rawPrefix[prefixEnd - 1] == '\\') - prefixEnd--; - if (prefixEnd == 0) - return; - - var prefix = prefixEnd == rawPrefix.Length ? rawPrefix : rawPrefix.Substring(0, prefixEnd); - var itemsGroup = groupMatch.Groups["items"]; - foreach (Match itemMatch in GroupUseTypeItemRegex.Matches(itemsGroup.Value)) - { - var isTargetKind = itemMatch.Groups["kind"].Success - && itemMatch.Groups["kind"].Value.Equals(importKind, StringComparison.OrdinalIgnoreCase); - if (requireImportKind != isTargetKind) - continue; - - var itemGroup = itemMatch.Groups["name"]; - var rawItemName = itemGroup.Value; - var leadingBackslashCount = 0; - while (leadingBackslashCount < rawItemName.Length && rawItemName[leadingBackslashCount] == '\\') - leadingBackslashCount++; - if (leadingBackslashCount == rawItemName.Length) - continue; - - var trimmedItemName = rawItemName.Substring(leadingBackslashCount); - var itemShortNameStart = trimmedItemName.LastIndexOf('\\') + 1; - var shortNameIndex = itemsGroup.Index + itemGroup.Index + leadingBackslashCount + itemShortNameStart; - AddPhpReferenceFromName( - prefix + "\\" + trimmedItemName, - prefixGroup.Index, - "reference", - references, - seen, - fileId, - context, - lineNumber, - container, - shortNameIndex); - } - } - - private static bool IsPhpCallAfterStaticMember(string line, int index) - { - while (index < line.Length && char.IsWhiteSpace(line[index])) - index++; - - return index < line.Length && line[index] == '('; - } - - private static bool IsPhpBuiltinTypeName(string name) - => !name.Contains('\\', StringComparison.Ordinal) - && BuiltinTypeNames.Contains(name); - - private static void AddPhpTypeReferenceFromQualifiedName( - Capture nameGroup, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - => AddPhpTypeReferenceFromName( - nameGroup.Value, - nameGroup.Index, - references, - seen, - fileId, - context, - lineNumber, - container); - - private static void AddPhpTypeReferenceFromName( - string rawName, - int nameIndex, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - int? shortNameIndexOverride = null) - => AddPhpReferenceFromName( - rawName, - nameIndex, - "type_reference", - references, - seen, - fileId, - context, - lineNumber, - container, - shortNameIndexOverride); - - private static void AddPhpReferenceFromName( - string rawName, - int nameIndex, - string referenceKind, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - int? shortNameIndexOverride = null) - { - var leadingBackslashCount = 0; - while (leadingBackslashCount < rawName.Length && rawName[leadingBackslashCount] == '\\') - leadingBackslashCount++; - if (leadingBackslashCount == rawName.Length) - return; - - var trimmedName = rawName.Substring(leadingBackslashCount); - var qualifiedNameIndex = nameIndex + leadingBackslashCount; - if (trimmedName.Contains('\\', StringComparison.Ordinal)) - { - ReferenceExtractor.AddReference( - references, - seen, - fileId, - trimmedName, - qualifiedNameIndex, - referenceKind, - context, - lineNumber, - container); - } - - var shortNameStart = trimmedName.LastIndexOf('\\') + 1; - var shortName = trimmedName[shortNameStart..]; - if (shortName.Length == 0) - return; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - shortName, - shortNameIndexOverride ?? qualifiedNameIndex + shortNameStart, - referenceKind, - context, - lineNumber, - container); - } - - public static void EmitObjectMemberAccessReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("->", StringComparison.Ordinal) < 0) - { - return; - } - - foreach (Match match in ObjectMemberAccessRegex.Matches(preparedLine)) - { - var nameGroup = match.Groups["name"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - nameGroup.Value, - nameGroup.Index, - "reference", - context, - lineNumber, - container); - } - } } diff --git a/src/CodeIndex/Indexer/References/Languages/PythonReferenceExtractor.RuntimeTypes.cs b/src/CodeIndex/Indexer/References/Languages/PythonReferenceExtractor.RuntimeTypes.cs new file mode 100644 index 000000000..3411aaa93 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/PythonReferenceExtractor.RuntimeTypes.cs @@ -0,0 +1,370 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class PythonReferenceExtractor +{ + public static void EmitRaiseReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Func isIgnoredName) + { + if (!StartsWithPythonKeywordStatement(preparedLine, "raise")) + return; + + foreach (Match match in BareRaiseTypeRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + match.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + + public static void EmitExceptReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Func isIgnoredName) + { + if (!StartsWithPythonKeywordStatement(preparedLine, "except")) + return; + + if (preparedLine.IndexOf('(') >= 0) + { + foreach (Match match in ExceptTupleTypeRegex.Matches(preparedLine)) + { + var typesGroup = match.Groups["types"]; + foreach (Match typeMatch in TypeNameRegex.Matches(typesGroup.Value)) + { + var name = typeMatch.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + typesGroup.Index + typeMatch.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + } + + foreach (Match match in ExceptTypeRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + match.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + + public static void EmitIsInstanceReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Func isIgnoredName) + { + if (preparedLine.IndexOf("isinstance", StringComparison.Ordinal) < 0) + return; + + if (MayContainPythonTupleArgument(preparedLine)) + { + foreach (Match match in IsInstanceTupleTypeRegex.Matches(preparedLine)) + { + var typesGroup = match.Groups["types"]; + foreach (Match typeMatch in TypeNameRegex.Matches(typesGroup.Value)) + { + var name = typeMatch.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + typesGroup.Index + typeMatch.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + } + + foreach (Match match in IsInstanceTypeRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + match.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + + public static void EmitIsSubclassReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Func isIgnoredName) + { + if (preparedLine.IndexOf("issubclass", StringComparison.Ordinal) < 0) + return; + + if (MayContainPythonTupleArgument(preparedLine)) + { + foreach (Match match in IsSubclassTupleTypeRegex.Matches(preparedLine)) + { + var typesGroup = match.Groups["types"]; + foreach (Match typeMatch in TypeNameRegex.Matches(typesGroup.Value)) + { + var name = typeMatch.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + typesGroup.Index + typeMatch.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + } + + foreach (Match match in IsSubclassTypeRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + match.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + + public static void EmitCastReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Func isIgnoredName) + { + if (preparedLine.IndexOf("cast", StringComparison.Ordinal) < 0) + return; + + if (preparedLine.IndexOf("typing", StringComparison.Ordinal) >= 0) + { + foreach (Match match in QualifiedCastTypeRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + match.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + + foreach (Match match in CastTypeRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + match.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + + private static bool MayContainPythonTupleArgument(string preparedLine) + { + var commaIndex = preparedLine.IndexOf(','); + while (commaIndex >= 0) + { + var index = commaIndex + 1; + while (index < preparedLine.Length && char.IsWhiteSpace(preparedLine[index])) + index++; + + if (index < preparedLine.Length && preparedLine[index] == '(') + return true; + + commaIndex = preparedLine.IndexOf(',', commaIndex + 1); + } + + return false; + } + + private static bool StartsWithPythonKeywordStatement(string preparedLine, string keyword) + { + var index = SkipPythonWhitespace(preparedLine, 0); + return StartsWithPythonKeywordAt(preparedLine, index, keyword); + } + + private static bool StartsWithPythonDefStatement(string preparedLine) + { + var index = SkipPythonWhitespace(preparedLine, 0); + if (StartsWithPythonKeywordAt(preparedLine, index, "async")) + index = SkipPythonWhitespace(preparedLine, index + "async".Length); + + return StartsWithPythonKeywordAt(preparedLine, index, "def"); + } + + private static bool StartsWithPythonKeywordAt(string preparedLine, int index, string keyword) + { + if (!preparedLine.AsSpan(index).StartsWith(keyword, StringComparison.Ordinal)) + return false; + + var after = index + keyword.Length; + return after >= preparedLine.Length || !IsPythonIdentifierContinue(preparedLine[after]); + } + + private static int SkipPythonWhitespace(string preparedLine, int index) + { + while (index < preparedLine.Length && char.IsWhiteSpace(preparedLine[index])) + index++; + return index; + } + + private static bool IsPythonIdentifierContinue(char ch) => + char.IsLetterOrDigit(ch) || ch == '_'; + + public static void EmitAssertTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Func isIgnoredName) + { + if (preparedLine.IndexOf("assert_type", StringComparison.Ordinal) < 0) + return; + + if (preparedLine.IndexOf("typing", StringComparison.Ordinal) >= 0) + { + foreach (Match match in QualifiedAssertTypeRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + match.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + + foreach (Match match in AssertTypeRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + match.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/PythonReferenceExtractor.TypingFactories.cs b/src/CodeIndex/Indexer/References/Languages/PythonReferenceExtractor.TypingFactories.cs new file mode 100644 index 000000000..1e044e141 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/PythonReferenceExtractor.TypingFactories.cs @@ -0,0 +1,281 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class PythonReferenceExtractor +{ + public static void EmitTypeAliasReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Func isIgnoredName) + { + if (preparedLine.IndexOf('=') < 0) + return; + if (preparedLine.IndexOf("TypeAlias", StringComparison.Ordinal) < 0 + && !MayStartPythonTypeAliasStatement(preparedLine)) + return; + + foreach (Match match in TypeAliasRhsExpressionRegex.Matches(preparedLine)) + { + var typeGroup = match.Groups["type"]; + EmitPythonTypeExpressionReferences( + typeGroup, + references, + seen, + fileId, + context, + lineNumber, + container, + resolveContainerForReference: null, + isIgnoredName); + } + } + + private static bool MayStartPythonTypeAliasStatement(string preparedLine) + { + var index = 0; + while (index < preparedLine.Length && char.IsWhiteSpace(preparedLine[index])) + index++; + + if (index + "type".Length >= preparedLine.Length) + return false; + + if (!preparedLine.AsSpan(index).StartsWith("type", StringComparison.Ordinal)) + return false; + + return char.IsWhiteSpace(preparedLine[index + "type".Length]); + } + + public static void EmitNewTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Func isIgnoredName) + { + if (preparedLine.IndexOf("NewType", StringComparison.Ordinal) < 0) + return; + + foreach (Match match in NewTypeUnderlyingTypeRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + match.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + + public static void EmitTypeVarBoundReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Func isIgnoredName) + { + if ((preparedLine.IndexOf("TypeVar", StringComparison.Ordinal) < 0 + && preparedLine.IndexOf("ParamSpec", StringComparison.Ordinal) < 0) + || preparedLine.IndexOf("bound", StringComparison.Ordinal) < 0) + { + return; + } + + foreach (Match match in TypeVarBoundTypeRegex.Matches(preparedLine)) + { + EmitPythonTypeExpressionReferences( + match.Groups["type"], + references, + seen, + fileId, + context, + lineNumber, + container, + resolveContainerForReference: null, + isIgnoredName); + } + } + + public static void EmitTypeVarConstraintReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Func isIgnoredName) + { + if (preparedLine.IndexOf("TypeVar", StringComparison.Ordinal) < 0 + && preparedLine.IndexOf("ParamSpec", StringComparison.Ordinal) < 0) + { + return; + } + if (preparedLine.IndexOf(',') < 0) + return; + + foreach (Match match in TypeVarConstraintTypesRegex.Matches(preparedLine)) + { + var typesGroup = match.Groups["types"]; + EmitPythonTypeExpressionReferences( + typesGroup, + references, + seen, + fileId, + context, + lineNumber, + container, + resolveContainerForReference: null, + isIgnoredName); + } + } + + public static void EmitGetTypeHintsReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Func isIgnoredName) + { + if (preparedLine.IndexOf("get_type_hints", StringComparison.Ordinal) < 0) + return; + + if (preparedLine.IndexOf("typing", StringComparison.Ordinal) >= 0) + { + foreach (Match match in QualifiedGetTypeHintsTargetRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + match.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + + foreach (Match match in GetTypeHintsTargetRegex.Matches(preparedLine)) + { + var name = match.Groups["name"].Value; + if (isIgnoredName(name)) + continue; + + ReferenceExtractor.AddTypeReferenceSegments( + references, + seen, + fileId, + name, + match.Groups["name"].Index, + context, + lineNumber, + container, + "python"); + } + } + + public static void EmitDynamicImportReferences( + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf('(') < 0) + return; + + if (preparedLine.IndexOf("importlib", StringComparison.Ordinal) >= 0) + { + foreach (Match match in ImportlibDynamicImportRegex.Matches(preparedLine)) + { + ReferenceExtractor.AddReference( + references, + seen, + fileId, + "importlib", + match.Index, + "call", + context, + lineNumber, + container, + "python"); + + var literalMatch = ImportlibDynamicImportLiteralRegex.Match(originalLine, match.Index); + if (!literalMatch.Success || literalMatch.Index != match.Index) + continue; + + var moduleGroup = literalMatch.Groups["module"]; + if (moduleGroup.Success && moduleGroup.Value.Length > 0) + { + ReferenceExtractor.AddReference( + references, + seen, + fileId, + moduleGroup.Value, + moduleGroup.Index, + "import", + context, + lineNumber, + container, + "python"); + } + } + } + + if (preparedLine.IndexOf("__import__", StringComparison.Ordinal) < 0) + return; + + foreach (Match match in BuiltinDynamicImportRegex.Matches(preparedLine)) + { + var literalMatch = BuiltinDynamicImportLiteralRegex.Match(originalLine, match.Index); + if (!literalMatch.Success || literalMatch.Index != match.Index) + continue; + + var moduleGroup = literalMatch.Groups["module"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + moduleGroup.Value, + moduleGroup.Index, + "import", + context, + lineNumber, + container, + "python"); + } + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/PythonReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/PythonReferenceExtractor.cs index 3cb80a58d..add67ceee 100644 --- a/src/CodeIndex/Indexer/References/Languages/PythonReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/PythonReferenceExtractor.cs @@ -385,637 +385,4 @@ private static bool IsPythonLiteralName(string name) return name is "True" or "False" or "None" or "Ellipsis"; } - public static void EmitRaiseReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Func isIgnoredName) - { - if (!StartsWithPythonKeywordStatement(preparedLine, "raise")) - return; - - foreach (Match match in BareRaiseTypeRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - match.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - - public static void EmitExceptReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Func isIgnoredName) - { - if (!StartsWithPythonKeywordStatement(preparedLine, "except")) - return; - - if (preparedLine.IndexOf('(') >= 0) - { - foreach (Match match in ExceptTupleTypeRegex.Matches(preparedLine)) - { - var typesGroup = match.Groups["types"]; - foreach (Match typeMatch in TypeNameRegex.Matches(typesGroup.Value)) - { - var name = typeMatch.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - typesGroup.Index + typeMatch.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - } - - foreach (Match match in ExceptTypeRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - match.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - - public static void EmitIsInstanceReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Func isIgnoredName) - { - if (preparedLine.IndexOf("isinstance", StringComparison.Ordinal) < 0) - return; - - if (MayContainPythonTupleArgument(preparedLine)) - { - foreach (Match match in IsInstanceTupleTypeRegex.Matches(preparedLine)) - { - var typesGroup = match.Groups["types"]; - foreach (Match typeMatch in TypeNameRegex.Matches(typesGroup.Value)) - { - var name = typeMatch.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - typesGroup.Index + typeMatch.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - } - - foreach (Match match in IsInstanceTypeRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - match.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - - public static void EmitIsSubclassReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Func isIgnoredName) - { - if (preparedLine.IndexOf("issubclass", StringComparison.Ordinal) < 0) - return; - - if (MayContainPythonTupleArgument(preparedLine)) - { - foreach (Match match in IsSubclassTupleTypeRegex.Matches(preparedLine)) - { - var typesGroup = match.Groups["types"]; - foreach (Match typeMatch in TypeNameRegex.Matches(typesGroup.Value)) - { - var name = typeMatch.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - typesGroup.Index + typeMatch.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - } - - foreach (Match match in IsSubclassTypeRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - match.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - - public static void EmitCastReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Func isIgnoredName) - { - if (preparedLine.IndexOf("cast", StringComparison.Ordinal) < 0) - return; - - if (preparedLine.IndexOf("typing", StringComparison.Ordinal) >= 0) - { - foreach (Match match in QualifiedCastTypeRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - match.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - - foreach (Match match in CastTypeRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - match.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - - private static bool MayContainPythonTupleArgument(string preparedLine) - { - var commaIndex = preparedLine.IndexOf(','); - while (commaIndex >= 0) - { - var index = commaIndex + 1; - while (index < preparedLine.Length && char.IsWhiteSpace(preparedLine[index])) - index++; - - if (index < preparedLine.Length && preparedLine[index] == '(') - return true; - - commaIndex = preparedLine.IndexOf(',', commaIndex + 1); - } - - return false; - } - - private static bool StartsWithPythonKeywordStatement(string preparedLine, string keyword) - { - var index = SkipPythonWhitespace(preparedLine, 0); - return StartsWithPythonKeywordAt(preparedLine, index, keyword); - } - - private static bool StartsWithPythonDefStatement(string preparedLine) - { - var index = SkipPythonWhitespace(preparedLine, 0); - if (StartsWithPythonKeywordAt(preparedLine, index, "async")) - index = SkipPythonWhitespace(preparedLine, index + "async".Length); - - return StartsWithPythonKeywordAt(preparedLine, index, "def"); - } - - private static bool StartsWithPythonKeywordAt(string preparedLine, int index, string keyword) - { - if (!preparedLine.AsSpan(index).StartsWith(keyword, StringComparison.Ordinal)) - return false; - - var after = index + keyword.Length; - return after >= preparedLine.Length || !IsPythonIdentifierContinue(preparedLine[after]); - } - - private static int SkipPythonWhitespace(string preparedLine, int index) - { - while (index < preparedLine.Length && char.IsWhiteSpace(preparedLine[index])) - index++; - return index; - } - - private static bool IsPythonIdentifierContinue(char ch) => - char.IsLetterOrDigit(ch) || ch == '_'; - - public static void EmitAssertTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Func isIgnoredName) - { - if (preparedLine.IndexOf("assert_type", StringComparison.Ordinal) < 0) - return; - - if (preparedLine.IndexOf("typing", StringComparison.Ordinal) >= 0) - { - foreach (Match match in QualifiedAssertTypeRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - match.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - - foreach (Match match in AssertTypeRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - match.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - - public static void EmitTypeAliasReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Func isIgnoredName) - { - if (preparedLine.IndexOf('=') < 0) - return; - if (preparedLine.IndexOf("TypeAlias", StringComparison.Ordinal) < 0 - && !MayStartPythonTypeAliasStatement(preparedLine)) - return; - - foreach (Match match in TypeAliasRhsExpressionRegex.Matches(preparedLine)) - { - var typeGroup = match.Groups["type"]; - EmitPythonTypeExpressionReferences( - typeGroup, - references, - seen, - fileId, - context, - lineNumber, - container, - resolveContainerForReference: null, - isIgnoredName); - } - } - - private static bool MayStartPythonTypeAliasStatement(string preparedLine) - { - var index = 0; - while (index < preparedLine.Length && char.IsWhiteSpace(preparedLine[index])) - index++; - - if (index + "type".Length >= preparedLine.Length) - return false; - - if (!preparedLine.AsSpan(index).StartsWith("type", StringComparison.Ordinal)) - return false; - - return char.IsWhiteSpace(preparedLine[index + "type".Length]); - } - - public static void EmitNewTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Func isIgnoredName) - { - if (preparedLine.IndexOf("NewType", StringComparison.Ordinal) < 0) - return; - - foreach (Match match in NewTypeUnderlyingTypeRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - match.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - - public static void EmitTypeVarBoundReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Func isIgnoredName) - { - if ((preparedLine.IndexOf("TypeVar", StringComparison.Ordinal) < 0 - && preparedLine.IndexOf("ParamSpec", StringComparison.Ordinal) < 0) - || preparedLine.IndexOf("bound", StringComparison.Ordinal) < 0) - { - return; - } - - foreach (Match match in TypeVarBoundTypeRegex.Matches(preparedLine)) - { - EmitPythonTypeExpressionReferences( - match.Groups["type"], - references, - seen, - fileId, - context, - lineNumber, - container, - resolveContainerForReference: null, - isIgnoredName); - } - } - - public static void EmitTypeVarConstraintReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Func isIgnoredName) - { - if (preparedLine.IndexOf("TypeVar", StringComparison.Ordinal) < 0 - && preparedLine.IndexOf("ParamSpec", StringComparison.Ordinal) < 0) - { - return; - } - if (preparedLine.IndexOf(',') < 0) - return; - - foreach (Match match in TypeVarConstraintTypesRegex.Matches(preparedLine)) - { - var typesGroup = match.Groups["types"]; - EmitPythonTypeExpressionReferences( - typesGroup, - references, - seen, - fileId, - context, - lineNumber, - container, - resolveContainerForReference: null, - isIgnoredName); - } - } - - public static void EmitGetTypeHintsReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Func isIgnoredName) - { - if (preparedLine.IndexOf("get_type_hints", StringComparison.Ordinal) < 0) - return; - - if (preparedLine.IndexOf("typing", StringComparison.Ordinal) >= 0) - { - foreach (Match match in QualifiedGetTypeHintsTargetRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - match.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - - foreach (Match match in GetTypeHintsTargetRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (isIgnoredName(name)) - continue; - - ReferenceExtractor.AddTypeReferenceSegments( - references, - seen, - fileId, - name, - match.Groups["name"].Index, - context, - lineNumber, - container, - "python"); - } - } - - public static void EmitDynamicImportReferences( - string preparedLine, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf('(') < 0) - return; - - if (preparedLine.IndexOf("importlib", StringComparison.Ordinal) >= 0) - { - foreach (Match match in ImportlibDynamicImportRegex.Matches(preparedLine)) - { - ReferenceExtractor.AddReference( - references, - seen, - fileId, - "importlib", - match.Index, - "call", - context, - lineNumber, - container, - "python"); - - var literalMatch = ImportlibDynamicImportLiteralRegex.Match(originalLine, match.Index); - if (!literalMatch.Success || literalMatch.Index != match.Index) - continue; - - var moduleGroup = literalMatch.Groups["module"]; - if (moduleGroup.Success && moduleGroup.Value.Length > 0) - { - ReferenceExtractor.AddReference( - references, - seen, - fileId, - moduleGroup.Value, - moduleGroup.Index, - "import", - context, - lineNumber, - container, - "python"); - } - } - } - - if (preparedLine.IndexOf("__import__", StringComparison.Ordinal) < 0) - return; - - foreach (Match match in BuiltinDynamicImportRegex.Matches(preparedLine)) - { - var literalMatch = BuiltinDynamicImportLiteralRegex.Match(originalLine, match.Index); - if (!literalMatch.Success || literalMatch.Index != match.Index) - continue; - - var moduleGroup = literalMatch.Groups["module"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - moduleGroup.Value, - moduleGroup.Index, - "import", - context, - lineNumber, - container, - "python"); - } - } } From 51b599ebc05dab048a6e35bf644308ec61e9a0fd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:34:49 +0900 Subject: [PATCH 049/101] Split specialized language reference extractors --- ...ferenceExtractor.AnimationsAndSelectors.cs | 546 +++++++++++++ ...eferenceExtractor.PreprocessorFiltering.cs | 167 ++++ .../Languages/CssReferenceExtractor.cs | 697 +--------------- .../HdlReferenceExtractor.Masking.cs | 174 ++++ .../Languages/HdlReferenceExtractor.Scopes.cs | 368 +++++++++ .../Languages/HdlReferenceExtractor.cs | 524 ------------ .../RReferenceExtractor.CallsAndResources.cs | 489 +++++++++++ .../Languages/RReferenceExtractor.Members.cs | 233 ++++++ .../Languages/RReferenceExtractor.cs | 706 +--------------- .../SwiftReferenceExtractor.Declarations.cs | 452 +++++++++++ .../SwiftReferenceExtractor.GenericCalls.cs | 325 ++++++++ .../Languages/SwiftReferenceExtractor.cs | 761 +----------------- 12 files changed, 2757 insertions(+), 2685 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.AnimationsAndSelectors.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.PreprocessorFiltering.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/HdlReferenceExtractor.Masking.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/HdlReferenceExtractor.Scopes.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/RReferenceExtractor.CallsAndResources.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/RReferenceExtractor.Members.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.Declarations.cs create mode 100644 src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.GenericCalls.cs diff --git a/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.AnimationsAndSelectors.cs b/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.AnimationsAndSelectors.cs new file mode 100644 index 000000000..77e069c7b --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.AnimationsAndSelectors.cs @@ -0,0 +1,546 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class CssReferenceExtractor +{ + private static void EmitMatches( + ReferencePattern pattern, + string preparedLine, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + HashSet? definitionNames, + SymbolRecord? container) + { + foreach (Match match in BoundedRegex.EnumerateMatches(pattern.Regex, preparedLine)) + { + var nameGroup = match.Groups["name"]; + if (definitionNames != null && definitionNames.Contains(nameGroup.Value)) + continue; + + if (pattern.SkipVariableDeclarations && ShouldSkipScssVariableReference(preparedLine, nameGroup.Index)) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + nameGroup.Value, + nameGroup.Index, + pattern.Kind, + context, + lineNumber, + container); + } + } + + private static void EmitCssAnimationNameReferences( + string value, + int valueIndex, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + HashSet? definitionNames, + SymbolRecord? container) + { + var segmentStart = 0; + for (var i = 0; i <= value.Length; i++) + { + if (i < value.Length && value[i] != ',') + continue; + + EmitCssAnimationNameSegmentReference( + value, + valueIndex, + segmentStart, + i, + context, + lineNumber, + references, + seen, + fileId, + definitionNames, + container); + segmentStart = i + 1; + } + } + + private static void EmitCssAnimationNameSegmentReference( + string value, + int valueIndex, + int segmentStart, + int segmentEnd, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + HashSet? definitionNames, + SymbolRecord? container) + { + var cursor = segmentStart; + while (cursor < segmentEnd && char.IsWhiteSpace(value[cursor])) + cursor++; + if (cursor >= segmentEnd) + return; + + var tokenStart = cursor; + while (cursor < segmentEnd && !char.IsWhiteSpace(value[cursor])) + cursor++; + + var token = value[tokenStart..cursor]; + if (!IsCssAnimationNameToken(token)) + return; + if (definitionNames != null && definitionNames.Contains(token)) + return; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + token, + valueIndex + tokenStart, + "reference", + context, + lineNumber, + container); + } + + private static void EmitCssAnimationShorthandReferences( + string value, + int valueIndex, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + HashSet? definitionNames, + SymbolRecord? container) + { + var segmentStart = 0; + var parenDepth = 0; + for (var i = 0; i <= value.Length; i++) + { + if (i < value.Length) + { + var ch = value[i]; + if (ch == '(') + { + parenDepth++; + continue; + } + + if (ch == ')' && parenDepth > 0) + { + parenDepth--; + continue; + } + + if (ch != ',' || parenDepth > 0) + continue; + } + + EmitCssAnimationShorthandSegmentReference( + value, + valueIndex, + segmentStart, + i, + context, + lineNumber, + references, + seen, + fileId, + definitionNames, + container); + segmentStart = i + 1; + } + } + + private static void EmitCssAnimationShorthandSegmentReference( + string value, + int valueIndex, + int segmentStart, + int segmentEnd, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + HashSet? definitionNames, + SymbolRecord? container) + { + var cursor = segmentStart; + while (cursor < segmentEnd) + { + while (cursor < segmentEnd && char.IsWhiteSpace(value[cursor])) + cursor++; + if (cursor >= segmentEnd) + break; + + var tokenStart = cursor; + while (cursor < segmentEnd && !char.IsWhiteSpace(value[cursor])) + cursor++; + + var token = value[tokenStart..cursor]; + if (!IsCssAnimationNameToken(token)) + continue; + if (definitionNames != null && definitionNames.Contains(token)) + return; + + ReferenceExtractor.AddReference(references, seen, fileId, token, valueIndex + tokenStart, "reference", context, lineNumber, container); + return; + } + } + + private static void EmitCssClassSelectorReferences( + string preparedLine, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + HashSet? definitionNames, + SymbolRecord? container) + { + // ID selectors (`#name`) are emitted only in selector-position segments + // because `#fff` / `#abc123` color literals also match the regex. A + // segment is treated as selector position when it terminates at `{` + // on the current line (clear selector → block opener) or when the + // entire line is a selector-list continuation (trimmed line ends with `,`). + // ID セレクタ (`#name`) は `#fff` 等の color literal とパターンが衝突するため、 + // セレクタ位置のセグメントでのみ参照を発行する。セグメントが本行内で `{` で + // 終わる場合、または行末カンマで selector list が継続する場合をセレクタ位置とみなす。 + var isSelectorContinuationLine = preparedLine.TrimEnd().EndsWith(','); + var segmentStart = 0; + while (segmentStart < preparedLine.Length) + { + var braceIndex = preparedLine.IndexOf('{', segmentStart); + var segmentEnd = braceIndex >= 0 ? braceIndex : preparedLine.Length; + var trimmedStart = segmentStart; + while (trimmedStart < segmentEnd && char.IsWhiteSpace(preparedLine[trimmedStart])) + trimmedStart++; + + if (trimmedStart < segmentEnd && preparedLine[trimmedStart] != '@') + { + var selectorSegment = preparedLine[trimmedStart..segmentEnd]; + var isIdSelectorContext = braceIndex >= 0 + || (segmentStart == 0 && isSelectorContinuationLine); + foreach (var (partStart, partEnd) in EnumerateCssSelectorListSegments(selectorSegment)) + { + var selectorPart = selectorSegment[partStart..partEnd]; + var hasClassCandidate = ContainsCssClassSelectorReferenceCandidate(selectorPart); + var hasIdCandidate = isIdSelectorContext + && ContainsCssIdSelectorReferenceCandidate(selectorPart); + if (!hasClassCandidate && !hasIdCandidate) + continue; + + var selectorPartTrimStart = 0; + while (selectorPartTrimStart < selectorPart.Length && char.IsWhiteSpace(selectorPart[selectorPartTrimStart])) + selectorPartTrimStart++; + + var selectorPartBody = selectorPart[selectorPartTrimStart..]; + + if (hasClassCandidate) + { + EmitCssSelectorMatches( + CssClassSelectorReferenceRegex, + selectorPartBody, + ".", + trimmedStart + partStart + selectorPartTrimStart, + context, + lineNumber, + references, + seen, + fileId, + definitionNames, + container); + } + + if (hasIdCandidate) + { + EmitCssSelectorMatches( + CssIdSelectorReferenceRegex, + selectorPartBody, + "#", + trimmedStart + partStart + selectorPartTrimStart, + context, + lineNumber, + references, + seen, + fileId, + definitionNames, + container); + } + } + } + + if (braceIndex < 0) + break; + + segmentStart = braceIndex + 1; + } + } + + private static void EmitCssSelectorMatches( + Regex regex, + string selectorPartBody, + string prefix, + int baseColumn, + string context, + int lineNumber, + List references, + ReferenceDedupeSet seen, + long fileId, + HashSet? definitionNames, + SymbolRecord? container) + { + foreach (Match match in BoundedRegex.EnumerateMatches(regex, selectorPartBody)) + { + var nameGroup = match.Groups["name"]; + var prefixIndex = nameGroup.Index - 1; + if (!IsCssSelectorPrefixOutsideAttributeValue(selectorPartBody, prefixIndex)) + continue; + + var name = prefix + nameGroup.Value; + if (definitionNames != null && definitionNames.Contains(name)) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name, + baseColumn + nameGroup.Index - 1, + "reference", + context, + lineNumber, + container); + } + } + + private static bool IsCssSelectorPrefixOutsideAttributeValue(string selectorPartBody, int prefixIndex) + { + var bracketDepth = 0; + char quote = '\0'; + for (var index = 0; index <= prefixIndex && index < selectorPartBody.Length; index++) + { + var ch = selectorPartBody[index]; + if (quote != '\0') + { + if (ch == quote && (index == 0 || selectorPartBody[index - 1] != '\\')) + quote = '\0'; + continue; + } + + if (ch is '\'' or '"') + { + quote = ch; + continue; + } + + if (ch == '[') + { + bracketDepth++; + continue; + } + + if (ch == ']' && bracketDepth > 0) + { + bracketDepth--; + continue; + } + } + + return bracketDepth == 0 && quote == '\0'; + } + + private static IEnumerable<(int Start, int End)> EnumerateCssSelectorListSegments(string selectorSegment) + { + var segmentStart = 0; + var parenDepth = 0; + var bracketDepth = 0; + + for (var index = 0; index < selectorSegment.Length; index++) + { + var ch = selectorSegment[index]; + if (ch == '(') + { + parenDepth++; + continue; + } + + if (ch == ')' && parenDepth > 0) + { + parenDepth--; + continue; + } + + if (ch == '[') + { + bracketDepth++; + continue; + } + + if (ch == ']' && bracketDepth > 0) + { + bracketDepth--; + continue; + } + + if (ch == ',' && parenDepth == 0 && bracketDepth == 0) + { + yield return (segmentStart, index); + segmentStart = index + 1; + } + } + + yield return (segmentStart, selectorSegment.Length); + } + + private static bool ContainsCssClassSelectorReferenceCandidate(string selectorPart) + => ContainsCssSelectorReferenceCandidate(selectorPart, '.'); + + private static bool ContainsCssIdSelectorReferenceCandidate(string selectorPart) + => ContainsCssSelectorReferenceCandidate(selectorPart, '#'); + + private static bool ContainsCssSelectorReferenceCandidate(string selectorPart, char prefix) + { + var bracketDepth = 0; + char quote = '\0'; + for (var index = 0; index < selectorPart.Length; index++) + { + var ch = selectorPart[index]; + if (quote != '\0') + { + if (ch == quote && (index == 0 || selectorPart[index - 1] != '\\')) + quote = '\0'; + continue; + } + + if (ch is '\'' or '"') + { + quote = ch; + continue; + } + + if (ch == '[') + { + bracketDepth++; + continue; + } + + if (ch == ']' && bracketDepth > 0) + { + bracketDepth--; + continue; + } + + if (bracketDepth == 0 && ch == prefix) + return true; + } + + return false; + } + + private static bool IsCssAnimationNameToken(string token) + { + if (string.IsNullOrWhiteSpace(token)) + return false; + + if (CssAnimationShorthandIgnoredTokens.Contains(token)) + return false; + if (token.IndexOf('(') >= 0 || token.IndexOf(')') >= 0 || token.IndexOf(',') >= 0 + || token.IndexOf('/') >= 0 || token.IndexOf(':') >= 0 || token.IndexOf(';') >= 0) + return false; + if (IsCssAnimationTimeToken(token) || IsCssAnimationNumberToken(token)) + return false; + if (token.StartsWith("--", StringComparison.Ordinal)) + return false; + if (!(char.IsLetter(token[0]) || token[0] == '_' || token[0] == '-')) + return false; + if (token[0] == '-' && token.Length > 1 && (token[1] == '-' || char.IsDigit(token[1]))) + return false; + + for (var i = 1; i < token.Length; i++) + { + if (char.IsLetterOrDigit(token[i]) || token[i] == '_' || token[i] == '-') + continue; + return false; + } + + return true; + } + + private static bool IsCssAnimationTimeToken(string token) + { + if (token.Length < 2) + return false; + + var unitLength = token.EndsWith("ms", StringComparison.OrdinalIgnoreCase) + ? 2 + : token.EndsWith("s", StringComparison.OrdinalIgnoreCase) + ? 1 + : 0; + if (unitLength == 0 || token.Length == unitLength) + return false; + + var numberPart = token[..^unitLength]; + var sawDigit = false; + var sawDot = false; + foreach (var ch in numberPart) + { + if (char.IsDigit(ch)) + { + sawDigit = true; + continue; + } + + if (ch == '.' && !sawDot) + { + sawDot = true; + continue; + } + + return false; + } + + return sawDigit; + } + + private static bool IsCssAnimationNumberToken(string token) + { + if (token.Length == 0 || token.IndexOfAny(['(', ')', ',', '/', ':', ';']) >= 0) + return false; + if (!(char.IsDigit(token[0]) || token[0] == '.')) + return false; + + var sawDigit = false; + var sawDot = false; + foreach (var ch in token) + { + if (char.IsDigit(ch)) + { + sawDigit = true; + continue; + } + + if (ch == '.' && !sawDot) + { + sawDot = true; + continue; + } + + return false; + } + + return sawDigit; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.PreprocessorFiltering.cs b/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.PreprocessorFiltering.cs new file mode 100644 index 000000000..7b217158b --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.PreprocessorFiltering.cs @@ -0,0 +1,167 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class CssReferenceExtractor +{ + private static bool ShouldSkipScssVariableReference(string preparedLine, int variableIndex) + { + var firstNonWhitespace = 0; + while (firstNonWhitespace < preparedLine.Length && char.IsWhiteSpace(preparedLine[firstNonWhitespace])) + firstNonWhitespace++; + + var lineTail = preparedLine.AsSpan(firstNonWhitespace); + if (lineTail.StartsWith("$", StringComparison.Ordinal)) + { + var declarationColonIndex = preparedLine.IndexOf(':', variableIndex); + if (declarationColonIndex >= 0) + return true; + } + + if (lineTail.StartsWith("@mixin", StringComparison.Ordinal) + || lineTail.StartsWith("@function", StringComparison.Ordinal)) + { + var braceIndex = preparedLine.IndexOf('{'); + if (braceIndex < 0) + return true; + if (variableIndex < braceIndex) + return true; + } + + return false; + } + + private static bool ShouldSkipSassIndentedDeclarationReferences(string preparedLine) + { + var firstNonWhitespace = 0; + while (firstNonWhitespace < preparedLine.Length && char.IsWhiteSpace(preparedLine[firstNonWhitespace])) + firstNonWhitespace++; + + return firstNonWhitespace < preparedLine.Length && preparedLine[firstNonWhitespace] == '='; + } + + private static bool ShouldSkipSassBareFunctionReference(string preparedLine, int functionIndex) + { + var firstNonWhitespace = 0; + while (firstNonWhitespace < preparedLine.Length && char.IsWhiteSpace(preparedLine[firstNonWhitespace])) + firstNonWhitespace++; + + var lineTail = preparedLine.AsSpan(firstNonWhitespace); + if (!lineTail.StartsWith("@function", StringComparison.Ordinal) + && !lineTail.StartsWith("@mixin", StringComparison.Ordinal)) + { + return false; + } + + return functionIndex >= firstNonWhitespace; + } + + private static string PrepareSassStylusReferenceLine(string originalLine) + { + char[]? chars = null; + + void MaskAt(int index) => + (chars ??= originalLine.ToCharArray())[index] = ' '; + + void MaskRange(int start, int endExclusive) + { + var masked = chars ??= originalLine.ToCharArray(); + for (var index = start; index < endExclusive; index++) + masked[index] = ' '; + } + + char quote = '\0'; + var parenDepth = 0; + for (var i = 0; i < originalLine.Length; i++) + { + var ch = originalLine[i]; + if (quote != '\0') + { + MaskAt(i); + if (ch == quote && (i == 0 || originalLine[i - 1] != '\\')) + quote = '\0'; + continue; + } + + if (ch is '\'' or '"') + { + MaskAt(i); + quote = ch; + continue; + } + + if (ch == '/' && i + 1 < originalLine.Length && originalLine[i + 1] == '*') + { + var commentEnd = originalLine.IndexOf("*/", i + 2, StringComparison.Ordinal); + var stop = commentEnd >= 0 ? commentEnd + 2 : originalLine.Length; + MaskRange(i, stop); + i = stop - 1; + continue; + } + + if (ch == '(') + { + parenDepth++; + continue; + } + + if (ch == ')' && parenDepth > 0) + { + parenDepth--; + continue; + } + + if (parenDepth == 0 && ch == '/' && i + 1 < originalLine.Length && originalLine[i + 1] == '/') + { + MaskRange(i, originalLine.Length); + break; + } + } + + return chars is null ? originalLine : new string(chars); + } + + private static bool ShouldSkipStylusVariableReference(string preparedLine, int variableIndex) + { + var firstNonWhitespace = 0; + while (firstNonWhitespace < preparedLine.Length && char.IsWhiteSpace(preparedLine[firstNonWhitespace])) + firstNonWhitespace++; + + var dollarIndex = variableIndex - 1; + if (dollarIndex != firstNonWhitespace || dollarIndex < 0 || preparedLine[dollarIndex] != '$') + return false; + + var cursor = variableIndex; + while (cursor < preparedLine.Length && (char.IsLetterOrDigit(preparedLine[cursor]) || preparedLine[cursor] is '_' or '-')) + cursor++; + while (cursor < preparedLine.Length && char.IsWhiteSpace(preparedLine[cursor])) + cursor++; + + return cursor < preparedLine.Length + && (preparedLine[cursor] == '=' + || (preparedLine[cursor] == ':' && cursor + 1 < preparedLine.Length && preparedLine[cursor + 1] == '=')); + } + + private static bool ShouldSkipStylusBareVariableReference(string preparedLine, int variableIndex) + { + var firstNonWhitespace = 0; + while (firstNonWhitespace < preparedLine.Length && char.IsWhiteSpace(preparedLine[firstNonWhitespace])) + firstNonWhitespace++; + if (variableIndex == firstNonWhitespace) + return true; + + var cursor = variableIndex; + while (cursor < preparedLine.Length && (char.IsLetterOrDigit(preparedLine[cursor]) || preparedLine[cursor] is '_' or '-')) + cursor++; + while (cursor < preparedLine.Length && char.IsWhiteSpace(preparedLine[cursor])) + cursor++; + + if (cursor < preparedLine.Length && preparedLine[cursor] == '(') + return true; + return cursor < preparedLine.Length + && (preparedLine[cursor] == '=' + || (preparedLine[cursor] == ':' && cursor + 1 < preparedLine.Length && preparedLine[cursor + 1] == '=')); + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.cs index 8ab6e413e..155f90b18 100644 --- a/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/CssReferenceExtractor.cs @@ -4,7 +4,7 @@ namespace CodeIndex.Indexer; -internal static class CssReferenceExtractor +internal static partial class CssReferenceExtractor { private readonly record struct ReferencePattern(Regex Regex, string Kind, bool SkipVariableDeclarations = false); @@ -617,699 +617,4 @@ private static bool HasPreprocessorImportMarker(string line) => || line.IndexOf("forward", StringComparison.OrdinalIgnoreCase) >= 0 || line.IndexOf("require", StringComparison.OrdinalIgnoreCase) >= 0); - private static void EmitMatches( - ReferencePattern pattern, - string preparedLine, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - HashSet? definitionNames, - SymbolRecord? container) - { - foreach (Match match in BoundedRegex.EnumerateMatches(pattern.Regex, preparedLine)) - { - var nameGroup = match.Groups["name"]; - if (definitionNames != null && definitionNames.Contains(nameGroup.Value)) - continue; - - if (pattern.SkipVariableDeclarations && ShouldSkipScssVariableReference(preparedLine, nameGroup.Index)) - continue; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - nameGroup.Value, - nameGroup.Index, - pattern.Kind, - context, - lineNumber, - container); - } - } - - private static void EmitCssAnimationNameReferences( - string value, - int valueIndex, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - HashSet? definitionNames, - SymbolRecord? container) - { - var segmentStart = 0; - for (var i = 0; i <= value.Length; i++) - { - if (i < value.Length && value[i] != ',') - continue; - - EmitCssAnimationNameSegmentReference( - value, - valueIndex, - segmentStart, - i, - context, - lineNumber, - references, - seen, - fileId, - definitionNames, - container); - segmentStart = i + 1; - } - } - - private static void EmitCssAnimationNameSegmentReference( - string value, - int valueIndex, - int segmentStart, - int segmentEnd, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - HashSet? definitionNames, - SymbolRecord? container) - { - var cursor = segmentStart; - while (cursor < segmentEnd && char.IsWhiteSpace(value[cursor])) - cursor++; - if (cursor >= segmentEnd) - return; - - var tokenStart = cursor; - while (cursor < segmentEnd && !char.IsWhiteSpace(value[cursor])) - cursor++; - - var token = value[tokenStart..cursor]; - if (!IsCssAnimationNameToken(token)) - return; - if (definitionNames != null && definitionNames.Contains(token)) - return; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - token, - valueIndex + tokenStart, - "reference", - context, - lineNumber, - container); - } - - private static void EmitCssAnimationShorthandReferences( - string value, - int valueIndex, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - HashSet? definitionNames, - SymbolRecord? container) - { - var segmentStart = 0; - var parenDepth = 0; - for (var i = 0; i <= value.Length; i++) - { - if (i < value.Length) - { - var ch = value[i]; - if (ch == '(') - { - parenDepth++; - continue; - } - - if (ch == ')' && parenDepth > 0) - { - parenDepth--; - continue; - } - - if (ch != ',' || parenDepth > 0) - continue; - } - - EmitCssAnimationShorthandSegmentReference( - value, - valueIndex, - segmentStart, - i, - context, - lineNumber, - references, - seen, - fileId, - definitionNames, - container); - segmentStart = i + 1; - } - } - - private static void EmitCssAnimationShorthandSegmentReference( - string value, - int valueIndex, - int segmentStart, - int segmentEnd, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - HashSet? definitionNames, - SymbolRecord? container) - { - var cursor = segmentStart; - while (cursor < segmentEnd) - { - while (cursor < segmentEnd && char.IsWhiteSpace(value[cursor])) - cursor++; - if (cursor >= segmentEnd) - break; - - var tokenStart = cursor; - while (cursor < segmentEnd && !char.IsWhiteSpace(value[cursor])) - cursor++; - - var token = value[tokenStart..cursor]; - if (!IsCssAnimationNameToken(token)) - continue; - if (definitionNames != null && definitionNames.Contains(token)) - return; - - ReferenceExtractor.AddReference(references, seen, fileId, token, valueIndex + tokenStart, "reference", context, lineNumber, container); - return; - } - } - - private static void EmitCssClassSelectorReferences( - string preparedLine, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - HashSet? definitionNames, - SymbolRecord? container) - { - // ID selectors (`#name`) are emitted only in selector-position segments - // because `#fff` / `#abc123` color literals also match the regex. A - // segment is treated as selector position when it terminates at `{` - // on the current line (clear selector → block opener) or when the - // entire line is a selector-list continuation (trimmed line ends with `,`). - // ID セレクタ (`#name`) は `#fff` 等の color literal とパターンが衝突するため、 - // セレクタ位置のセグメントでのみ参照を発行する。セグメントが本行内で `{` で - // 終わる場合、または行末カンマで selector list が継続する場合をセレクタ位置とみなす。 - var isSelectorContinuationLine = preparedLine.TrimEnd().EndsWith(','); - var segmentStart = 0; - while (segmentStart < preparedLine.Length) - { - var braceIndex = preparedLine.IndexOf('{', segmentStart); - var segmentEnd = braceIndex >= 0 ? braceIndex : preparedLine.Length; - var trimmedStart = segmentStart; - while (trimmedStart < segmentEnd && char.IsWhiteSpace(preparedLine[trimmedStart])) - trimmedStart++; - - if (trimmedStart < segmentEnd && preparedLine[trimmedStart] != '@') - { - var selectorSegment = preparedLine[trimmedStart..segmentEnd]; - var isIdSelectorContext = braceIndex >= 0 - || (segmentStart == 0 && isSelectorContinuationLine); - foreach (var (partStart, partEnd) in EnumerateCssSelectorListSegments(selectorSegment)) - { - var selectorPart = selectorSegment[partStart..partEnd]; - var hasClassCandidate = ContainsCssClassSelectorReferenceCandidate(selectorPart); - var hasIdCandidate = isIdSelectorContext - && ContainsCssIdSelectorReferenceCandidate(selectorPart); - if (!hasClassCandidate && !hasIdCandidate) - continue; - - var selectorPartTrimStart = 0; - while (selectorPartTrimStart < selectorPart.Length && char.IsWhiteSpace(selectorPart[selectorPartTrimStart])) - selectorPartTrimStart++; - - var selectorPartBody = selectorPart[selectorPartTrimStart..]; - - if (hasClassCandidate) - { - EmitCssSelectorMatches( - CssClassSelectorReferenceRegex, - selectorPartBody, - ".", - trimmedStart + partStart + selectorPartTrimStart, - context, - lineNumber, - references, - seen, - fileId, - definitionNames, - container); - } - - if (hasIdCandidate) - { - EmitCssSelectorMatches( - CssIdSelectorReferenceRegex, - selectorPartBody, - "#", - trimmedStart + partStart + selectorPartTrimStart, - context, - lineNumber, - references, - seen, - fileId, - definitionNames, - container); - } - } - } - - if (braceIndex < 0) - break; - - segmentStart = braceIndex + 1; - } - } - - private static void EmitCssSelectorMatches( - Regex regex, - string selectorPartBody, - string prefix, - int baseColumn, - string context, - int lineNumber, - List references, - ReferenceDedupeSet seen, - long fileId, - HashSet? definitionNames, - SymbolRecord? container) - { - foreach (Match match in BoundedRegex.EnumerateMatches(regex, selectorPartBody)) - { - var nameGroup = match.Groups["name"]; - var prefixIndex = nameGroup.Index - 1; - if (!IsCssSelectorPrefixOutsideAttributeValue(selectorPartBody, prefixIndex)) - continue; - - var name = prefix + nameGroup.Value; - if (definitionNames != null && definitionNames.Contains(name)) - continue; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name, - baseColumn + nameGroup.Index - 1, - "reference", - context, - lineNumber, - container); - } - } - - private static bool IsCssSelectorPrefixOutsideAttributeValue(string selectorPartBody, int prefixIndex) - { - var bracketDepth = 0; - char quote = '\0'; - for (var index = 0; index <= prefixIndex && index < selectorPartBody.Length; index++) - { - var ch = selectorPartBody[index]; - if (quote != '\0') - { - if (ch == quote && (index == 0 || selectorPartBody[index - 1] != '\\')) - quote = '\0'; - continue; - } - - if (ch is '\'' or '"') - { - quote = ch; - continue; - } - - if (ch == '[') - { - bracketDepth++; - continue; - } - - if (ch == ']' && bracketDepth > 0) - { - bracketDepth--; - continue; - } - } - - return bracketDepth == 0 && quote == '\0'; - } - - private static IEnumerable<(int Start, int End)> EnumerateCssSelectorListSegments(string selectorSegment) - { - var segmentStart = 0; - var parenDepth = 0; - var bracketDepth = 0; - - for (var index = 0; index < selectorSegment.Length; index++) - { - var ch = selectorSegment[index]; - if (ch == '(') - { - parenDepth++; - continue; - } - - if (ch == ')' && parenDepth > 0) - { - parenDepth--; - continue; - } - - if (ch == '[') - { - bracketDepth++; - continue; - } - - if (ch == ']' && bracketDepth > 0) - { - bracketDepth--; - continue; - } - - if (ch == ',' && parenDepth == 0 && bracketDepth == 0) - { - yield return (segmentStart, index); - segmentStart = index + 1; - } - } - - yield return (segmentStart, selectorSegment.Length); - } - - private static bool ContainsCssClassSelectorReferenceCandidate(string selectorPart) - => ContainsCssSelectorReferenceCandidate(selectorPart, '.'); - - private static bool ContainsCssIdSelectorReferenceCandidate(string selectorPart) - => ContainsCssSelectorReferenceCandidate(selectorPart, '#'); - - private static bool ContainsCssSelectorReferenceCandidate(string selectorPart, char prefix) - { - var bracketDepth = 0; - char quote = '\0'; - for (var index = 0; index < selectorPart.Length; index++) - { - var ch = selectorPart[index]; - if (quote != '\0') - { - if (ch == quote && (index == 0 || selectorPart[index - 1] != '\\')) - quote = '\0'; - continue; - } - - if (ch is '\'' or '"') - { - quote = ch; - continue; - } - - if (ch == '[') - { - bracketDepth++; - continue; - } - - if (ch == ']' && bracketDepth > 0) - { - bracketDepth--; - continue; - } - - if (bracketDepth == 0 && ch == prefix) - return true; - } - - return false; - } - - private static bool IsCssAnimationNameToken(string token) - { - if (string.IsNullOrWhiteSpace(token)) - return false; - - if (CssAnimationShorthandIgnoredTokens.Contains(token)) - return false; - if (token.IndexOf('(') >= 0 || token.IndexOf(')') >= 0 || token.IndexOf(',') >= 0 - || token.IndexOf('/') >= 0 || token.IndexOf(':') >= 0 || token.IndexOf(';') >= 0) - return false; - if (IsCssAnimationTimeToken(token) || IsCssAnimationNumberToken(token)) - return false; - if (token.StartsWith("--", StringComparison.Ordinal)) - return false; - if (!(char.IsLetter(token[0]) || token[0] == '_' || token[0] == '-')) - return false; - if (token[0] == '-' && token.Length > 1 && (token[1] == '-' || char.IsDigit(token[1]))) - return false; - - for (var i = 1; i < token.Length; i++) - { - if (char.IsLetterOrDigit(token[i]) || token[i] == '_' || token[i] == '-') - continue; - return false; - } - - return true; - } - - private static bool IsCssAnimationTimeToken(string token) - { - if (token.Length < 2) - return false; - - var unitLength = token.EndsWith("ms", StringComparison.OrdinalIgnoreCase) - ? 2 - : token.EndsWith("s", StringComparison.OrdinalIgnoreCase) - ? 1 - : 0; - if (unitLength == 0 || token.Length == unitLength) - return false; - - var numberPart = token[..^unitLength]; - var sawDigit = false; - var sawDot = false; - foreach (var ch in numberPart) - { - if (char.IsDigit(ch)) - { - sawDigit = true; - continue; - } - - if (ch == '.' && !sawDot) - { - sawDot = true; - continue; - } - - return false; - } - - return sawDigit; - } - - private static bool IsCssAnimationNumberToken(string token) - { - if (token.Length == 0 || token.IndexOfAny(['(', ')', ',', '/', ':', ';']) >= 0) - return false; - if (!(char.IsDigit(token[0]) || token[0] == '.')) - return false; - - var sawDigit = false; - var sawDot = false; - foreach (var ch in token) - { - if (char.IsDigit(ch)) - { - sawDigit = true; - continue; - } - - if (ch == '.' && !sawDot) - { - sawDot = true; - continue; - } - - return false; - } - - return sawDigit; - } - - private static bool ShouldSkipScssVariableReference(string preparedLine, int variableIndex) - { - var firstNonWhitespace = 0; - while (firstNonWhitespace < preparedLine.Length && char.IsWhiteSpace(preparedLine[firstNonWhitespace])) - firstNonWhitespace++; - - var lineTail = preparedLine.AsSpan(firstNonWhitespace); - if (lineTail.StartsWith("$", StringComparison.Ordinal)) - { - var declarationColonIndex = preparedLine.IndexOf(':', variableIndex); - if (declarationColonIndex >= 0) - return true; - } - - if (lineTail.StartsWith("@mixin", StringComparison.Ordinal) - || lineTail.StartsWith("@function", StringComparison.Ordinal)) - { - var braceIndex = preparedLine.IndexOf('{'); - if (braceIndex < 0) - return true; - if (variableIndex < braceIndex) - return true; - } - - return false; - } - - private static bool ShouldSkipSassIndentedDeclarationReferences(string preparedLine) - { - var firstNonWhitespace = 0; - while (firstNonWhitespace < preparedLine.Length && char.IsWhiteSpace(preparedLine[firstNonWhitespace])) - firstNonWhitespace++; - - return firstNonWhitespace < preparedLine.Length && preparedLine[firstNonWhitespace] == '='; - } - - private static bool ShouldSkipSassBareFunctionReference(string preparedLine, int functionIndex) - { - var firstNonWhitespace = 0; - while (firstNonWhitespace < preparedLine.Length && char.IsWhiteSpace(preparedLine[firstNonWhitespace])) - firstNonWhitespace++; - - var lineTail = preparedLine.AsSpan(firstNonWhitespace); - if (!lineTail.StartsWith("@function", StringComparison.Ordinal) - && !lineTail.StartsWith("@mixin", StringComparison.Ordinal)) - { - return false; - } - - return functionIndex >= firstNonWhitespace; - } - - private static string PrepareSassStylusReferenceLine(string originalLine) - { - char[]? chars = null; - - void MaskAt(int index) => - (chars ??= originalLine.ToCharArray())[index] = ' '; - - void MaskRange(int start, int endExclusive) - { - var masked = chars ??= originalLine.ToCharArray(); - for (var index = start; index < endExclusive; index++) - masked[index] = ' '; - } - - char quote = '\0'; - var parenDepth = 0; - for (var i = 0; i < originalLine.Length; i++) - { - var ch = originalLine[i]; - if (quote != '\0') - { - MaskAt(i); - if (ch == quote && (i == 0 || originalLine[i - 1] != '\\')) - quote = '\0'; - continue; - } - - if (ch is '\'' or '"') - { - MaskAt(i); - quote = ch; - continue; - } - - if (ch == '/' && i + 1 < originalLine.Length && originalLine[i + 1] == '*') - { - var commentEnd = originalLine.IndexOf("*/", i + 2, StringComparison.Ordinal); - var stop = commentEnd >= 0 ? commentEnd + 2 : originalLine.Length; - MaskRange(i, stop); - i = stop - 1; - continue; - } - - if (ch == '(') - { - parenDepth++; - continue; - } - - if (ch == ')' && parenDepth > 0) - { - parenDepth--; - continue; - } - - if (parenDepth == 0 && ch == '/' && i + 1 < originalLine.Length && originalLine[i + 1] == '/') - { - MaskRange(i, originalLine.Length); - break; - } - } - - return chars is null ? originalLine : new string(chars); - } - - private static bool ShouldSkipStylusVariableReference(string preparedLine, int variableIndex) - { - var firstNonWhitespace = 0; - while (firstNonWhitespace < preparedLine.Length && char.IsWhiteSpace(preparedLine[firstNonWhitespace])) - firstNonWhitespace++; - - var dollarIndex = variableIndex - 1; - if (dollarIndex != firstNonWhitespace || dollarIndex < 0 || preparedLine[dollarIndex] != '$') - return false; - - var cursor = variableIndex; - while (cursor < preparedLine.Length && (char.IsLetterOrDigit(preparedLine[cursor]) || preparedLine[cursor] is '_' or '-')) - cursor++; - while (cursor < preparedLine.Length && char.IsWhiteSpace(preparedLine[cursor])) - cursor++; - - return cursor < preparedLine.Length - && (preparedLine[cursor] == '=' - || (preparedLine[cursor] == ':' && cursor + 1 < preparedLine.Length && preparedLine[cursor + 1] == '=')); - } - - private static bool ShouldSkipStylusBareVariableReference(string preparedLine, int variableIndex) - { - var firstNonWhitespace = 0; - while (firstNonWhitespace < preparedLine.Length && char.IsWhiteSpace(preparedLine[firstNonWhitespace])) - firstNonWhitespace++; - if (variableIndex == firstNonWhitespace) - return true; - - var cursor = variableIndex; - while (cursor < preparedLine.Length && (char.IsLetterOrDigit(preparedLine[cursor]) || preparedLine[cursor] is '_' or '-')) - cursor++; - while (cursor < preparedLine.Length && char.IsWhiteSpace(preparedLine[cursor])) - cursor++; - - if (cursor < preparedLine.Length && preparedLine[cursor] == '(') - return true; - return cursor < preparedLine.Length - && (preparedLine[cursor] == '=' - || (preparedLine[cursor] == ':' && cursor + 1 < preparedLine.Length && preparedLine[cursor + 1] == '=')); - } } diff --git a/src/CodeIndex/Indexer/References/Languages/HdlReferenceExtractor.Masking.cs b/src/CodeIndex/Indexer/References/Languages/HdlReferenceExtractor.Masking.cs new file mode 100644 index 000000000..e5aa936e9 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/HdlReferenceExtractor.Masking.cs @@ -0,0 +1,174 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static string MaskHdlCommentsAndStrings( + string line, + string language, + ref bool inVerilogBlockComment) + { + char[]? masked = null; + var inString = false; + for (var index = 0; index < line.Length; index++) + { + if (language != "vhdl" && inVerilogBlockComment) + { + MaskCharacter(ref masked, line, index); + if (line[index] == '*' && index + 1 < line.Length && line[index + 1] == '/') + { + MaskCharacter(ref masked, line, ++index); + inVerilogBlockComment = false; + } + + continue; + } + + if (inString) + { + MaskCharacter(ref masked, line, index); + if (language == "vhdl" + && line[index] == '"' + && index + 1 < line.Length + && line[index + 1] == '"') + { + MaskCharacter(ref masked, line, ++index); + continue; + } + + if (line[index] == '\\' && language != "vhdl" && index + 1 < line.Length) + { + MaskCharacter(ref masked, line, ++index); + continue; + } + + if (line[index] == '"') + inString = false; + continue; + } + + if (language == "vhdl" + && line[index] == '\'' + && index + 2 < line.Length + && line[index + 2] == '\'') + { + MaskRange(ref masked, line, index, index + 3); + index += 2; + continue; + } + + if (language != "vhdl" && line[index] == '\'') + { + var literalEnd = FindVerilogNumericLiteralEnd(line, index); + if (literalEnd > index) + { + MaskRange(ref masked, line, index, literalEnd); + index = literalEnd - 1; + continue; + } + } + + if (line[index] == '"') + { + if (language == "vhdl") + { + var bitStringStart = FindVhdlBitStringLiteralStart(line, index); + if (bitStringStart < index) + MaskRange(ref masked, line, bitStringStart, index); + } + inString = true; + MaskCharacter(ref masked, line, index); + continue; + } + + if (language == "vhdl" + && line[index] == '-' + && index + 1 < line.Length + && line[index + 1] == '-') + { + MaskRange(ref masked, line, index, line.Length); + break; + } + + if (language != "vhdl" + && line[index] == '/' + && index + 1 < line.Length) + { + if (line[index + 1] == '/') + { + MaskRange(ref masked, line, index, line.Length); + break; + } + + if (line[index + 1] == '*') + { + MaskCharacter(ref masked, line, index); + MaskCharacter(ref masked, line, ++index); + inVerilogBlockComment = true; + } + } + } + + return masked == null ? line : new string(masked); + } + + private static int FindVhdlBitStringLiteralStart(string line, int quoteIndex) + { + var baseIndex = quoteIndex - 1; + if (baseIndex < 0 || !"BOXDboxd".Contains(line[baseIndex])) + return quoteIndex; + + var start = baseIndex; + if (start > 0 && (line[start - 1] is 'U' or 'u' or 'S' or 's')) + start--; + while (start > 0 && (char.IsDigit(line[start - 1]) || line[start - 1] == '_')) + start--; + + return start == 0 || !IsVhdlIdentifierCharacter(line[start - 1]) + ? start + : quoteIndex; + } + + private static bool IsVhdlIdentifierCharacter(char value) + => char.IsAsciiLetterOrDigit(value) || value == '_'; + + private static int FindVerilogNumericLiteralEnd(string line, int apostropheIndex) + { + var index = apostropheIndex + 1; + if (index >= line.Length) + return apostropheIndex; + + if (line[index] is '0' or '1' or 'x' or 'X' or 'z' or 'Z' or '?') + return index + 1; + + if (line[index] is 's' or 'S') + index++; + if (index >= line.Length || line[index] is not ('b' or 'B' or 'o' or 'O' or 'd' or 'D' or 'h' or 'H')) + return apostropheIndex; + + index++; + var digitStart = index; + while (index < line.Length + && (char.IsLetterOrDigit(line[index]) || line[index] is '_' or '?')) + { + index++; + } + + return index > digitStart ? index : apostropheIndex; + } + + private static void MaskRange(ref char[]? masked, string source, int start, int end) + { + for (var index = start; index < end; index++) + MaskCharacter(ref masked, source, index); + } + + private static void MaskCharacter(ref char[]? masked, string source, int index) + { + masked ??= source.ToCharArray(); + masked[index] = ' '; + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/HdlReferenceExtractor.Scopes.cs b/src/CodeIndex/Indexer/References/Languages/HdlReferenceExtractor.Scopes.cs new file mode 100644 index 000000000..128c14491 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/HdlReferenceExtractor.Scopes.cs @@ -0,0 +1,368 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static bool TryPopHdlScope( + string language, + string structuralLine, + List scopes) + { + if (scopes.Count == 0) + return false; + + if (language != "vhdl") + { + var match = VerilogScopeEndRegex.Match(structuralLine); + if (!match.Success) + return false; + + PopHdlScope(scopes, NormalizeVerilogScopeKind(match.Groups["kind"].Value), name: null, ignoreCase: false); + return true; + } + + var vhdlMatch = VhdlScopeEndRegex.Match(structuralLine); + if (!vhdlMatch.Success) + return false; + + var kind = vhdlMatch.Groups["kind"].Success + ? NormalizeVhdlScopeKind(vhdlMatch.Groups["kind"].Value) + : null; + var name = vhdlMatch.Groups["name"].Success + ? vhdlMatch.Groups["name"].Value + : null; + if (name != null && VhdlControlEndNames.Contains(name)) + return true; + + if (kind == null && name == null) + scopes.RemoveAt(scopes.Count - 1); + else + PopHdlScope(scopes, kind, name, ignoreCase: true); + return true; + } + + private static void PopHdlScope( + List scopes, + string? kind, + string? name, + bool ignoreCase) + { + var comparison = ignoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + for (var index = scopes.Count - 1; index >= 0; index--) + { + var scope = scopes[index].Symbol; + if ((kind == null || string.Equals(scope.Kind, kind, comparison)) + && (name == null || string.Equals(scope.Name, name, comparison))) + { + scopes.RemoveRange(index, scopes.Count - index); + return; + } + } + } + + private static void TryPushHdlScope( + string language, + string structuralLine, + List scopes, + ref int nextDesignUnitId) + { + if (language != "vhdl") + { + var match = VerilogScopeStartRegex.Match(structuralLine); + if (match.Success) + { + AddHdlScope( + scopes, + NormalizeVerilogScopeKind(match.Groups["kind"].Value), + match.Groups["name"].Value, + ref nextDesignUnitId); + return; + } + + match = SystemVerilogClassStartRegex.Match(structuralLine); + if (match.Success) + { + AddHdlScope(scopes, "class", match.Groups["name"].Value, ref nextDesignUnitId); + return; + } + + match = VerilogFunctionStartRegex.Match(structuralLine); + if (match.Success) + { + AddHdlScope(scopes, "function", match.Groups["name"].Value, ref nextDesignUnitId); + return; + } + + match = VerilogTaskStartRegex.Match(structuralLine); + if (match.Success) + AddHdlScope(scopes, "function", match.Groups["name"].Value, ref nextDesignUnitId); + return; + } + + if (TryMatchHdlScope(VhdlArchitectureStartRegex, structuralLine, "module", scopes, ref nextDesignUnitId) + || TryMatchHdlScope(VhdlEntityStartRegex, structuralLine, "module", scopes, ref nextDesignUnitId) + || TryMatchHdlScope(VhdlPackageStartRegex, structuralLine, "package", scopes, ref nextDesignUnitId) + || TryMatchHdlScope(VhdlConfigurationStartRegex, structuralLine, "module", scopes, ref nextDesignUnitId)) + { + return; + } + + TryMatchHdlScope( + VhdlProcessStartRegex, + structuralLine, + "function", + scopes, + ref nextDesignUnitId); + } + + private static bool TryMatchHdlScope( + Regex regex, + string line, + string kind, + List scopes, + ref int nextDesignUnitId) + { + var match = regex.Match(line); + if (!match.Success) + return false; + + AddHdlScope(scopes, kind, match.Groups["name"].Value, ref nextDesignUnitId); + return true; + } + + private static void AddHdlScope( + List scopes, + string kind, + string name, + ref int nextDesignUnitId, + IReadOnlySet? shadowedNames = null) + { + var designUnitId = scopes.Count == 0 + ? nextDesignUnitId++ + : scopes[0].DesignUnitId; + scopes.Add(new HdlScope( + new SymbolRecord + { + Kind = kind, + Name = name, + }, + designUnitId, + shadowedNames == null + ? new HashSet(StringComparer.OrdinalIgnoreCase) + : new HashSet(shadowedNames, StringComparer.OrdinalIgnoreCase))); + } + + private static int[] BuildVhdlDesignUnitIds( + string[] lines, + int lineCount, + CancellationToken cancellationToken) + { + var result = new int[lines.Length]; + var scopes = new List(); + var designUnitIdsByKey = new Dictionary(StringComparer.OrdinalIgnoreCase); + var nextDesignUnitId = 1; + VhdlPendingSubprogramHeader? pendingSubprogram = null; + var unusedBlockCommentState = false; + for (var index = 0; index < lineCount; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var structuralLine = MaskHdlCommentsAndStrings( + lines[index], + "vhdl", + ref unusedBlockCommentState); + if (string.IsNullOrWhiteSpace(structuralLine)) + continue; + + TryPopHdlScope("vhdl", structuralLine, scopes); + var wasOutsideDesignUnit = scopes.Count == 0; + AdvanceVhdlSubprogramHeader( + structuralLine, + ref pendingSubprogram, + out var completedSubprogram); + if (completedSubprogram != null) + { + AddHdlScope( + scopes, + "function", + completedSubprogram.Name, + ref nextDesignUnitId, + completedSubprogram.ShadowedNames); + } + else + { + TryPushHdlScope("vhdl", structuralLine, scopes, ref nextDesignUnitId); + } + if (wasOutsideDesignUnit + && scopes.Count > 0 + && TryGetVhdlDesignUnitKey(structuralLine, out var designUnitKey)) + { + if (!designUnitIdsByKey.TryGetValue(designUnitKey, out var designUnitId)) + { + designUnitId = scopes[0].DesignUnitId; + designUnitIdsByKey[designUnitKey] = designUnitId; + } + scopes[0] = scopes[0] with { DesignUnitId = designUnitId }; + } + if (scopes.Count > 0) + result[index] = scopes[0].DesignUnitId; + } + + return result; + } + + private static HashSet? AdvanceVhdlSubprogramHeader( + string line, + ref VhdlPendingSubprogramHeader? pending, + out VhdlCompletedSubprogramHeader? completed) + { + completed = null; + if (pending == null) + { + var startMatch = VhdlFunctionStartRegex.Match(line); + if (!startMatch.Success) + startMatch = VhdlProcedureStartRegex.Match(line); + if (!startMatch.Success) + return null; + pending = new VhdlPendingSubprogramHeader(startMatch.Groups["name"].Value); + } + + var declaredOnLine = GetVhdlParameterNames(line); + if (declaredOnLine != null) + pending.ShadowedNames.UnionWith(declaredOnLine); + + foreach (var character in line) + { + if (character == '(') + pending.ParenthesisDepth++; + else if (character == ')' && pending.ParenthesisDepth > 0) + pending.ParenthesisDepth--; + } + + if (pending.ParenthesisDepth == 0 + && VhdlSubprogramBodyMarkerRegex.IsMatch(line)) + { + completed = new VhdlCompletedSubprogramHeader( + pending.Name, + new HashSet(pending.ShadowedNames, StringComparer.OrdinalIgnoreCase)); + pending = null; + } + else if (pending.ParenthesisDepth == 0 && line.Contains(';')) + { + pending = null; + } + + return declaredOnLine; + } + + private static HashSet? GetVhdlParameterNames(string line) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Match parameterMatch in VhdlParameterNamesRegex.Matches(line)) + AddVhdlDeclaredNames(result, parameterMatch.Groups["names"].Value); + return result.Count == 0 ? null : result; + } + + private static HashSet? MergeVhdlDeclaredNames( + HashSet? first, + HashSet? second) + { + if (first == null) + return second; + if (second != null) + first.UnionWith(second); + return first; + } + + private static bool TryGetVhdlDesignUnitKey(string line, out string key) + { + var architectureMatch = VhdlArchitectureRegex.Match(line); + if (architectureMatch.Success) + { + key = $"entity:{architectureMatch.Groups["entity"].Value}"; + return true; + } + + var entityMatch = VhdlEntityStartRegex.Match(line); + if (entityMatch.Success) + { + key = $"entity:{entityMatch.Groups["name"].Value}"; + return true; + } + + var packageMatch = VhdlPackageStartRegex.Match(line); + if (packageMatch.Success) + { + key = $"package:{packageMatch.Groups["name"].Value}"; + return true; + } + + var configurationMatch = VhdlConfigurationStartRegex.Match(line); + if (configurationMatch.Success) + { + key = $"configuration:{configurationMatch.Groups["name"].Value}"; + return true; + } + + key = string.Empty; + return false; + } + + private static HashSet? GetVhdlDeclaredNames(string line) + { + Match? declarationMatch = null; + if (VhdlFunctionStartRegex.IsMatch(line) || VhdlProcedureStartRegex.IsMatch(line)) + { + var openParenthesis = line.IndexOf('('); + var closeParenthesis = line.LastIndexOf(')'); + if (openParenthesis >= 0 && closeParenthesis > openParenthesis) + { + var parameters = line.Substring( + openParenthesis + 1, + closeParenthesis - openParenthesis - 1); + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Match parameterMatch in VhdlParameterNamesRegex.Matches(parameters)) + AddVhdlDeclaredNames(result, parameterMatch.Groups["names"].Value); + return result.Count == 0 ? null : result; + } + } + else + { + declarationMatch = VhdlLocalDeclarationRegex.Match(line); + } + + if (declarationMatch is not { Success: true }) + return null; + + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + AddVhdlDeclaredNames(names, declarationMatch.Groups["names"].Value); + return names.Count == 0 ? null : names; + } + + private static void AddVhdlDeclaredNames(HashSet names, string value) + { + foreach (var name in value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + names.Add(name); + } + + private static string NormalizeVerilogScopeKind(string kind) + => kind switch + { + "macromodule" or "primitive" or "program" => "module", + "task" => "function", + _ => kind, + }; + + private static string NormalizeVhdlScopeKind(string kind) + => kind switch + { + "architecture" or "entity" or "configuration" => "module", + "procedure" or "process" => "function", + _ => kind, + }; + +} diff --git a/src/CodeIndex/Indexer/References/Languages/HdlReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/HdlReferenceExtractor.cs index 5ba15a59c..7cd1e2723 100644 --- a/src/CodeIndex/Indexer/References/Languages/HdlReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/HdlReferenceExtractor.cs @@ -726,528 +726,4 @@ private static void AddHdlReference( specialPositions?.Add(nameIndex); } - private static bool TryPopHdlScope( - string language, - string structuralLine, - List scopes) - { - if (scopes.Count == 0) - return false; - - if (language != "vhdl") - { - var match = VerilogScopeEndRegex.Match(structuralLine); - if (!match.Success) - return false; - - PopHdlScope(scopes, NormalizeVerilogScopeKind(match.Groups["kind"].Value), name: null, ignoreCase: false); - return true; - } - - var vhdlMatch = VhdlScopeEndRegex.Match(structuralLine); - if (!vhdlMatch.Success) - return false; - - var kind = vhdlMatch.Groups["kind"].Success - ? NormalizeVhdlScopeKind(vhdlMatch.Groups["kind"].Value) - : null; - var name = vhdlMatch.Groups["name"].Success - ? vhdlMatch.Groups["name"].Value - : null; - if (name != null && VhdlControlEndNames.Contains(name)) - return true; - - if (kind == null && name == null) - scopes.RemoveAt(scopes.Count - 1); - else - PopHdlScope(scopes, kind, name, ignoreCase: true); - return true; - } - - private static void PopHdlScope( - List scopes, - string? kind, - string? name, - bool ignoreCase) - { - var comparison = ignoreCase - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - for (var index = scopes.Count - 1; index >= 0; index--) - { - var scope = scopes[index].Symbol; - if ((kind == null || string.Equals(scope.Kind, kind, comparison)) - && (name == null || string.Equals(scope.Name, name, comparison))) - { - scopes.RemoveRange(index, scopes.Count - index); - return; - } - } - } - - private static void TryPushHdlScope( - string language, - string structuralLine, - List scopes, - ref int nextDesignUnitId) - { - if (language != "vhdl") - { - var match = VerilogScopeStartRegex.Match(structuralLine); - if (match.Success) - { - AddHdlScope( - scopes, - NormalizeVerilogScopeKind(match.Groups["kind"].Value), - match.Groups["name"].Value, - ref nextDesignUnitId); - return; - } - - match = SystemVerilogClassStartRegex.Match(structuralLine); - if (match.Success) - { - AddHdlScope(scopes, "class", match.Groups["name"].Value, ref nextDesignUnitId); - return; - } - - match = VerilogFunctionStartRegex.Match(structuralLine); - if (match.Success) - { - AddHdlScope(scopes, "function", match.Groups["name"].Value, ref nextDesignUnitId); - return; - } - - match = VerilogTaskStartRegex.Match(structuralLine); - if (match.Success) - AddHdlScope(scopes, "function", match.Groups["name"].Value, ref nextDesignUnitId); - return; - } - - if (TryMatchHdlScope(VhdlArchitectureStartRegex, structuralLine, "module", scopes, ref nextDesignUnitId) - || TryMatchHdlScope(VhdlEntityStartRegex, structuralLine, "module", scopes, ref nextDesignUnitId) - || TryMatchHdlScope(VhdlPackageStartRegex, structuralLine, "package", scopes, ref nextDesignUnitId) - || TryMatchHdlScope(VhdlConfigurationStartRegex, structuralLine, "module", scopes, ref nextDesignUnitId)) - { - return; - } - - TryMatchHdlScope( - VhdlProcessStartRegex, - structuralLine, - "function", - scopes, - ref nextDesignUnitId); - } - - private static bool TryMatchHdlScope( - Regex regex, - string line, - string kind, - List scopes, - ref int nextDesignUnitId) - { - var match = regex.Match(line); - if (!match.Success) - return false; - - AddHdlScope(scopes, kind, match.Groups["name"].Value, ref nextDesignUnitId); - return true; - } - - private static void AddHdlScope( - List scopes, - string kind, - string name, - ref int nextDesignUnitId, - IReadOnlySet? shadowedNames = null) - { - var designUnitId = scopes.Count == 0 - ? nextDesignUnitId++ - : scopes[0].DesignUnitId; - scopes.Add(new HdlScope( - new SymbolRecord - { - Kind = kind, - Name = name, - }, - designUnitId, - shadowedNames == null - ? new HashSet(StringComparer.OrdinalIgnoreCase) - : new HashSet(shadowedNames, StringComparer.OrdinalIgnoreCase))); - } - - private static int[] BuildVhdlDesignUnitIds( - string[] lines, - int lineCount, - CancellationToken cancellationToken) - { - var result = new int[lines.Length]; - var scopes = new List(); - var designUnitIdsByKey = new Dictionary(StringComparer.OrdinalIgnoreCase); - var nextDesignUnitId = 1; - VhdlPendingSubprogramHeader? pendingSubprogram = null; - var unusedBlockCommentState = false; - for (var index = 0; index < lineCount; index++) - { - cancellationToken.ThrowIfCancellationRequested(); - var structuralLine = MaskHdlCommentsAndStrings( - lines[index], - "vhdl", - ref unusedBlockCommentState); - if (string.IsNullOrWhiteSpace(structuralLine)) - continue; - - TryPopHdlScope("vhdl", structuralLine, scopes); - var wasOutsideDesignUnit = scopes.Count == 0; - AdvanceVhdlSubprogramHeader( - structuralLine, - ref pendingSubprogram, - out var completedSubprogram); - if (completedSubprogram != null) - { - AddHdlScope( - scopes, - "function", - completedSubprogram.Name, - ref nextDesignUnitId, - completedSubprogram.ShadowedNames); - } - else - { - TryPushHdlScope("vhdl", structuralLine, scopes, ref nextDesignUnitId); - } - if (wasOutsideDesignUnit - && scopes.Count > 0 - && TryGetVhdlDesignUnitKey(structuralLine, out var designUnitKey)) - { - if (!designUnitIdsByKey.TryGetValue(designUnitKey, out var designUnitId)) - { - designUnitId = scopes[0].DesignUnitId; - designUnitIdsByKey[designUnitKey] = designUnitId; - } - scopes[0] = scopes[0] with { DesignUnitId = designUnitId }; - } - if (scopes.Count > 0) - result[index] = scopes[0].DesignUnitId; - } - - return result; - } - - private static HashSet? AdvanceVhdlSubprogramHeader( - string line, - ref VhdlPendingSubprogramHeader? pending, - out VhdlCompletedSubprogramHeader? completed) - { - completed = null; - if (pending == null) - { - var startMatch = VhdlFunctionStartRegex.Match(line); - if (!startMatch.Success) - startMatch = VhdlProcedureStartRegex.Match(line); - if (!startMatch.Success) - return null; - pending = new VhdlPendingSubprogramHeader(startMatch.Groups["name"].Value); - } - - var declaredOnLine = GetVhdlParameterNames(line); - if (declaredOnLine != null) - pending.ShadowedNames.UnionWith(declaredOnLine); - - foreach (var character in line) - { - if (character == '(') - pending.ParenthesisDepth++; - else if (character == ')' && pending.ParenthesisDepth > 0) - pending.ParenthesisDepth--; - } - - if (pending.ParenthesisDepth == 0 - && VhdlSubprogramBodyMarkerRegex.IsMatch(line)) - { - completed = new VhdlCompletedSubprogramHeader( - pending.Name, - new HashSet(pending.ShadowedNames, StringComparer.OrdinalIgnoreCase)); - pending = null; - } - else if (pending.ParenthesisDepth == 0 && line.Contains(';')) - { - pending = null; - } - - return declaredOnLine; - } - - private static HashSet? GetVhdlParameterNames(string line) - { - var result = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (Match parameterMatch in VhdlParameterNamesRegex.Matches(line)) - AddVhdlDeclaredNames(result, parameterMatch.Groups["names"].Value); - return result.Count == 0 ? null : result; - } - - private static HashSet? MergeVhdlDeclaredNames( - HashSet? first, - HashSet? second) - { - if (first == null) - return second; - if (second != null) - first.UnionWith(second); - return first; - } - - private static bool TryGetVhdlDesignUnitKey(string line, out string key) - { - var architectureMatch = VhdlArchitectureRegex.Match(line); - if (architectureMatch.Success) - { - key = $"entity:{architectureMatch.Groups["entity"].Value}"; - return true; - } - - var entityMatch = VhdlEntityStartRegex.Match(line); - if (entityMatch.Success) - { - key = $"entity:{entityMatch.Groups["name"].Value}"; - return true; - } - - var packageMatch = VhdlPackageStartRegex.Match(line); - if (packageMatch.Success) - { - key = $"package:{packageMatch.Groups["name"].Value}"; - return true; - } - - var configurationMatch = VhdlConfigurationStartRegex.Match(line); - if (configurationMatch.Success) - { - key = $"configuration:{configurationMatch.Groups["name"].Value}"; - return true; - } - - key = string.Empty; - return false; - } - - private static HashSet? GetVhdlDeclaredNames(string line) - { - Match? declarationMatch = null; - if (VhdlFunctionStartRegex.IsMatch(line) || VhdlProcedureStartRegex.IsMatch(line)) - { - var openParenthesis = line.IndexOf('('); - var closeParenthesis = line.LastIndexOf(')'); - if (openParenthesis >= 0 && closeParenthesis > openParenthesis) - { - var parameters = line.Substring( - openParenthesis + 1, - closeParenthesis - openParenthesis - 1); - var result = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (Match parameterMatch in VhdlParameterNamesRegex.Matches(parameters)) - AddVhdlDeclaredNames(result, parameterMatch.Groups["names"].Value); - return result.Count == 0 ? null : result; - } - } - else - { - declarationMatch = VhdlLocalDeclarationRegex.Match(line); - } - - if (declarationMatch is not { Success: true }) - return null; - - var names = new HashSet(StringComparer.OrdinalIgnoreCase); - AddVhdlDeclaredNames(names, declarationMatch.Groups["names"].Value); - return names.Count == 0 ? null : names; - } - - private static void AddVhdlDeclaredNames(HashSet names, string value) - { - foreach (var name in value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - names.Add(name); - } - - private static string NormalizeVerilogScopeKind(string kind) - => kind switch - { - "macromodule" or "primitive" or "program" => "module", - "task" => "function", - _ => kind, - }; - - private static string NormalizeVhdlScopeKind(string kind) - => kind switch - { - "architecture" or "entity" or "configuration" => "module", - "procedure" or "process" => "function", - _ => kind, - }; - - private static string MaskHdlCommentsAndStrings( - string line, - string language, - ref bool inVerilogBlockComment) - { - char[]? masked = null; - var inString = false; - for (var index = 0; index < line.Length; index++) - { - if (language != "vhdl" && inVerilogBlockComment) - { - MaskCharacter(ref masked, line, index); - if (line[index] == '*' && index + 1 < line.Length && line[index + 1] == '/') - { - MaskCharacter(ref masked, line, ++index); - inVerilogBlockComment = false; - } - - continue; - } - - if (inString) - { - MaskCharacter(ref masked, line, index); - if (language == "vhdl" - && line[index] == '"' - && index + 1 < line.Length - && line[index + 1] == '"') - { - MaskCharacter(ref masked, line, ++index); - continue; - } - - if (line[index] == '\\' && language != "vhdl" && index + 1 < line.Length) - { - MaskCharacter(ref masked, line, ++index); - continue; - } - - if (line[index] == '"') - inString = false; - continue; - } - - if (language == "vhdl" - && line[index] == '\'' - && index + 2 < line.Length - && line[index + 2] == '\'') - { - MaskRange(ref masked, line, index, index + 3); - index += 2; - continue; - } - - if (language != "vhdl" && line[index] == '\'') - { - var literalEnd = FindVerilogNumericLiteralEnd(line, index); - if (literalEnd > index) - { - MaskRange(ref masked, line, index, literalEnd); - index = literalEnd - 1; - continue; - } - } - - if (line[index] == '"') - { - if (language == "vhdl") - { - var bitStringStart = FindVhdlBitStringLiteralStart(line, index); - if (bitStringStart < index) - MaskRange(ref masked, line, bitStringStart, index); - } - inString = true; - MaskCharacter(ref masked, line, index); - continue; - } - - if (language == "vhdl" - && line[index] == '-' - && index + 1 < line.Length - && line[index + 1] == '-') - { - MaskRange(ref masked, line, index, line.Length); - break; - } - - if (language != "vhdl" - && line[index] == '/' - && index + 1 < line.Length) - { - if (line[index + 1] == '/') - { - MaskRange(ref masked, line, index, line.Length); - break; - } - - if (line[index + 1] == '*') - { - MaskCharacter(ref masked, line, index); - MaskCharacter(ref masked, line, ++index); - inVerilogBlockComment = true; - } - } - } - - return masked == null ? line : new string(masked); - } - - private static int FindVhdlBitStringLiteralStart(string line, int quoteIndex) - { - var baseIndex = quoteIndex - 1; - if (baseIndex < 0 || !"BOXDboxd".Contains(line[baseIndex])) - return quoteIndex; - - var start = baseIndex; - if (start > 0 && (line[start - 1] is 'U' or 'u' or 'S' or 's')) - start--; - while (start > 0 && (char.IsDigit(line[start - 1]) || line[start - 1] == '_')) - start--; - - return start == 0 || !IsVhdlIdentifierCharacter(line[start - 1]) - ? start - : quoteIndex; - } - - private static bool IsVhdlIdentifierCharacter(char value) - => char.IsAsciiLetterOrDigit(value) || value == '_'; - - private static int FindVerilogNumericLiteralEnd(string line, int apostropheIndex) - { - var index = apostropheIndex + 1; - if (index >= line.Length) - return apostropheIndex; - - if (line[index] is '0' or '1' or 'x' or 'X' or 'z' or 'Z' or '?') - return index + 1; - - if (line[index] is 's' or 'S') - index++; - if (index >= line.Length || line[index] is not ('b' or 'B' or 'o' or 'O' or 'd' or 'D' or 'h' or 'H')) - return apostropheIndex; - - index++; - var digitStart = index; - while (index < line.Length - && (char.IsLetterOrDigit(line[index]) || line[index] is '_' or '?')) - { - index++; - } - - return index > digitStart ? index : apostropheIndex; - } - - private static void MaskRange(ref char[]? masked, string source, int start, int end) - { - for (var index = start; index < end; index++) - MaskCharacter(ref masked, source, index); - } - - private static void MaskCharacter(ref char[]? masked, string source, int index) - { - masked ??= source.ToCharArray(); - masked[index] = ' '; - } } diff --git a/src/CodeIndex/Indexer/References/Languages/RReferenceExtractor.CallsAndResources.cs b/src/CodeIndex/Indexer/References/Languages/RReferenceExtractor.CallsAndResources.cs new file mode 100644 index 000000000..b50d45128 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/RReferenceExtractor.CallsAndResources.cs @@ -0,0 +1,489 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class RReferenceExtractor +{ + public static void EmitBacktickCallReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + HashSet? definitionNames) + { + if (preparedLine.IndexOf('`') < 0 || preparedLine.IndexOf('(') < 0) + return; + + foreach (Match match in BacktickCallRegex.Matches(preparedLine)) + { + var nameGroup = match.Groups["name"]; + var name = nameGroup.Value; + if (definitionNames != null && definitionNames.Contains(name)) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name, + nameGroup.Index, + "call", + context, + lineNumber, + container); + } + } + + public static void EmitInfixOperatorCallReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + HashSet? definitionNames) + { + if (preparedLine.IndexOf('%') < 0) + return; + + foreach (Match match in InfixOperatorCallRegex.Matches(preparedLine)) + { + var nameGroup = match.Groups["name"]; + var name = nameGroup.Value; + if (definitionNames != null && definitionNames.Contains(name)) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name, + nameGroup.Index, + "call", + context, + lineNumber, + container); + } + } + + public static void EmitSourceFileReferences( + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("source", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('(') < 0) + { + return; + } + + if (!SourceFileReferenceStartRegex.IsMatch(preparedLine)) + return; + + if (!ContainsRQuotedArgument(originalLine)) + return; + + var line = StripRNamespaceDirectiveComment(originalLine); + var match = SourceFileReferenceRegex.Match(line); + if (!match.Success) + return; + + var path = match.Groups["path"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + path.Value, + path.Index, + "reference", + context, + lineNumber, + container); + } + + public static void EmitLoadAllReferences( + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (originalLine.IndexOf("load_all", StringComparison.Ordinal) < 0 + || originalLine.IndexOf('(') < 0) + { + return; + } + + if (!ContainsRQuotedArgument(originalLine)) + return; + + var line = StripRNamespaceDirectiveComment(originalLine); + var match = LoadAllReferenceRegex.Match(line); + if (!match.Success) + return; + + var path = match.Groups["path"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + path.Value, + path.Index, + "reference", + context, + lineNumber, + container); + } + + public static void EmitDataCallReferences( + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("data", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('(') < 0) + { + return; + } + + if (!DataCallStartRegex.IsMatch(preparedLine)) + return; + + if (!ContainsRQuotedArgument(originalLine)) + return; + + var line = StripRNamespaceDirectiveComment(originalLine); + foreach (Match match in DataCallDatasetRegex.Matches(line)) + { + var name = match.Groups["name"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name.Value, + name.Index, + "reference", + context, + lineNumber, + container); + } + + var packageMatch = DataCallPackageRegex.Match(line); + if (!packageMatch.Success) + return; + + var package = packageMatch.Groups["name"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + package.Value, + package.Index, + "reference", + context, + lineNumber, + container); + } + + public static void EmitSystemFileReferences( + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("system.file", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('(') < 0) + { + return; + } + + if (!SystemFileCallStartRegex.IsMatch(preparedLine)) + return; + + if (!ContainsRQuotedArgument(originalLine)) + return; + + var line = StripRNamespaceDirectiveComment(originalLine); + foreach (Match match in SystemFilePathPartRegex.Matches(line)) + { + var name = match.Groups["name"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name.Value, + name.Index, + "reference", + context, + lineNumber, + container); + } + + var packageMatch = DataCallPackageRegex.Match(line); + if (!packageMatch.Success) + return; + + var package = packageMatch.Groups["name"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + package.Value, + package.Index, + "reference", + context, + lineNumber, + container); + } + + public static void EmitVignetteReferences( + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("vignette", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('(') < 0) + { + return; + } + + EmitDocumentationTopicReferences( + preparedLine, + originalLine, + references, + seen, + fileId, + context, + lineNumber, + container, + VignetteCallStartRegex); + } + + public static void EmitHelpExampleReferences( + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if ((preparedLine.IndexOf("help", StringComparison.Ordinal) < 0 + && preparedLine.IndexOf("example", StringComparison.Ordinal) < 0) + || preparedLine.IndexOf('(') < 0) + { + return; + } + + EmitDocumentationTopicReferences( + preparedLine, + originalLine, + references, + seen, + fileId, + context, + lineNumber, + container, + HelpExampleCallStartRegex); + } + + private static void EmitDocumentationTopicReferences( + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Regex startRegex) + { + if (!startRegex.IsMatch(preparedLine)) + return; + + if (!ContainsRQuotedArgument(originalLine)) + return; + + var line = StripRNamespaceDirectiveComment(originalLine); + foreach (Match match in DocumentationTopicRegex.Matches(line)) + { + var name = match.Groups["name"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name.Value, + name.Index, + "reference", + context, + lineNumber, + container); + } + + var packageMatch = DataCallPackageRegex.Match(line); + if (!packageMatch.Success) + return; + + var package = packageMatch.Groups["name"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + package.Value, + package.Index, + "reference", + context, + lineNumber, + container); + } + + public static void EmitInstallPackagesReferences( + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("install.packages", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('(') < 0) + { + return; + } + + EmitPackageNameArgumentReferences( + preparedLine, + originalLine, + references, + seen, + fileId, + context, + lineNumber, + container, + InstallPackagesCallStartRegex); + } + + public static void EmitNamespacePackageInstallReferences( + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("install", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('(') < 0) + { + return; + } + + EmitPackageNameArgumentReferences( + preparedLine, + originalLine, + references, + seen, + fileId, + context, + lineNumber, + container, + NamespacePackageInstallCallStartRegex); + } + + public static void EmitGitHubPackageInstallReferences( + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container) + { + if (preparedLine.IndexOf("install_github", StringComparison.Ordinal) < 0 + || preparedLine.IndexOf('(') < 0) + { + return; + } + + EmitPackageNameArgumentReferences( + preparedLine, + originalLine, + references, + seen, + fileId, + context, + lineNumber, + container, + GitHubPackageInstallCallStartRegex); + } + + private static void EmitPackageNameArgumentReferences( + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Regex startRegex) + { + if (!startRegex.IsMatch(preparedLine)) + return; + + if (!ContainsRQuotedArgument(originalLine)) + return; + + var line = StripRNamespaceDirectiveComment(originalLine); + foreach (Match match in InstallPackagesNameRegex.Matches(line)) + { + var name = match.Groups["name"]; + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name.Value, + name.Index, + "reference", + context, + lineNumber, + container); + } + } + + private static bool ContainsRQuotedArgument(string line) + => line.IndexOf('"') >= 0 || line.IndexOf('\'') >= 0; + +} diff --git a/src/CodeIndex/Indexer/References/Languages/RReferenceExtractor.Members.cs b/src/CodeIndex/Indexer/References/Languages/RReferenceExtractor.Members.cs new file mode 100644 index 000000000..1eb7c0415 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/RReferenceExtractor.Members.cs @@ -0,0 +1,233 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class RReferenceExtractor +{ + public static void EmitDollarMemberReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + HashSet? definitionNames) + { + if (preparedLine.IndexOf('$') < 0) + return; + + foreach (Match match in DollarMemberReferenceRegex.Matches(preparedLine)) + { + var backtickReceiverGroup = match.Groups["backtickReceiver"]; + var receiverGroup = backtickReceiverGroup.Success ? backtickReceiverGroup : match.Groups["receiver"]; + var receiver = receiverGroup.Value; + var backtickNameGroup = match.Groups["backtickName"]; + var nameGroup = backtickNameGroup.Success ? backtickNameGroup : match.Groups["name"]; + var name = nameGroup.Value; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + $"{receiver}${name}", + receiverGroup.Index, + "reference", + context, + lineNumber, + container); + + if (definitionNames != null && definitionNames.Contains(name)) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name, + nameGroup.Index, + "reference", + context, + lineNumber, + container); + } + } + + public static void EmitBracketMemberReferences( + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + HashSet? definitionNames) + { + if (!preparedLine.Contains("[[", StringComparison.Ordinal)) + return; + + if (!ContainsRQuotedArgument(originalLine)) + return; + + var line = StripRNamespaceDirectiveComment(originalLine); + foreach (Match match in BracketMemberReferenceRegex.Matches(line)) + { + var backtickReceiverGroup = match.Groups["backtickReceiver"]; + var receiverGroup = backtickReceiverGroup.Success ? backtickReceiverGroup : match.Groups["receiver"]; + var receiver = receiverGroup.Value; + var nameGroup = match.Groups["name"]; + var name = nameGroup.Value; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + $"{receiver}${name}", + receiverGroup.Index, + "reference", + context, + lineNumber, + container); + + if (definitionNames != null && definitionNames.Contains(name)) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name, + nameGroup.Index, + "reference", + context, + lineNumber, + container); + } + } + + public static void EmitSlotMemberReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + HashSet? definitionNames) + { + if (preparedLine.IndexOf('@') < 0) + return; + + foreach (Match match in SlotMemberReferenceRegex.Matches(preparedLine)) + { + var backtickReceiverGroup = match.Groups["backtickReceiver"]; + var receiverGroup = backtickReceiverGroup.Success ? backtickReceiverGroup : match.Groups["receiver"]; + var receiver = receiverGroup.Value; + var backtickNameGroup = match.Groups["backtickName"]; + var nameGroup = backtickNameGroup.Success ? backtickNameGroup : match.Groups["name"]; + var name = nameGroup.Value; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + $"{receiver}@{name}", + receiverGroup.Index, + "reference", + context, + lineNumber, + container); + + if (definitionNames != null && definitionNames.Contains(name)) + continue; + + ReferenceExtractor.AddReference( + references, + seen, + fileId, + name, + nameGroup.Index, + "reference", + context, + lineNumber, + container); + } + } + + private static IEnumerable<(string Name, int Index)> EnumerateNamespaceDirectiveNames(string value, int baseIndex) + { + foreach (Match match in NamespaceDirectiveNameRegex.Matches(value)) + { + var backtickNameGroup = match.Groups["backtickName"]; + var nameGroup = backtickNameGroup.Success ? backtickNameGroup : match.Groups["name"]; + yield return (nameGroup.Value, baseIndex + nameGroup.Index + (backtickNameGroup.Success ? 1 : 0)); + } + } + + private static (string Name, int Index)? GetNamespaceDirectiveToken(Match match, params string[] groupNames) + { + foreach (var groupName in groupNames) + { + var group = match.Groups[groupName]; + if (group.Success) + return (group.Value, group.Index); + } + + return null; + } + + private static string StripRNamespaceDirectiveComment(string line) + { + var inBacktickIdentifier = false; + var quote = '\0'; + for (var i = 0; i < line.Length; i++) + { + var ch = line[i]; + if (quote != '\0') + { + if (ch == '\\' && i + 1 < line.Length) + { + i++; + continue; + } + + if (ch == quote) + quote = '\0'; + continue; + } + + if (inBacktickIdentifier) + { + if (ch == '\\' && i + 1 < line.Length) + { + i++; + continue; + } + + if (ch == '`') + inBacktickIdentifier = false; + continue; + } + + if (ch is '\'' or '"') + { + quote = ch; + continue; + } + + if (ch == '`') + { + inBacktickIdentifier = true; + continue; + } + + if (ch == '#') + return line[..i]; + } + + return line; + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/RReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/RReferenceExtractor.cs index de9f5dffb..2967bd3f6 100644 --- a/src/CodeIndex/Indexer/References/Languages/RReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/RReferenceExtractor.cs @@ -4,7 +4,7 @@ namespace CodeIndex.Indexer; -internal static class RReferenceExtractor +internal static partial class RReferenceExtractor { // R namespace references like `pkg::fun` and `pkg:::fun` should be searchable as references // even when they are not invoked as calls. @@ -659,708 +659,4 @@ public static void EmitRoxygenMethodReferences( container); } - public static void EmitBacktickCallReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - HashSet? definitionNames) - { - if (preparedLine.IndexOf('`') < 0 || preparedLine.IndexOf('(') < 0) - return; - - foreach (Match match in BacktickCallRegex.Matches(preparedLine)) - { - var nameGroup = match.Groups["name"]; - var name = nameGroup.Value; - if (definitionNames != null && definitionNames.Contains(name)) - continue; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name, - nameGroup.Index, - "call", - context, - lineNumber, - container); - } - } - - public static void EmitInfixOperatorCallReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - HashSet? definitionNames) - { - if (preparedLine.IndexOf('%') < 0) - return; - - foreach (Match match in InfixOperatorCallRegex.Matches(preparedLine)) - { - var nameGroup = match.Groups["name"]; - var name = nameGroup.Value; - if (definitionNames != null && definitionNames.Contains(name)) - continue; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name, - nameGroup.Index, - "call", - context, - lineNumber, - container); - } - } - - public static void EmitSourceFileReferences( - string preparedLine, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("source", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('(') < 0) - { - return; - } - - if (!SourceFileReferenceStartRegex.IsMatch(preparedLine)) - return; - - if (!ContainsRQuotedArgument(originalLine)) - return; - - var line = StripRNamespaceDirectiveComment(originalLine); - var match = SourceFileReferenceRegex.Match(line); - if (!match.Success) - return; - - var path = match.Groups["path"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - path.Value, - path.Index, - "reference", - context, - lineNumber, - container); - } - - public static void EmitLoadAllReferences( - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (originalLine.IndexOf("load_all", StringComparison.Ordinal) < 0 - || originalLine.IndexOf('(') < 0) - { - return; - } - - if (!ContainsRQuotedArgument(originalLine)) - return; - - var line = StripRNamespaceDirectiveComment(originalLine); - var match = LoadAllReferenceRegex.Match(line); - if (!match.Success) - return; - - var path = match.Groups["path"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - path.Value, - path.Index, - "reference", - context, - lineNumber, - container); - } - - public static void EmitDataCallReferences( - string preparedLine, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("data", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('(') < 0) - { - return; - } - - if (!DataCallStartRegex.IsMatch(preparedLine)) - return; - - if (!ContainsRQuotedArgument(originalLine)) - return; - - var line = StripRNamespaceDirectiveComment(originalLine); - foreach (Match match in DataCallDatasetRegex.Matches(line)) - { - var name = match.Groups["name"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name.Value, - name.Index, - "reference", - context, - lineNumber, - container); - } - - var packageMatch = DataCallPackageRegex.Match(line); - if (!packageMatch.Success) - return; - - var package = packageMatch.Groups["name"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - package.Value, - package.Index, - "reference", - context, - lineNumber, - container); - } - - public static void EmitSystemFileReferences( - string preparedLine, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("system.file", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('(') < 0) - { - return; - } - - if (!SystemFileCallStartRegex.IsMatch(preparedLine)) - return; - - if (!ContainsRQuotedArgument(originalLine)) - return; - - var line = StripRNamespaceDirectiveComment(originalLine); - foreach (Match match in SystemFilePathPartRegex.Matches(line)) - { - var name = match.Groups["name"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name.Value, - name.Index, - "reference", - context, - lineNumber, - container); - } - - var packageMatch = DataCallPackageRegex.Match(line); - if (!packageMatch.Success) - return; - - var package = packageMatch.Groups["name"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - package.Value, - package.Index, - "reference", - context, - lineNumber, - container); - } - - public static void EmitVignetteReferences( - string preparedLine, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("vignette", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('(') < 0) - { - return; - } - - EmitDocumentationTopicReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container, - VignetteCallStartRegex); - } - - public static void EmitHelpExampleReferences( - string preparedLine, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if ((preparedLine.IndexOf("help", StringComparison.Ordinal) < 0 - && preparedLine.IndexOf("example", StringComparison.Ordinal) < 0) - || preparedLine.IndexOf('(') < 0) - { - return; - } - - EmitDocumentationTopicReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container, - HelpExampleCallStartRegex); - } - - private static void EmitDocumentationTopicReferences( - string preparedLine, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Regex startRegex) - { - if (!startRegex.IsMatch(preparedLine)) - return; - - if (!ContainsRQuotedArgument(originalLine)) - return; - - var line = StripRNamespaceDirectiveComment(originalLine); - foreach (Match match in DocumentationTopicRegex.Matches(line)) - { - var name = match.Groups["name"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name.Value, - name.Index, - "reference", - context, - lineNumber, - container); - } - - var packageMatch = DataCallPackageRegex.Match(line); - if (!packageMatch.Success) - return; - - var package = packageMatch.Groups["name"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - package.Value, - package.Index, - "reference", - context, - lineNumber, - container); - } - - public static void EmitInstallPackagesReferences( - string preparedLine, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("install.packages", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('(') < 0) - { - return; - } - - EmitPackageNameArgumentReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container, - InstallPackagesCallStartRegex); - } - - public static void EmitNamespacePackageInstallReferences( - string preparedLine, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("install", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('(') < 0) - { - return; - } - - EmitPackageNameArgumentReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container, - NamespacePackageInstallCallStartRegex); - } - - public static void EmitGitHubPackageInstallReferences( - string preparedLine, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container) - { - if (preparedLine.IndexOf("install_github", StringComparison.Ordinal) < 0 - || preparedLine.IndexOf('(') < 0) - { - return; - } - - EmitPackageNameArgumentReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container, - GitHubPackageInstallCallStartRegex); - } - - private static void EmitPackageNameArgumentReferences( - string preparedLine, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - Regex startRegex) - { - if (!startRegex.IsMatch(preparedLine)) - return; - - if (!ContainsRQuotedArgument(originalLine)) - return; - - var line = StripRNamespaceDirectiveComment(originalLine); - foreach (Match match in InstallPackagesNameRegex.Matches(line)) - { - var name = match.Groups["name"]; - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name.Value, - name.Index, - "reference", - context, - lineNumber, - container); - } - } - - private static bool ContainsRQuotedArgument(string line) - => line.IndexOf('"') >= 0 || line.IndexOf('\'') >= 0; - - public static void EmitDollarMemberReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - HashSet? definitionNames) - { - if (preparedLine.IndexOf('$') < 0) - return; - - foreach (Match match in DollarMemberReferenceRegex.Matches(preparedLine)) - { - var backtickReceiverGroup = match.Groups["backtickReceiver"]; - var receiverGroup = backtickReceiverGroup.Success ? backtickReceiverGroup : match.Groups["receiver"]; - var receiver = receiverGroup.Value; - var backtickNameGroup = match.Groups["backtickName"]; - var nameGroup = backtickNameGroup.Success ? backtickNameGroup : match.Groups["name"]; - var name = nameGroup.Value; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - $"{receiver}${name}", - receiverGroup.Index, - "reference", - context, - lineNumber, - container); - - if (definitionNames != null && definitionNames.Contains(name)) - continue; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name, - nameGroup.Index, - "reference", - context, - lineNumber, - container); - } - } - - public static void EmitBracketMemberReferences( - string preparedLine, - string originalLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - HashSet? definitionNames) - { - if (!preparedLine.Contains("[[", StringComparison.Ordinal)) - return; - - if (!ContainsRQuotedArgument(originalLine)) - return; - - var line = StripRNamespaceDirectiveComment(originalLine); - foreach (Match match in BracketMemberReferenceRegex.Matches(line)) - { - var backtickReceiverGroup = match.Groups["backtickReceiver"]; - var receiverGroup = backtickReceiverGroup.Success ? backtickReceiverGroup : match.Groups["receiver"]; - var receiver = receiverGroup.Value; - var nameGroup = match.Groups["name"]; - var name = nameGroup.Value; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - $"{receiver}${name}", - receiverGroup.Index, - "reference", - context, - lineNumber, - container); - - if (definitionNames != null && definitionNames.Contains(name)) - continue; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name, - nameGroup.Index, - "reference", - context, - lineNumber, - container); - } - } - - public static void EmitSlotMemberReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - SymbolRecord? container, - HashSet? definitionNames) - { - if (preparedLine.IndexOf('@') < 0) - return; - - foreach (Match match in SlotMemberReferenceRegex.Matches(preparedLine)) - { - var backtickReceiverGroup = match.Groups["backtickReceiver"]; - var receiverGroup = backtickReceiverGroup.Success ? backtickReceiverGroup : match.Groups["receiver"]; - var receiver = receiverGroup.Value; - var backtickNameGroup = match.Groups["backtickName"]; - var nameGroup = backtickNameGroup.Success ? backtickNameGroup : match.Groups["name"]; - var name = nameGroup.Value; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - $"{receiver}@{name}", - receiverGroup.Index, - "reference", - context, - lineNumber, - container); - - if (definitionNames != null && definitionNames.Contains(name)) - continue; - - ReferenceExtractor.AddReference( - references, - seen, - fileId, - name, - nameGroup.Index, - "reference", - context, - lineNumber, - container); - } - } - - private static IEnumerable<(string Name, int Index)> EnumerateNamespaceDirectiveNames(string value, int baseIndex) - { - foreach (Match match in NamespaceDirectiveNameRegex.Matches(value)) - { - var backtickNameGroup = match.Groups["backtickName"]; - var nameGroup = backtickNameGroup.Success ? backtickNameGroup : match.Groups["name"]; - yield return (nameGroup.Value, baseIndex + nameGroup.Index + (backtickNameGroup.Success ? 1 : 0)); - } - } - - private static (string Name, int Index)? GetNamespaceDirectiveToken(Match match, params string[] groupNames) - { - foreach (var groupName in groupNames) - { - var group = match.Groups[groupName]; - if (group.Success) - return (group.Value, group.Index); - } - - return null; - } - - private static string StripRNamespaceDirectiveComment(string line) - { - var inBacktickIdentifier = false; - var quote = '\0'; - for (var i = 0; i < line.Length; i++) - { - var ch = line[i]; - if (quote != '\0') - { - if (ch == '\\' && i + 1 < line.Length) - { - i++; - continue; - } - - if (ch == quote) - quote = '\0'; - continue; - } - - if (inBacktickIdentifier) - { - if (ch == '\\' && i + 1 < line.Length) - { - i++; - continue; - } - - if (ch == '`') - inBacktickIdentifier = false; - continue; - } - - if (ch is '\'' or '"') - { - quote = ch; - continue; - } - - if (ch == '`') - { - inBacktickIdentifier = true; - continue; - } - - if (ch == '#') - return line[..i]; - } - - return line; - } } diff --git a/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.Declarations.cs b/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.Declarations.cs new file mode 100644 index 000000000..49a7cf434 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.Declarations.cs @@ -0,0 +1,452 @@ +using CodeIndex.Models; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; + +namespace CodeIndex.Indexer; + +internal static partial class SwiftReferenceExtractor +{ + private static void EmitExtensionTargetReference( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var extensionIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "extension"); + if (extensionIndex < 0) + return; + + var targetStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, extensionIndex + "extension".Length); + var targetEnd = FindSwiftExtensionTargetEnd(preparedLine, targetStart); + if (targetEnd <= targetStart) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(targetStart, targetEnd - targetStart), + targetStart, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(targetStart)); + } + + private static int FindSwiftExtensionTargetEnd(string preparedLine, int targetStart) + { + var expressionEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, targetStart, stopAtComma: false); + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', targetStart); + if (colonIndex >= 0 && colonIndex < expressionEnd) + return colonIndex; + + return expressionEnd; + } + + private static void EmitGenericBoundReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var genericOpenIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '<'); + if (genericOpenIndex >= 0) + { + TypedLanguageReferenceExtractor.EmitGenericColonBoundReferences( + preparedLine, + genericOpenIndex, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } + + TypedLanguageReferenceExtractor.EmitWhereClauseTypeReferences( + preparedLine, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + EmitWhereClauseSameTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + } + + private static void EmitCallableSignatureTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var funcIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "func"); + if (funcIndex < 0) + return; + + var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '(', funcIndex + "func".Length); + if (openParen <= funcIndex) + return; + + var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); + if (closeParen < 0) + return; + + TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( + preparedLine, + openParen + 1, + closeParen, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + + EmitTypedThrowsReferences(preparedLine, closeParen + 1, references, seen, fileId, context, lineNumber, resolveContainerForColumn); + + var arrowIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(preparedLine, "->", closeParen + 1); + if (arrowIndex < 0) + return; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, arrowIndex + 2); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); + if (typeEnd <= typeStart) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + + private static void EmitClosureSignatureTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var braceIndex = preparedLine.IndexOf('{', StringComparison.Ordinal); + if (braceIndex < 0) + return; + + var openParen = preparedLine.IndexOf('(', braceIndex + 1); + if (openParen < 0) + return; + + var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); + if (closeParen < 0) + return; + + var inIndex = FindSwiftClosureInKeyword(preparedLine, closeParen + 1); + if (inIndex < 0) + return; + + TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( + preparedLine, + openParen + 1, + closeParen, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + + var arrowIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(preparedLine, "->", closeParen + 1); + if (arrowIndex < 0 || arrowIndex >= inIndex) + return; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, arrowIndex + 2); + if (typeStart >= inIndex) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, inIndex - typeStart), + typeStart, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + + private static int FindSwiftClosureInKeyword(string preparedLine, int startIndex) + { + foreach (var inIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "in", startIndex)) + return inIndex; + + return -1; + } + + private static void EmitWhereClauseSameTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + foreach (var whereIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "where")) + { + var clauseStart = whereIndex + "where".Length; + var clauseEnd = FindSwiftWhereClauseEnd(preparedLine, clauseStart); + + var clause = preparedLine.Substring(clauseStart, clauseEnd - clauseStart); + foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(clause)) + { + var fragment = clause.Substring(segmentStart, segmentLength); + var equalsIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(fragment, "=="); + if (equalsIndex < 0) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, equalsIndex + 2); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart, stopAtComma: false, stopAtArrow: false); + if (typeEnd <= typeStart) + continue; + + var absoluteStart = clauseStart + segmentStart + typeStart; + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + fragment.Substring(typeStart, typeEnd - typeStart), + absoluteStart, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(absoluteStart)); + } + } + } + + private static int FindSwiftWhereClauseEnd(string preparedLine, int clauseStart) + { + var clauseEnd = preparedLine.Length; + var braceIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '{', clauseStart); + if (braceIndex >= 0) + clauseEnd = Math.Min(clauseEnd, braceIndex); + + var semicolonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ';', clauseStart); + if (semicolonIndex >= 0) + clauseEnd = Math.Min(clauseEnd, semicolonIndex); + + return Math.Max(clauseStart, clauseEnd); + } + + private static void EmitTypealiasRhsTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var typealiasIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "typealias"); + if (typealiasIndex < 0) + return; + + var equalsIndex = FindTopLevelAssignmentEquals(preparedLine, typealiasIndex + "typealias".Length); + if (equalsIndex < 0) + return; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, equalsIndex + 1); + var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart, stopAtComma: false, stopAtArrow: false); + if (typeEnd <= typeStart) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, typeEnd - typeStart), + typeStart, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + } + + private static void EmitAssociatedTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var associatedTypeIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "associatedtype"); + if (associatedTypeIndex < 0) + return; + + var declarationStart = associatedTypeIndex + "associatedtype".Length; + var equalsIndex = FindTopLevelAssignmentEquals(preparedLine, declarationStart); + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', declarationStart); + + if (colonIndex >= 0 && (equalsIndex < 0 || colonIndex < equalsIndex)) + { + var constraintStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); + var constraintEnd = equalsIndex >= 0 + ? equalsIndex + : TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, constraintStart, stopAtComma: false); + if (constraintEnd > constraintStart) + { + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(constraintStart, constraintEnd - constraintStart), + constraintStart, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(constraintStart)); + } + } + + if (equalsIndex < 0) + return; + + var defaultStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, equalsIndex + 1); + var defaultEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, defaultStart, stopAtComma: false, stopAtArrow: false); + if (defaultEnd <= defaultStart) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(defaultStart, defaultEnd - defaultStart), + defaultStart, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(defaultStart)); + } + + private static int FindTopLevelAssignmentEquals(string preparedLine, int startIndex) + { + var searchStart = startIndex; + while (searchStart < preparedLine.Length) + { + var equalsIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', searchStart); + if (equalsIndex < 0) + return -1; + + var previous = equalsIndex > 0 ? preparedLine[equalsIndex - 1] : '\0'; + var next = equalsIndex + 1 < preparedLine.Length ? preparedLine[equalsIndex + 1] : '\0'; + if (previous is not ('=' or '!' or '<' or '>') && next != '=') + return equalsIndex; + + searchStart = equalsIndex + 1; + } + + return -1; + } + + private static void EmitTypedThrowsReferences( + string preparedLine, + int searchStart, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + foreach (var throwsIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "throws", searchStart)) + { + var openParen = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, throwsIndex + "throws".Length); + if (openParen >= preparedLine.Length || preparedLine[openParen] != '(') + continue; + + var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); + if (closeParen < 0) + continue; + + var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, openParen + 1); + if (typeStart >= closeParen) + return; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(typeStart, closeParen - typeStart), + typeStart, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(typeStart)); + return; + } + } + + private static void EmitHeritageTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + var trimmed = preparedLine.TrimStart(); + if (!(trimmed.StartsWith("class ", StringComparison.Ordinal) + || trimmed.StartsWith("struct ", StringComparison.Ordinal) + || trimmed.StartsWith("protocol ", StringComparison.Ordinal) + || trimmed.StartsWith("enum ", StringComparison.Ordinal) + || trimmed.StartsWith("extension ", StringComparison.Ordinal))) + { + return; + } + + var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':'); + if (colonIndex < 0) + return; + + var listStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); + var listEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, listStart, stopAtComma: false); + if (listEnd <= listStart) + return; + + TypedLanguageReferenceExtractor.EmitCommaSeparatedTypeListReferences( + preparedLine, + listStart, + listEnd, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.GenericCalls.cs b/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.GenericCalls.cs new file mode 100644 index 000000000..6e24fe2d2 --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.GenericCalls.cs @@ -0,0 +1,325 @@ +using CodeIndex.Models; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; + +namespace CodeIndex.Indexer; + +internal static partial class SwiftReferenceExtractor +{ + private static void EmitCatchPatternTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + foreach (var catchIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "catch")) + { + var patternStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, catchIndex + "catch".Length); + if (patternStart >= preparedLine.Length + || preparedLine[patternStart] == '{' + || StartsWithSwiftWord(preparedLine, patternStart, "let") + || StartsWithSwiftWord(preparedLine, patternStart, "var")) + { + continue; + } + + if (StartsWithSwiftWord(preparedLine, patternStart, "is")) + { + patternStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, patternStart + "is".Length); + if (patternStart >= preparedLine.Length || preparedLine[patternStart] == '{') + continue; + } + + var typeEnd = FindSwiftCatchPatternTypeEnd(preparedLine, patternStart); + if (typeEnd <= patternStart) + continue; + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(patternStart, typeEnd - patternStart), + patternStart, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(patternStart)); + } + } + + private static int FindSwiftCatchPatternTypeEnd(string preparedLine, int patternStart) + { + for (var index = patternStart; index < preparedLine.Length; index++) + { + var ch = preparedLine[index]; + if (ch == '.' + || ch == '{' + || ch == ',' + || ch == '(' + || StartsWithSwiftWord(preparedLine, index, "where")) + { + return index; + } + } + + return preparedLine.Length; + } + + private static void EmitGenericInvocationArgumentReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + for (var index = 0; index < preparedLine.Length; index++) + { + if (!IsSwiftIdentifierStart(preparedLine[index])) + continue; + + var nameStart = index; + index++; + while (index < preparedLine.Length && IsSwiftIdentifierPart(preparedLine[index])) + index++; + + if (HasSwiftDeclarationKeywordBefore(preparedLine, nameStart) + || index >= preparedLine.Length + || preparedLine[index] != '<') + { + index--; + continue; + } + + var closeAngle = ReferenceExtractor.FindMatchingChar(preparedLine, index, '<', '>'); + if (closeAngle < 0) + { + index--; + continue; + } + + var afterGeneric = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, closeAngle + 1); + if (afterGeneric >= preparedLine.Length || preparedLine[afterGeneric] is not ('(' or '{')) + { + index = closeAngle; + continue; + } + + TypedLanguageReferenceExtractor.EmitCommaSeparatedTypeListReferences( + preparedLine, + index + 1, + closeAngle, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + index = closeAngle; + } + } + + private static void EmitGenericStaticMemberTypeReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + for (var index = 0; index < preparedLine.Length; index++) + { + if (!LooksLikeSwiftTypeExpressionStart(preparedLine[index])) + continue; + + var nameStart = index; + index++; + while (index < preparedLine.Length && IsSwiftIdentifierPart(preparedLine[index])) + index++; + + if (HasSwiftDeclarationKeywordBefore(preparedLine, nameStart) + || index >= preparedLine.Length + || preparedLine[index] != '<') + { + index--; + continue; + } + + var closeAngle = ReferenceExtractor.FindMatchingChar(preparedLine, index, '<', '>'); + if (closeAngle < 0) + { + index--; + continue; + } + + var afterGeneric = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, closeAngle + 1); + if (afterGeneric >= preparedLine.Length || preparedLine[afterGeneric] != '.') + { + index = closeAngle; + continue; + } + + TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( + preparedLine.Substring(nameStart, closeAngle - nameStart + 1), + nameStart, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn(nameStart)); + index = closeAngle; + } + } + + private static bool HasSwiftDeclarationKeywordBefore(string preparedLine, int nameStart) + { + var previous = nameStart - 1; + while (previous >= 0 && char.IsWhiteSpace(preparedLine[previous])) + previous--; + if (previous < 0) + return false; + + var wordEnd = previous + 1; + while (previous >= 0 && IsSwiftIdentifierPart(preparedLine[previous])) + previous--; + var wordStart = previous + 1; + if (wordStart >= wordEnd) + return false; + + var word = preparedLine[wordStart..wordEnd]; + return word is "associatedtype" or "class" or "enum" or "extension" or "func" or "macro" + or "protocol" or "struct" or "typealias"; + } + + private static void EmitMacroGenericArgumentReferences( + string preparedLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn) + { + for (int hashIndex = 0; hashIndex < preparedLine.Length; hashIndex++) + { + if (preparedLine[hashIndex] != '#') + continue; + + var nameStart = hashIndex + 1; + if (nameStart >= preparedLine.Length || !IsSwiftIdentifierStart(preparedLine[nameStart])) + continue; + + var nameEnd = nameStart + 1; + while (nameEnd < preparedLine.Length && IsSwiftIdentifierPart(preparedLine[nameEnd])) + nameEnd++; + + var openAngle = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, nameEnd); + if (openAngle >= preparedLine.Length || preparedLine[openAngle] != '<') + continue; + + var closeAngle = ReferenceExtractor.FindMatchingChar(preparedLine, openAngle, '<', '>'); + if (closeAngle < 0) + continue; + + TypedLanguageReferenceExtractor.EmitCommaSeparatedTypeListReferences( + preparedLine, + openAngle + 1, + closeAngle, + "swift", + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + hashIndex = closeAngle; + } + } + + private static bool IsSwiftIdentifierStart(char ch) + => ch == '_' || char.IsLetter(ch); + + private static bool IsSwiftIdentifierPart(char ch) + => ch == '_' || char.IsLetterOrDigit(ch); + + private static bool StartsWithSwiftWord(string text, int index, string word) + { + if (index < 0 || index + word.Length > text.Length) + return false; + if (!text.AsSpan(index, word.Length).SequenceEqual(word.AsSpan())) + return false; + + var beforeOk = index == 0 || !IsSwiftIdentifierPart(text[index - 1]); + var after = index + word.Length; + var afterOk = after >= text.Length || !IsSwiftIdentifierPart(text[after]); + return beforeOk && afterOk; + } + + private static int FindSwiftKeyPathRootEnd(string preparedLine, int rootStart) + { + var angleDepth = 0; + var parenDepth = 0; + var squareDepth = 0; + for (int index = rootStart; index < preparedLine.Length; index++) + { + var ch = preparedLine[index]; + switch (ch) + { + case '<': + angleDepth++; + break; + case '>': + if (angleDepth > 0) + angleDepth--; + break; + case '(': + parenDepth++; + break; + case ')': + if (parenDepth > 0) + parenDepth--; + else + return index; + break; + case '[': + squareDepth++; + break; + case ']': + if (squareDepth > 0) + squareDepth--; + else + return index; + break; + case '.': + if (angleDepth == 0 + && parenDepth == 0 + && squareDepth == 0 + && index + 1 < preparedLine.Length + && (char.IsLower(preparedLine[index + 1]) || preparedLine[index + 1] == '_')) + { + return index; + } + + break; + case ',': + case ';': + case '{': + case '}': + if (angleDepth == 0 && parenDepth == 0 && squareDepth == 0) + return index; + break; + } + } + + return preparedLine.Length; + } + +} diff --git a/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.cs index 7c4232509..cf5be4ad6 100644 --- a/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/SwiftReferenceExtractor.cs @@ -4,7 +4,7 @@ namespace CodeIndex.Indexer; -internal static class SwiftReferenceExtractor +internal static partial class SwiftReferenceExtractor { internal readonly record struct LineRange(int StartLine, int EndLine); internal readonly record struct TypeAliasBinding( @@ -806,763 +806,4 @@ private static bool IsSwiftSubscriptLikeOpenBracket(string preparedLine, int ope return previous >= 0 && (IsSwiftIdentifierPart(preparedLine[previous]) || preparedLine[previous] == ']'); } - private static void EmitCatchPatternTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - foreach (var catchIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "catch")) - { - var patternStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, catchIndex + "catch".Length); - if (patternStart >= preparedLine.Length - || preparedLine[patternStart] == '{' - || StartsWithSwiftWord(preparedLine, patternStart, "let") - || StartsWithSwiftWord(preparedLine, patternStart, "var")) - { - continue; - } - - if (StartsWithSwiftWord(preparedLine, patternStart, "is")) - { - patternStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, patternStart + "is".Length); - if (patternStart >= preparedLine.Length || preparedLine[patternStart] == '{') - continue; - } - - var typeEnd = FindSwiftCatchPatternTypeEnd(preparedLine, patternStart); - if (typeEnd <= patternStart) - continue; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(patternStart, typeEnd - patternStart), - patternStart, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(patternStart)); - } - } - - private static int FindSwiftCatchPatternTypeEnd(string preparedLine, int patternStart) - { - for (var index = patternStart; index < preparedLine.Length; index++) - { - var ch = preparedLine[index]; - if (ch == '.' - || ch == '{' - || ch == ',' - || ch == '(' - || StartsWithSwiftWord(preparedLine, index, "where")) - { - return index; - } - } - - return preparedLine.Length; - } - - private static void EmitGenericInvocationArgumentReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - for (var index = 0; index < preparedLine.Length; index++) - { - if (!IsSwiftIdentifierStart(preparedLine[index])) - continue; - - var nameStart = index; - index++; - while (index < preparedLine.Length && IsSwiftIdentifierPart(preparedLine[index])) - index++; - - if (HasSwiftDeclarationKeywordBefore(preparedLine, nameStart) - || index >= preparedLine.Length - || preparedLine[index] != '<') - { - index--; - continue; - } - - var closeAngle = ReferenceExtractor.FindMatchingChar(preparedLine, index, '<', '>'); - if (closeAngle < 0) - { - index--; - continue; - } - - var afterGeneric = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, closeAngle + 1); - if (afterGeneric >= preparedLine.Length || preparedLine[afterGeneric] is not ('(' or '{')) - { - index = closeAngle; - continue; - } - - TypedLanguageReferenceExtractor.EmitCommaSeparatedTypeListReferences( - preparedLine, - index + 1, - closeAngle, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - index = closeAngle; - } - } - - private static void EmitGenericStaticMemberTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - for (var index = 0; index < preparedLine.Length; index++) - { - if (!LooksLikeSwiftTypeExpressionStart(preparedLine[index])) - continue; - - var nameStart = index; - index++; - while (index < preparedLine.Length && IsSwiftIdentifierPart(preparedLine[index])) - index++; - - if (HasSwiftDeclarationKeywordBefore(preparedLine, nameStart) - || index >= preparedLine.Length - || preparedLine[index] != '<') - { - index--; - continue; - } - - var closeAngle = ReferenceExtractor.FindMatchingChar(preparedLine, index, '<', '>'); - if (closeAngle < 0) - { - index--; - continue; - } - - var afterGeneric = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, closeAngle + 1); - if (afterGeneric >= preparedLine.Length || preparedLine[afterGeneric] != '.') - { - index = closeAngle; - continue; - } - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(nameStart, closeAngle - nameStart + 1), - nameStart, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(nameStart)); - index = closeAngle; - } - } - - private static bool HasSwiftDeclarationKeywordBefore(string preparedLine, int nameStart) - { - var previous = nameStart - 1; - while (previous >= 0 && char.IsWhiteSpace(preparedLine[previous])) - previous--; - if (previous < 0) - return false; - - var wordEnd = previous + 1; - while (previous >= 0 && IsSwiftIdentifierPart(preparedLine[previous])) - previous--; - var wordStart = previous + 1; - if (wordStart >= wordEnd) - return false; - - var word = preparedLine[wordStart..wordEnd]; - return word is "associatedtype" or "class" or "enum" or "extension" or "func" or "macro" - or "protocol" or "struct" or "typealias"; - } - - private static void EmitMacroGenericArgumentReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - for (int hashIndex = 0; hashIndex < preparedLine.Length; hashIndex++) - { - if (preparedLine[hashIndex] != '#') - continue; - - var nameStart = hashIndex + 1; - if (nameStart >= preparedLine.Length || !IsSwiftIdentifierStart(preparedLine[nameStart])) - continue; - - var nameEnd = nameStart + 1; - while (nameEnd < preparedLine.Length && IsSwiftIdentifierPart(preparedLine[nameEnd])) - nameEnd++; - - var openAngle = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, nameEnd); - if (openAngle >= preparedLine.Length || preparedLine[openAngle] != '<') - continue; - - var closeAngle = ReferenceExtractor.FindMatchingChar(preparedLine, openAngle, '<', '>'); - if (closeAngle < 0) - continue; - - TypedLanguageReferenceExtractor.EmitCommaSeparatedTypeListReferences( - preparedLine, - openAngle + 1, - closeAngle, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - hashIndex = closeAngle; - } - } - - private static bool IsSwiftIdentifierStart(char ch) - => ch == '_' || char.IsLetter(ch); - - private static bool IsSwiftIdentifierPart(char ch) - => ch == '_' || char.IsLetterOrDigit(ch); - - private static bool StartsWithSwiftWord(string text, int index, string word) - { - if (index < 0 || index + word.Length > text.Length) - return false; - if (!text.AsSpan(index, word.Length).SequenceEqual(word.AsSpan())) - return false; - - var beforeOk = index == 0 || !IsSwiftIdentifierPart(text[index - 1]); - var after = index + word.Length; - var afterOk = after >= text.Length || !IsSwiftIdentifierPart(text[after]); - return beforeOk && afterOk; - } - - private static int FindSwiftKeyPathRootEnd(string preparedLine, int rootStart) - { - var angleDepth = 0; - var parenDepth = 0; - var squareDepth = 0; - for (int index = rootStart; index < preparedLine.Length; index++) - { - var ch = preparedLine[index]; - switch (ch) - { - case '<': - angleDepth++; - break; - case '>': - if (angleDepth > 0) - angleDepth--; - break; - case '(': - parenDepth++; - break; - case ')': - if (parenDepth > 0) - parenDepth--; - else - return index; - break; - case '[': - squareDepth++; - break; - case ']': - if (squareDepth > 0) - squareDepth--; - else - return index; - break; - case '.': - if (angleDepth == 0 - && parenDepth == 0 - && squareDepth == 0 - && index + 1 < preparedLine.Length - && (char.IsLower(preparedLine[index + 1]) || preparedLine[index + 1] == '_')) - { - return index; - } - - break; - case ',': - case ';': - case '{': - case '}': - if (angleDepth == 0 && parenDepth == 0 && squareDepth == 0) - return index; - break; - } - } - - return preparedLine.Length; - } - - private static void EmitExtensionTargetReference( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var extensionIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "extension"); - if (extensionIndex < 0) - return; - - var targetStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, extensionIndex + "extension".Length); - var targetEnd = FindSwiftExtensionTargetEnd(preparedLine, targetStart); - if (targetEnd <= targetStart) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(targetStart, targetEnd - targetStart), - targetStart, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(targetStart)); - } - - private static int FindSwiftExtensionTargetEnd(string preparedLine, int targetStart) - { - var expressionEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, targetStart, stopAtComma: false); - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', targetStart); - if (colonIndex >= 0 && colonIndex < expressionEnd) - return colonIndex; - - return expressionEnd; - } - - private static void EmitGenericBoundReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var genericOpenIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '<'); - if (genericOpenIndex >= 0) - { - TypedLanguageReferenceExtractor.EmitGenericColonBoundReferences( - preparedLine, - genericOpenIndex, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } - - TypedLanguageReferenceExtractor.EmitWhereClauseTypeReferences( - preparedLine, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - EmitWhereClauseSameTypeReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - } - - private static void EmitCallableSignatureTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var funcIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "func"); - if (funcIndex < 0) - return; - - var openParen = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '(', funcIndex + "func".Length); - if (openParen <= funcIndex) - return; - - var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); - if (closeParen < 0) - return; - - TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( - preparedLine, - openParen + 1, - closeParen, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - - EmitTypedThrowsReferences(preparedLine, closeParen + 1, references, seen, fileId, context, lineNumber, resolveContainerForColumn); - - var arrowIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(preparedLine, "->", closeParen + 1); - if (arrowIndex < 0) - return; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, arrowIndex + 2); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart); - if (typeEnd <= typeStart) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - - private static void EmitClosureSignatureTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var braceIndex = preparedLine.IndexOf('{', StringComparison.Ordinal); - if (braceIndex < 0) - return; - - var openParen = preparedLine.IndexOf('(', braceIndex + 1); - if (openParen < 0) - return; - - var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); - if (closeParen < 0) - return; - - var inIndex = FindSwiftClosureInKeyword(preparedLine, closeParen + 1); - if (inIndex < 0) - return; - - TypedLanguageReferenceExtractor.EmitColonParameterTypeReferences( - preparedLine, - openParen + 1, - closeParen, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - - var arrowIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(preparedLine, "->", closeParen + 1); - if (arrowIndex < 0 || arrowIndex >= inIndex) - return; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, arrowIndex + 2); - if (typeStart >= inIndex) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, inIndex - typeStart), - typeStart, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - - private static int FindSwiftClosureInKeyword(string preparedLine, int startIndex) - { - foreach (var inIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "in", startIndex)) - return inIndex; - - return -1; - } - - private static void EmitWhereClauseSameTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - foreach (var whereIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "where")) - { - var clauseStart = whereIndex + "where".Length; - var clauseEnd = FindSwiftWhereClauseEnd(preparedLine, clauseStart); - - var clause = preparedLine.Substring(clauseStart, clauseEnd - clauseStart); - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(clause)) - { - var fragment = clause.Substring(segmentStart, segmentLength); - var equalsIndex = TypedLanguageReferenceExtractor.FindTopLevelSequence(fragment, "=="); - if (equalsIndex < 0) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(fragment, equalsIndex + 2); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(fragment, typeStart, stopAtComma: false, stopAtArrow: false); - if (typeEnd <= typeStart) - continue; - - var absoluteStart = clauseStart + segmentStart + typeStart; - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - fragment.Substring(typeStart, typeEnd - typeStart), - absoluteStart, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(absoluteStart)); - } - } - } - - private static int FindSwiftWhereClauseEnd(string preparedLine, int clauseStart) - { - var clauseEnd = preparedLine.Length; - var braceIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '{', clauseStart); - if (braceIndex >= 0) - clauseEnd = Math.Min(clauseEnd, braceIndex); - - var semicolonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ';', clauseStart); - if (semicolonIndex >= 0) - clauseEnd = Math.Min(clauseEnd, semicolonIndex); - - return Math.Max(clauseStart, clauseEnd); - } - - private static void EmitTypealiasRhsTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var typealiasIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "typealias"); - if (typealiasIndex < 0) - return; - - var equalsIndex = FindTopLevelAssignmentEquals(preparedLine, typealiasIndex + "typealias".Length); - if (equalsIndex < 0) - return; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, equalsIndex + 1); - var typeEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, typeStart, stopAtComma: false, stopAtArrow: false); - if (typeEnd <= typeStart) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, typeEnd - typeStart), - typeStart, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - } - - private static void EmitAssociatedTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var associatedTypeIndex = ReferenceExtractor.FindTopLevelKeyword(preparedLine, "associatedtype"); - if (associatedTypeIndex < 0) - return; - - var declarationStart = associatedTypeIndex + "associatedtype".Length; - var equalsIndex = FindTopLevelAssignmentEquals(preparedLine, declarationStart); - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':', declarationStart); - - if (colonIndex >= 0 && (equalsIndex < 0 || colonIndex < equalsIndex)) - { - var constraintStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); - var constraintEnd = equalsIndex >= 0 - ? equalsIndex - : TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, constraintStart, stopAtComma: false); - if (constraintEnd > constraintStart) - { - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(constraintStart, constraintEnd - constraintStart), - constraintStart, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(constraintStart)); - } - } - - if (equalsIndex < 0) - return; - - var defaultStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, equalsIndex + 1); - var defaultEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, defaultStart, stopAtComma: false, stopAtArrow: false); - if (defaultEnd <= defaultStart) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(defaultStart, defaultEnd - defaultStart), - defaultStart, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(defaultStart)); - } - - private static int FindTopLevelAssignmentEquals(string preparedLine, int startIndex) - { - var searchStart = startIndex; - while (searchStart < preparedLine.Length) - { - var equalsIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, '=', searchStart); - if (equalsIndex < 0) - return -1; - - var previous = equalsIndex > 0 ? preparedLine[equalsIndex - 1] : '\0'; - var next = equalsIndex + 1 < preparedLine.Length ? preparedLine[equalsIndex + 1] : '\0'; - if (previous is not ('=' or '!' or '<' or '>') && next != '=') - return equalsIndex; - - searchStart = equalsIndex + 1; - } - - return -1; - } - - private static void EmitTypedThrowsReferences( - string preparedLine, - int searchStart, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - foreach (var throwsIndex in TypedLanguageReferenceExtractor.EnumerateTopLevelKeywordIndices(preparedLine, "throws", searchStart)) - { - var openParen = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, throwsIndex + "throws".Length); - if (openParen >= preparedLine.Length || preparedLine[openParen] != '(') - continue; - - var closeParen = ReferenceExtractor.FindMatchingChar(preparedLine, openParen, '(', ')'); - if (closeParen < 0) - continue; - - var typeStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, openParen + 1); - if (typeStart >= closeParen) - return; - - TypedLanguageReferenceExtractor.EmitTypeExpressionReferences( - preparedLine.Substring(typeStart, closeParen - typeStart), - typeStart, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn(typeStart)); - return; - } - } - - private static void EmitHeritageTypeReferences( - string preparedLine, - List references, - ReferenceDedupeSet seen, - long fileId, - string context, - int lineNumber, - Func resolveContainerForColumn) - { - var trimmed = preparedLine.TrimStart(); - if (!(trimmed.StartsWith("class ", StringComparison.Ordinal) - || trimmed.StartsWith("struct ", StringComparison.Ordinal) - || trimmed.StartsWith("protocol ", StringComparison.Ordinal) - || trimmed.StartsWith("enum ", StringComparison.Ordinal) - || trimmed.StartsWith("extension ", StringComparison.Ordinal))) - { - return; - } - - var colonIndex = TypedLanguageReferenceExtractor.FindTopLevelChar(preparedLine, ':'); - if (colonIndex < 0) - return; - - var listStart = TypedLanguageReferenceExtractor.SkipTypePrefixTrivia(preparedLine, colonIndex + 1); - var listEnd = TypedLanguageReferenceExtractor.FindTypeExpressionEnd(preparedLine, listStart, stopAtComma: false); - if (listEnd <= listStart) - return; - - TypedLanguageReferenceExtractor.EmitCommaSeparatedTypeListReferences( - preparedLine, - listStart, - listEnd, - "swift", - references, - seen, - fileId, - context, - lineNumber, - resolveContainerForColumn); - } } From 0ce9b94b59654510578558d4d7e0763cd9f0b282 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:37:47 +0900 Subject: [PATCH 050/101] Split database context responsibilities --- .../Database/DbContext.ConnectionFunctions.cs | 544 +++ .../Database/DbContext.ConnectionLifecycle.cs | 632 ++++ .../Database/DbContext.Maintenance.cs | 228 ++ .../Database/DbContext.ReadMigrations.cs | 437 +++ .../DbContext.SchemaInitialization.cs | 755 ++++ .../Database/DbContext.SchemaMetadata.cs | 372 ++ .../Database/DbContext.SchemaRebuild.cs | 302 ++ src/CodeIndex/Database/DbContext.cs | 3181 +---------------- 8 files changed, 3271 insertions(+), 3180 deletions(-) create mode 100644 src/CodeIndex/Database/DbContext.ConnectionFunctions.cs create mode 100644 src/CodeIndex/Database/DbContext.ConnectionLifecycle.cs create mode 100644 src/CodeIndex/Database/DbContext.Maintenance.cs create mode 100644 src/CodeIndex/Database/DbContext.ReadMigrations.cs create mode 100644 src/CodeIndex/Database/DbContext.SchemaInitialization.cs create mode 100644 src/CodeIndex/Database/DbContext.SchemaMetadata.cs create mode 100644 src/CodeIndex/Database/DbContext.SchemaRebuild.cs diff --git a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs new file mode 100644 index 000000000..fdb2a7712 --- /dev/null +++ b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs @@ -0,0 +1,544 @@ +using CodeIndex.Cli; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; +using System.Globalization; +using System.Runtime.ExceptionServices; + +namespace CodeIndex.Database; + +public partial class DbContext : IDisposable +{ + private static SqliteConnection OpenArtifactPreservingQueryOnly(string dbPath) + { + var connection = CreateArtifactPreservingQueryOnlyConnection( + dbPath, + pooling: false, + out _, + out _, + out _); + connection.Open(); + return connection; + } + + internal static void RegisterConnectionFunctions(SqliteConnection connection) + { + static int? ToNullableInt(long? value) + => value is null || value < int.MinValue || value > int.MaxValue ? null : (int)value.Value; + + connection.CreateFunction( + "markdown_resolve_path", + (string? sourcePath, string? targetPath) => DbReader.ResolveMarkdownDependencyPath(sourcePath, targetPath)); + connection.CreateFunction( + "python_import_resolves", + (string? sourcePath, string? targetPath, string? referenceName, string? referenceKind, string? context, long? columnNumber, string? signature) => + PythonImportBindingResolver.ResolvesDependency(sourcePath, targetPath, referenceName, referenceKind, context, columnNumber, signature)); + connection.CreateFunction( + "python_import_target_name", + (string? sourcePath, string? referenceName, string? context, long? columnNumber, string? signature) => + PythonImportBindingResolver.ResolveTargetName(sourcePath, referenceName, context, columnNumber, signature)); + connection.CreateFunction( + "sql_leaf_name", + (string? name) => string.IsNullOrWhiteSpace(name) ? null : SqlNameResolver.GetLeafName(name)); + connection.CreateFunction( + "sql_leaf_name_folded", + (string? name) => + { + if (string.IsNullOrWhiteSpace(name)) + return null; + + var leafName = SqlNameResolver.GetLeafName(name); + return leafName.Length == 0 ? null : NameFold.Fold(leafName) ?? leafName; + }); + connection.CreateFunction( + "sql_normalize_name", + (string? name) => string.IsNullOrWhiteSpace(name) ? null : SqlNameResolver.NormalizeQualifiedName(name)); + connection.CreateFunction( + "sql_normalize_name_folded", + (string? name) => + { + if (string.IsNullOrWhiteSpace(name)) + return null; + + var normalizedName = SqlNameResolver.NormalizeQualifiedName(name); + return normalizedName.Length == 0 ? null : NameFold.Fold(normalizedName) ?? normalizedName; + }); + connection.CreateFunction( + "sql_normalize_csharp_verbatim_name", + (string? text) => string.IsNullOrWhiteSpace(text) ? null : CSharpVerbatimNameNormalizer.Normalize(text)); + connection.CreateFunction( + "csharp_identifier_occurrence_count", + (string? text, string? identifier) => CountCSharpIdentifierOccurrences(text, identifier)); + connection.CreateFunction( + "sql_normalize_exact_source_name", + (string? text, string? lang) => string.IsNullOrWhiteSpace(text) ? null : ExactSourceSearchNormalizer.Normalize(text, lang)); + connection.CreateFunction( + "sql_segment_count", + (string? name) => string.IsNullOrWhiteSpace(name) ? (int?)null : SqlNameResolver.GetSegmentCount(name)); + connection.CreateFunction( + "sql_context_has_name", + (string? context, string? query) => SqlNameResolver.ContextContainsQualifiedName(context, query) ? 1 : 0); + connection.CreateFunction( + "sql_context_has_name_folded", + (string? context, string? query) => SqlNameResolver.ContextContainsQualifiedNameFolded(context, query) ? 1 : 0); + connection.CreateFunction( + "sql_context_has_name_at", + (string? context, string? query, long? columnNumber) => + SqlNameResolver.ContextContainsQualifiedNameAtColumn(context, query, ToNullableInt(columnNumber)) ? 1 : 0); + connection.CreateFunction( + "sql_context_has_name_folded_at", + (string? context, string? query, long? columnNumber) => + SqlNameResolver.ContextContainsQualifiedNameFoldedAtColumn(context, query, ToNullableInt(columnNumber)) ? 1 : 0); + connection.CreateFunction( + "sql_context_like_name_at", + (string? context, string? query, long? columnNumber) => + SqlNameResolver.ContextContainsQualifiedNameLikeAtColumn(context, query, ToNullableInt(columnNumber)) ? 1 : 0); + connection.CreateFunction( + "sql_context_like_name_folded_at", + (string? context, string? query, long? columnNumber) => + SqlNameResolver.ContextContainsQualifiedNameLikeFoldedAtColumn(context, query, ToNullableInt(columnNumber)) ? 1 : 0); + connection.CreateFunction( + "sql_resolve_reference_name", + (string? symbolName, string? context, string? containerName) => + { + var resolved = SqlNameResolver.ResolveReferenceName(symbolName, context, containerName); + return resolved.Length == 0 ? null : resolved; + }); + connection.CreateFunction( + "sql_resolve_reference_name_folded", + (string? symbolName, string? context, string? containerName) => + { + var resolved = SqlNameResolver.ResolveReferenceNameFolded(symbolName, context, containerName); + return resolved.Length == 0 ? null : resolved; + }); + connection.CreateFunction( + "sql_resolve_reference_name_at", + (string? symbolName, string? context, string? containerName, long? columnNumber) => + { + var resolved = SqlNameResolver.ResolveReferenceNameAtColumn(symbolName, context, containerName, ToNullableInt(columnNumber)); + return resolved.Length == 0 ? null : resolved; + }); + connection.CreateFunction( + "sql_resolve_reference_name_folded_at", + (string? symbolName, string? context, string? containerName, long? columnNumber) => + { + var resolved = SqlNameResolver.ResolveReferenceNameFoldedAtColumn(symbolName, context, containerName, ToNullableInt(columnNumber)); + return resolved.Length == 0 ? null : resolved; + }); + connection.CreateFunction( + "sql_resolve_reference_segment_count_at", + (string? symbolName, string? context, string? containerName, long? columnNumber) => (int?)( + SqlNameResolver.ResolveReferenceSegmentCountAtColumn(symbolName, context, containerName, ToNullableInt(columnNumber)) is var segmentCount + && segmentCount > 0 + ? segmentCount + : null)); + connection.CreateFunction( + "sql_reference_matches_target_at", + (string? symbolName, string? context, string? containerName, long? columnNumber, string? targetName) => + SqlNameResolver.ReferenceMatchesTargetAtColumn(symbolName, context, containerName, ToNullableInt(columnNumber), targetName) ? 1 : 0); + connection.CreateFunction( + "sql_allow_leaf_fallback_at", + (string? symbolName, string? context, string? containerName, long? columnNumber) => + SqlNameResolver.AllowLeafFallbackAtColumn(symbolName, context, containerName, ToNullableInt(columnNumber)) ? 1 : 0); + } + + internal static int CountCSharpIdentifierOccurrences(string? text, string? identifier) + { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(identifier)) + return 0; + + text = MaskCSharpCommentsAndStrings(text); + var count = 0; + var searchIndex = 0; + while (searchIndex < text.Length) + { + var index = text.IndexOf(identifier, searchIndex, StringComparison.Ordinal); + if (index < 0) + break; + + var beforeIndex = index - 1; + var afterIndex = index + identifier.Length; + var hasIdentifierBefore = beforeIndex >= 0 && IsCSharpIdentifierPart(text[beforeIndex]); + var hasIdentifierAfter = afterIndex < text.Length && IsCSharpIdentifierPart(text[afterIndex]); + if (!hasIdentifierBefore && !hasIdentifierAfter) + count++; + + searchIndex = index + identifier.Length; + } + + return count; + } + + internal static bool HasCSharpIdentifierOccurrenceOutsideLineRange(string? text, string? identifier, int startLine, int endLine) + { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(identifier)) + return false; + + var normalizedStartLine = Math.Max(1, startLine); + var normalizedEndLine = Math.Max(normalizedStartLine, endLine); + text = MaskCSharpCommentsAndStrings(text); + + var inRangeOccurrences = 0; + var lineNumber = 1; + var lineStart = 0; + while (lineStart <= text.Length) + { + var lineEnd = text.IndexOf('\n', lineStart); + if (lineEnd < 0) + lineEnd = text.Length; + + var lineOccurrences = CountCSharpIdentifierOccurrencesInRange(text, identifier, lineStart, lineEnd); + if (lineOccurrences > 0) + { + if (lineNumber < normalizedStartLine || lineNumber > normalizedEndLine) + return true; + + inRangeOccurrences += lineOccurrences; + if (inRangeOccurrences > 1) + return true; + } + + if (lineEnd == text.Length) + break; + + lineStart = lineEnd + 1; + lineNumber++; + } + + return false; + } + + private static int CountCSharpIdentifierOccurrencesInRange(string text, string identifier, int start, int end) + { + var count = 0; + var searchIndex = start; + while (searchIndex < end) + { + var index = text.IndexOf(identifier, searchIndex, end - searchIndex, StringComparison.Ordinal); + if (index < 0) + break; + + var beforeIndex = index - 1; + var afterIndex = index + identifier.Length; + var hasIdentifierBefore = beforeIndex >= start && IsCSharpIdentifierPart(text[beforeIndex]); + var hasIdentifierAfter = afterIndex < end && IsCSharpIdentifierPart(text[afterIndex]); + if (!hasIdentifierBefore && !hasIdentifierAfter) + count++; + + searchIndex = index + identifier.Length; + } + + return count; + } + + private static bool IsCSharpIdentifierPart(char ch) + { + return ch == '_' || char.IsLetterOrDigit(ch); + } + + private static string MaskCSharpCommentsAndStrings(string text) + { + var chars = text.ToCharArray(); + var inBlockComment = false; + var inLineComment = false; + var inString = false; + var inChar = false; + var inVerbatimString = false; + + for (var i = 0; i < chars.Length; i++) + { + var ch = chars[i]; + var next = i + 1 < chars.Length ? chars[i + 1] : '\0'; + + if (inLineComment) + { + if (ch is '\r' or '\n') + inLineComment = false; + else + chars[i] = ' '; + continue; + } + + if (inBlockComment) + { + if (ch == '*' && next == '/') + { + chars[i] = ' '; + chars[i + 1] = ' '; + i++; + inBlockComment = false; + } + else if (ch is not ('\r' or '\n')) + { + chars[i] = ' '; + } + continue; + } + + if (inString) + { + if (ch == '\\' && !inVerbatimString && next != '\0') + { + chars[i] = ' '; + if (next is not ('\r' or '\n')) + chars[i + 1] = ' '; + i++; + continue; + } + + if (inVerbatimString && ch == '"' && next == '"') + { + chars[i] = ' '; + chars[i + 1] = ' '; + i++; + continue; + } + + if (ch == '"') + inString = false; + + chars[i] = ch is '\r' or '\n' ? ch : ' '; + continue; + } + + if (inChar) + { + if (ch == '\\' && next != '\0') + { + chars[i] = ' '; + if (next is not ('\r' or '\n')) + chars[i + 1] = ' '; + i++; + continue; + } + + if (ch == '\'') + inChar = false; + + chars[i] = ch is '\r' or '\n' ? ch : ' '; + continue; + } + + if (ch == '/' && next == '/') + { + chars[i] = ' '; + chars[i + 1] = ' '; + i++; + inLineComment = true; + continue; + } + + if (ch == '/' && next == '*') + { + chars[i] = ' '; + chars[i + 1] = ' '; + i++; + inBlockComment = true; + continue; + } + + if (TryMaskCSharpRawString(chars, ref i)) + continue; + + if (TryMaskCSharpInterpolatedString(chars, ref i)) + continue; + + if (ch == '@' && next == '"') + { + chars[i] = ' '; + chars[i + 1] = ' '; + i++; + inString = true; + inVerbatimString = true; + continue; + } + + if (ch == '"') + { + chars[i] = ' '; + inString = true; + inVerbatimString = false; + continue; + } + + if (ch == '\'') + { + chars[i] = ' '; + inChar = true; + } + } + + return new string(chars); + } + + private static bool TryMaskCSharpRawString(char[] chars, ref int index) + { + var start = index; + var cursor = start; + while (cursor < chars.Length && chars[cursor] == '$') + cursor++; + + if (cursor + 2 >= chars.Length + || chars[cursor] != '"' + || chars[cursor + 1] != '"' + || chars[cursor + 2] != '"') + { + return false; + } + + var quoteCount = 0; + while (cursor + quoteCount < chars.Length && chars[cursor + quoteCount] == '"') + quoteCount++; + if (quoteCount < 3) + return false; + + var interpolationDollarCount = cursor - start; + MaskRangePreservingNewLines(chars, start, cursor + quoteCount); + var search = cursor + quoteCount; + var interpolationBraceDepth = 0; + while (search < chars.Length) + { + if (interpolationBraceDepth == 0 && HasQuoteRun(chars, search, quoteCount)) + { + MaskRangePreservingNewLines(chars, search, search + quoteCount); + index = search + quoteCount - 1; + return true; + } + + if (interpolationDollarCount > 0 && chars[search] == '{') + { + interpolationBraceDepth++; + } + else if (interpolationBraceDepth > 0 && chars[search] == '}') + { + interpolationBraceDepth--; + } + else if (interpolationBraceDepth == 0 && chars[search] is not ('\r' or '\n')) + { + chars[search] = ' '; + } + search++; + } + + index = chars.Length - 1; + return true; + } + + private static bool TryMaskCSharpInterpolatedString(char[] chars, ref int index) + { + var start = index; + if (chars[start] != '$') + return false; + + var cursor = start + 1; + var verbatim = false; + if (cursor < chars.Length && chars[cursor] == '@') + { + verbatim = true; + cursor++; + } + + if (cursor >= chars.Length || chars[cursor] != '"') + return false; + + MaskRangePreservingNewLines(chars, start, cursor + 1); + var braceDepth = 0; + for (var i = cursor + 1; i < chars.Length; i++) + { + var ch = chars[i]; + var next = i + 1 < chars.Length ? chars[i + 1] : '\0'; + + if (braceDepth == 0 && ch == '"' && !(verbatim && next == '"')) + { + chars[i] = ' '; + index = i; + return true; + } + + if (verbatim && braceDepth == 0 && ch == '"' && next == '"') + { + chars[i] = ' '; + chars[i + 1] = ' '; + i++; + continue; + } + + if (!verbatim && braceDepth == 0 && ch == '\\' && next != '\0') + { + chars[i] = ' '; + if (next is not ('\r' or '\n')) + chars[i + 1] = ' '; + i++; + continue; + } + + if (ch == '{') + { + braceDepth++; + continue; + } + + if (braceDepth > 0 && ch == '}') + { + braceDepth--; + continue; + } + + if (braceDepth == 0 && ch is not ('\r' or '\n')) + chars[i] = ' '; + } + + index = chars.Length - 1; + return true; + } + + private static bool HasQuoteRun(char[] chars, int start, int quoteCount) + { + if (start + quoteCount > chars.Length) + return false; + for (var i = 0; i < quoteCount; i++) + { + if (chars[start + i] != '"') + return false; + } + return true; + } + + private static void MaskRangePreservingNewLines(char[] chars, int start, int end) + { + for (var i = start; i < end && i < chars.Length; i++) + { + if (chars[i] is not ('\r' or '\n')) + chars[i] = ' '; + } + } + + internal static void RegisterConnectionFunctionsWithRetry( + SqliteConnection connection, + Action? sleep = null, + int maxAttempts = 5, + CancellationToken cancellationToken = default, + Action? registerConnectionFunctions = null) + { + if (maxAttempts <= 0) + throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "Must be at least 1."); + + cancellationToken.ThrowIfCancellationRequested(); + registerConnectionFunctions ??= RegisterConnectionFunctions; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + registerConnectionFunctions(connection); + return; + } + catch (SqliteException ex) when (DbConnectionFactory.IsTransientBusyError(ex) && attempt < maxAttempts) + { + DbConnectionFactory.SleepBeforeRetry(50 * attempt, sleep, cancellationToken); + } + } + } + +} diff --git a/src/CodeIndex/Database/DbContext.ConnectionLifecycle.cs b/src/CodeIndex/Database/DbContext.ConnectionLifecycle.cs new file mode 100644 index 000000000..30db62725 --- /dev/null +++ b/src/CodeIndex/Database/DbContext.ConnectionLifecycle.cs @@ -0,0 +1,632 @@ +using CodeIndex.Cli; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; +using System.Globalization; +using System.Runtime.ExceptionServices; + +namespace CodeIndex.Database; + +public partial class DbContext : IDisposable +{ + private void OpenQueryOnly(string dbPath, CancellationToken cancellationToken) + { + if (SqliteFileUri.StartsWithFileScheme(dbPath) + && !SqliteFileUri.TryValidateBounds(dbPath, out var boundsError)) + { + throw boundsError ?? new FormatException("Invalid SQLite file URI."); + } + + try + { + var immutableSnapshot = false; + var immutableWalRisk = false; + var detachedSnapshot = false; + DbConnectionFactory.QueryOnlySnapshotSourceState? snapshotSourceState = null; + _connection = OpenSqliteConnectionWithRetry( + () => DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( + dbPath, + pooling: false, + out immutableSnapshot, + out immutableWalRisk, + out detachedSnapshot, + out snapshotSourceState, + cancellationToken), + static connection => connection.Open(), + dbPath: dbPath, + cancellationToken: cancellationToken); + Execute("PRAGMA query_only=ON"); + ApplyBusyTimeoutPragma(); + ApplyConnectionPerformancePragmas(); + RegisterConnectionFunctionsWithRetry(_connection, cancellationToken: cancellationToken); + _isReadOnly = true; + _immutableReadOnly = immutableSnapshot; + _immutableReadOnlyWalRisk = immutableWalRisk; + _connectionPooling = false; + _queryOnlySnapshotRequiresRefresh = detachedSnapshot; + _queryOnlySnapshotSourcePath = detachedSnapshot ? dbPath : null; + _queryOnlySnapshotSourceState = snapshotSourceState; + WarnIfBatchInProgress(); + } + catch + { + _connection?.Dispose(); + throw; + } + } + + private void OpenReadOnlyFallback(string dbPath, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + _connection = OpenReadOnly(dbPath, out _readOnlyImmutableFallback); + _immutableReadOnly = _readOnlyImmutableFallback; + ApplyBusyTimeoutPragma(); + ApplyConnectionPerformancePragmas(); + RegisterConnectionFunctionsWithRetry(_connection, cancellationToken: cancellationToken); + _isReadOnly = true; + WarnIfBatchInProgress(); + } + + internal static WalCheckpointResult CheckpointWalBeforeReadOnlyFallback( + string dbPath, + CancellationToken cancellationToken) + { + try + { + var connectionString = SqliteConnectionPolicy.BuildConnectionString(dbPath, SqliteConnectionPolicyMode.ReadWrite); + using var connection = OpenSqliteConnectionWithRetry( + () => new SqliteConnection(connectionString), + static connection => connection.Open(), + maxOpenAttempts: 1, + dbPath: dbPath, + cancellationToken: cancellationToken); + return ExecuteWalCheckpointTruncate(connection, cancellationToken, invokeTestingHook: true); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return WalCheckpointResult.Failed(FormatWalCheckpointFailureReason(ex)); + } + } + + private static string FormatWalCheckpointFailureReason(Exception ex) => ex switch + { + SqliteException { SqliteErrorCode: 3 } => "sqlite_permission_denied", + SqliteException { SqliteErrorCode: 5 } => "sqlite_busy", + SqliteException { SqliteErrorCode: 6 } => "sqlite_locked", + SqliteException { SqliteErrorCode: 8 } => "sqlite_read_only", + SqliteException { SqliteErrorCode: 10 } => "sqlite_io_error", + SqliteException { SqliteErrorCode: 11 } => "sqlite_corrupt", + SqliteException { SqliteErrorCode: 13 } => "sqlite_full", + SqliteException { SqliteErrorCode: 14 } => "sqlite_cannot_open", + SqliteException { SqliteErrorCode: 26 } => "sqlite_not_a_database", + SqliteException sqlite => $"sqlite_error_{sqlite.SqliteErrorCode.ToString(CultureInfo.InvariantCulture)}", + CodeIndexException codeIndexException => codeIndexException.Code, + _ => WalCheckpointResult.GenericFailureReason, + }; + + public bool TryCheckpointWalTruncate() + => TryCheckpointWalTruncate(CancellationToken.None); + + public bool TryCheckpointWalTruncate(CancellationToken cancellationToken) + => CheckpointWalTruncate(cancellationToken).Succeeded; + + public WalCheckpointResult CheckpointWalTruncate() + => CheckpointWalTruncate(CancellationToken.None); + + public WalCheckpointResult CheckpointWalTruncate(CancellationToken cancellationToken) + { + if (_isReadOnly) + { + var result = WalCheckpointResult.NotAttempted(WalCheckpointResult.ReadOnlySkippedReason); + ApplyWalCheckpointResult(result); + return result; + } + + cancellationToken.ThrowIfCancellationRequested(); + try + { + var result = ExecuteWalCheckpointTruncate(_connection, cancellationToken, invokeTestingHook: true); + ApplyWalCheckpointResult(result); + return result; + } + catch (OperationCanceledException) + { + ApplyWalCheckpointResult(WalCheckpointResult.Failed(WalCheckpointResult.CancelledFailureReason)); + throw; + } + } + + private static WalCheckpointResult ExecuteWalCheckpointTruncate( + SqliteConnection connection, + CancellationToken cancellationToken, + bool invokeTestingHook) + { + try + { + using var cmd = SqliteConnectionPolicy.CreateCommand(connection, "PRAGMA wal_checkpoint(TRUNCATE)"); + if (invokeTestingHook) + WalCheckpointTruncateExecutedForTesting?.Invoke(connection.DataSource); + + cancellationToken.ThrowIfCancellationRequested(); + ReportMaintenanceProgress("wal_checkpoint", "truncate_start", connection.DataSource); + using var reader = cmd.ExecuteReader(); + if (!reader.Read()) + return WalCheckpointResult.Failed(WalCheckpointResult.MissingResultFailureReason); + + long busy; + long logPageCount; + long checkpointedPageCount; + try + { + busy = reader.GetInt64(0); + logPageCount = reader.GetInt64(1); + checkpointedPageCount = reader.GetInt64(2); + } + catch (Exception ex) when (ex is ArgumentOutOfRangeException + or InvalidCastException + or InvalidOperationException + or IndexOutOfRangeException) + { + return WalCheckpointResult.Failed(WalCheckpointResult.InvalidResultFailureReason); + } + + ReportMaintenanceProgress("wal_checkpoint", "truncate_complete", connection.DataSource); + cancellationToken.ThrowIfCancellationRequested(); + + var notWalMode = busy == 0 && logPageCount == -1 && checkpointedPageCount == -1; + if (!notWalMode && + (busy < 0 || logPageCount < 0 || checkpointedPageCount < 0 || checkpointedPageCount > logPageCount)) + { + return new WalCheckpointResult( + true, + false, + busy, + logPageCount, + checkpointedPageCount, + null, + null, + WalCheckpointResult.InvalidResultFailureReason); + } + + var remainingPageCount = notWalMode ? 0 : logPageCount - checkpointedPageCount; + var failureReason = busy != 0 + ? WalCheckpointResult.BusyFailureReason + : remainingPageCount != 0 + ? WalCheckpointResult.PagesRemainingFailureReason + : null; + + return new WalCheckpointResult( + true, + failureReason == null, + busy, + logPageCount, + checkpointedPageCount, + remainingPageCount, + null, + failureReason); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return WalCheckpointResult.Failed(FormatWalCheckpointFailureReason(ex)); + } + } + + private void ApplyWalCheckpointResult(WalCheckpointResult result) + { + _walCheckpointAttempted = result.Attempted; + _walCheckpointSucceeded = result.Succeeded; + _walCheckpointBusy = result.Busy; + _walCheckpointLogPageCount = result.LogPageCount; + _walCheckpointCheckpointedPageCount = result.CheckpointedPageCount; + _walCheckpointRemainingPageCount = result.RemainingPageCount; + _walCheckpointSkippedReason = result.SkippedReason; + _walCheckpointFailureReason = result.FailureReason; + } + + public static string ToReadOnlyUri(string dbPath) + => SqliteConnectionPolicy.ToReadOnlyUri(dbPath); + + private void ApplyPrivateDatabaseFileModes(string dbPath) + { + if (!_databaseFileModeProvider.SupportsUnixFileModes || + dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + ApplyPrivateFileModeIfExists(dbPath, "database"); + ApplyPrivateFileModeIfExists(dbPath + "-wal", "wal"); + ApplyPrivateFileModeIfExists(dbPath + "-shm", "shm"); + } + + private void ApplyPrivateFileModeIfExists(string path, string target) + { + var normalizedPath = LongPath.EnsureWindowsPrefix(path); + try + { + if (!_databaseFileModeProvider.FileExists(normalizedPath)) + return; + +#pragma warning disable CA1416 + _databaseFileModeProvider.SetUnixFileMode( + normalizedPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite); +#pragma warning restore CA1416 + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + HandleDatabasePermissionFailure("set", target, ex); + } + } + + public static string? GetUnixFileModeString(string? path) + => GetUnixFileModeString( + path, + DatabasePermissionPolicyMode.BestEffort, + SystemDatabaseFileModeProvider.Instance, + out _); + + internal static string? GetUnixFileModeString( + string? path, + string policyName, + out StatusDatabasePermissionDiagnostic? diagnostic) + => GetUnixFileModeString( + path, + string.Equals(policyName, DatabasePermissionPolicy.StrictName, StringComparison.Ordinal) + ? DatabasePermissionPolicyMode.Strict + : DatabasePermissionPolicyMode.BestEffort, + SystemDatabaseFileModeProvider.Instance, + out diagnostic); + + internal static string? GetUnixFileModeString( + string? path, + DatabasePermissionPolicyMode policy, + IDatabaseFileModeProvider fileModeProvider, + out StatusDatabasePermissionDiagnostic? diagnostic) + { + diagnostic = null; + if (string.IsNullOrWhiteSpace(path) || + !fileModeProvider.SupportsUnixFileModes || + path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + try + { + if (!fileModeProvider.FileExists(path)) + return null; + + var mode = fileModeProvider.GetUnixFileMode(path) & + (UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute); + return Convert.ToString((int)mode, 8).PadLeft(4, '0'); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + diagnostic = DatabasePermissionPolicy.CreateDiagnostic("read", "database", ex); + if (policy == DatabasePermissionPolicyMode.Strict) + throw DatabasePermissionPolicy.CreateStrictFailure(diagnostic, ex); + + WriteBestEffortDatabasePermissionWarning(diagnostic); + return null; + } + } + + private void HandleDatabasePermissionFailure(string operation, string target, Exception exception) + { + var diagnostic = DatabasePermissionPolicy.CreateDiagnostic(operation, target, exception); + if (_databasePermissionPolicy == DatabasePermissionPolicyMode.Strict) + throw DatabasePermissionPolicy.CreateStrictFailure(diagnostic, exception); + + if (_databasePermissionDiagnostics.Any(existing => + existing.Operation == diagnostic.Operation && + existing.Target == diagnostic.Target && + existing.Reason == diagnostic.Reason)) + { + return; + } + + _databasePermissionDiagnostics.Add(diagnostic); + WriteBestEffortDatabasePermissionWarning(diagnostic); + } + + private static void WriteBestEffortDatabasePermissionWarning(StatusDatabasePermissionDiagnostic diagnostic) + => CommandErrorWriter.WriteStderr( + $"Warning [{DatabasePermissionPolicy.FailureCode}]: policy={DatabasePermissionPolicy.BestEffortName} " + + $"operation={diagnostic.Operation} target={diagnostic.Target} reason={diagnostic.Reason}; " + + diagnostic.RecommendedAction); + + private static string? TryCreateSchemaCacheKey(string dbPath) + { + if (string.IsNullOrWhiteSpace(dbPath)) + return null; + + if (dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + var localPath = TryGetLocalPath(dbPath); + if (localPath == null) + return null; + dbPath = localPath; + } + + try + { + return Path.GetFullPath(dbPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return null; + } + } + + private void WarnIfBatchInProgress() + { + var raw = GetMetaString(BatchInProgressMetaKey); + if (string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)) + CommandErrorWriter.WriteStderr("Warning: Last batch did not complete; run `cdidx index --rebuild` to re-index from a known clean state."); + } + + /// + /// Demote readiness after an interrupted batch only from an explicitly selected repair path. + /// interrupted batch 後の readiness demotion は、明示的な repair path からのみ実行する。 + /// + public bool RepairIncompleteBatchReadiness() + { + if (_openIntent != DbOpenIntent.Repair) + throw new InvalidOperationException("Incomplete-batch readiness repair requires DbOpenIntent.Repair."); + + var raw = GetMetaString(BatchInProgressMetaKey); + if (!string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)) + return false; + + ClearReadyFlags(); + return true; + } + + private void ApplyConnectionPerformancePragmas() + { + var settings = DbPragmaPolicy.ReadConnectionPragmaSettings( + CacheSizeEnvironmentVariable, + DefaultCacheSizeKb, + MaxCacheSizeKb, + MmapSizeEnvironmentVariable, + DefaultMmapSizeBytes, + MaxMmapSizeBytes, + Environment.Is64BitProcess); + DbPragmaPolicy.ApplyConnectionPerformancePragmas(Execute, settings); + } + + private void ConfigureAutoVacuumForEmptyDatabase() + { + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'"; + var objectCount = SqliteCommandPolicy.ReadInt64Scalar(cmd, "sqlite_master object count"); + if (objectCount == 0) + Execute(DbPragmaPolicy.AutoVacuumIncrementalPragmaSql); + } + + public VacuumResult RunIncrementalVacuum(bool dryRun = false) + => RunIncrementalVacuum(dryRun, CancellationToken.None); + + public VacuumResult RunIncrementalVacuum(bool dryRun, CancellationToken cancellationToken) + { + if (_isReadOnly && !dryRun) + { + throw new CodeIndexException( + code: CommandErrorCodes.DbNotWritable, + category: CodeIndexExceptionCategory.Database, + message: "database must be writable for vacuum", + path: _connection.DataSource, + hint: "Copy the database to writable storage or rerun cdidx without a read-only --db URI."); + } + + cancellationToken.ThrowIfCancellationRequested(); + ReportMaintenanceProgress("vacuum", "metrics_before", _connection.DataSource); + var before = ReadVacuumMetrics(); + cancellationToken.ThrowIfCancellationRequested(); + if (!dryRun && before.AutoVacuumMode == 2) + { + ReportMaintenanceProgress("vacuum", "incremental_vacuum", _connection.DataSource); + Execute(DbPragmaPolicy.IncrementalVacuumPragmaSql(before.FreelistCount)); + } + else if (!dryRun) + { + ReportMaintenanceProgress("vacuum", "enable_incremental_autovacuum", _connection.DataSource); + Execute("PRAGMA auto_vacuum=INCREMENTAL"); + cancellationToken.ThrowIfCancellationRequested(); + ReportMaintenanceProgress("vacuum", "vacuum_rebuild", _connection.DataSource); + Execute("VACUUM"); + } + cancellationToken.ThrowIfCancellationRequested(); + ReportMaintenanceProgress("vacuum", "metrics_after", _connection.DataSource); + var after = dryRun ? before : ReadVacuumMetrics(); + cancellationToken.ThrowIfCancellationRequested(); + var pagesReclaimed = dryRun ? 0 : Math.Max(0, before.PageCount - after.PageCount); + var bytesReclaimed = pagesReclaimed * after.PageSize; + var estimatedPagesReclaimable = Math.Max(0, before.FreelistCount); + var estimatedBytesReclaimable = estimatedPagesReclaimable * before.PageSize; + var guidance = MaintenanceGuidanceBuilder.Build(new MaintenanceMetrics( + after.PageCount, + after.FreelistCount, + after.PageSize, + after.WalSizeBytes, + after.DbSizeBytes, + after.AutoVacuumMode)); + return new VacuumResult( + Status: dryRun ? "dry_run" : "ok", + DryRun: dryRun, + PageSize: after.PageSize, + PageCountBefore: before.PageCount, + FreelistCountBefore: before.FreelistCount, + PageCountAfter: after.PageCount, + FreelistCountAfter: after.FreelistCount, + PagesReclaimed: pagesReclaimed, + BytesReclaimed: bytesReclaimed, + EstimatedPagesReclaimable: estimatedPagesReclaimable, + EstimatedBytesReclaimable: estimatedBytesReclaimable, + DbSizeBytesBefore: before.DbSizeBytes, + WalSizeBytesBefore: before.WalSizeBytes, + DbSizeBytesAfter: after.DbSizeBytes, + WalSizeBytesAfter: after.WalSizeBytes, + WalCheckpointTimingNote: BuildWalCheckpointTimingNote(dryRun), + AutoVacuumModeBefore: before.AutoVacuumMode, + AutoVacuumModeBeforeName: MaintenanceGuidanceBuilder.FormatAutoVacuumMode(before.AutoVacuumMode) ?? "unknown", + AutoVacuumModeAfter: after.AutoVacuumMode, + AutoVacuumModeAfterName: MaintenanceGuidanceBuilder.FormatAutoVacuumMode(after.AutoVacuumMode) ?? "unknown", + MaintenanceGuidance: guidance); + } + + private static string? BuildWalCheckpointTimingNote(bool dryRun) + => dryRun + ? null + : "wal_size_bytes_after is sampled before the vacuum connection closes; SQLite may checkpoint or truncate WAL pages after command cleanup, so a later status call can report a smaller wal_size_bytes value."; + + private static void ReportMaintenanceProgress(string operation, string phase, string dbPath) + { + GlobalToolLog.Info($"db_maintenance_progress operation={operation} phase={phase} db_path={ConsoleUi.FormatBoundedValue(dbPath)}"); + MaintenanceProgressForTesting?.Invoke(operation, phase); + } + + private VacuumMetrics ReadVacuumMetrics() + => new( + ReadPragmaLong("page_count"), + ReadPragmaLong("freelist_count"), + ReadPragmaLong("page_size"), + ReadAutoVacuumMode(), + TryGetDatabaseFileSize(), + TryGetWalFileSize()); + + private long ReadAutoVacuumMode() => ReadPragmaLong("auto_vacuum"); + + private void ApplyBusyTimeoutPragma() + { + var busyTimeoutMs = DbPragmaPolicy.ReadBusyTimeoutMs(BusyTimeoutEnvironmentVariable); + Execute(DbPragmaPolicy.BusyTimeoutPragmaSql(busyTimeoutMs)); + } + + private long? TryGetDatabaseFileSize() + { + var path = _connection.DataSource; + if (string.IsNullOrWhiteSpace(path) || path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + return null; + + try + { + var info = new FileInfo(path); + return info.Exists ? info.Length : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) + { + return null; + } + } + + private long? TryGetWalFileSize() + { + var path = _connection.DataSource; + if (string.IsNullOrWhiteSpace(path) || path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + return null; + + try + { + var info = new FileInfo(path + "-wal"); + return info.Exists ? info.Length : 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) + { + return null; + } + } + + private long ReadPragmaLong(string name) + { + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + cmd.CommandText = SqliteCommandPolicy.PragmaSql(name); + return SqliteCommandPolicy.ReadInt64Scalar(cmd, $"pragma {name}"); + } + + private readonly record struct VacuumMetrics( + long PageCount, + long FreelistCount, + long PageSize, + long AutoVacuumMode, + long? DbSizeBytes, + long? WalSizeBytes); + + private void EnsureWritableUserVersionSupported(string dbPath) + { + var userVersion = GetUserVersion(); + var unknownBits = userVersion & ~CurrentSchemaVersion; + if (unknownBits == 0) + return; + + _connection.Dispose(); + throw new CodeIndexException( + code: CommandErrorCodes.SchemaTooNew, + category: CodeIndexExceptionCategory.Database, + message: $"This DB was written by a newer cdidx schema stamp (user_version {userVersion}); this binary supports up to {CurrentSchemaVersion}.", + path: dbPath, + hint: "Run with a current cdidx binary or rebuild the index with this version before writing to the database."); + } + + internal static void ExecuteSynchronousPragmaWithFallback(Action execute) + => DbPragmaPolicy.ExecuteSynchronousPragmaWithFallback(execute, DefaultSynchronousMode); + + internal static bool IsSafetyLevelTransactionError(SqliteException ex) => + DbPragmaPolicy.IsSafetyLevelTransactionError(ex); + + private static bool IsReadOnlyOpenError(SqliteException ex, string dbPath) => + DbConnectionFactory.IsReadOnlyOpenError(ex, dbPath); + + internal static SqliteConnection OpenSqliteConnectionWithRetry( + Func createConnection, + Action openConnection, + Action? sleep = null, + int maxOpenAttempts = 5, + string? dbPath = null, + CancellationToken cancellationToken = default) + => DbConnectionFactory.OpenWithRetry( + createConnection, + openConnection, + sleep, + maxOpenAttempts, + dbPath, + cancellationToken); + + private static string? TryGetLocalPath(string uriText) + => DbConnectionFactory.TryGetLocalPath(uriText); + + private static bool TryGetLocalPath(string uriText, out string? localPath, out string? failureReason) + => DbConnectionFactory.TryGetLocalPath(uriText, out localPath, out failureReason); + + private static SqliteConnection OpenReadOnly(string dbPath) + => DbConnectionFactory.OpenReadOnly(dbPath); + + private static SqliteConnection OpenReadOnly(string dbPath, out bool usedImmutableFallback) + => DbConnectionFactory.OpenReadOnly(dbPath, out usedImmutableFallback); + + private static SqliteConnection CreateArtifactPreservingQueryOnlyConnection( + string dbPath, + bool pooling, + out bool immutableSnapshot, + out bool immutableWalRisk, + out bool detachedSnapshot) + => DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( + dbPath, + pooling, + out immutableSnapshot, + out immutableWalRisk, + out detachedSnapshot); + +} diff --git a/src/CodeIndex/Database/DbContext.Maintenance.cs b/src/CodeIndex/Database/DbContext.Maintenance.cs new file mode 100644 index 000000000..11626b6ed --- /dev/null +++ b/src/CodeIndex/Database/DbContext.Maintenance.cs @@ -0,0 +1,228 @@ +using CodeIndex.Cli; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; +using System.Globalization; +using System.Runtime.ExceptionServices; + +namespace CodeIndex.Database; + +public partial class DbContext : IDisposable +{ + private void NormalizeCodeIndexMetaKeys() + { + if (!TableExists("codeindex_meta")) + return; + + using (var delete = SqliteConnectionPolicy.CreateCommand(_connection)) + { + if (_activeMigrationTransaction != null) + delete.Transaction = _activeMigrationTransaction; + + delete.CommandText = @" + DELETE FROM codeindex_meta + WHERE key IN ('hotspot_family_version', 'hotspot_family_marker_fingerprint') + AND value IS NULL"; + delete.ExecuteNonQuery(); + } + + using var stamp = SqliteConnectionPolicy.CreateCommand(_connection); + if (_activeMigrationTransaction != null) + stamp.Transaction = _activeMigrationTransaction; + stamp.CommandText = @" + INSERT INTO codeindex_meta (key, value) VALUES ('codeindex_meta_schema_version', @version) + ON CONFLICT(key) DO UPDATE SET value = excluded.value"; + SqliteCommandPolicy.Add(stamp, "@version", CodeIndexMetaSchemaVersion.ToString(CultureInfo.InvariantCulture)); + stamp.ExecuteNonQuery(); + } + + internal void MarkWriteWork(bool walCheckpointable = true) + { + if (!_isReadOnly && !_suppressWriteWorkTracking) + { + _hasWriteWork = true; + if (walCheckpointable) + _hasWalCheckpointableWriteWork = true; + } + } + + internal sealed record PlannerStatisticsMaintenanceFailure(string CommandText, SqliteException Exception); + + internal void SuppressPlannerStatisticsMaintenanceOnClose() + => Volatile.Write(ref _suppressPlannerStatisticsMaintenanceOnClose, true); + + internal PlannerStatisticsMaintenanceFailure? RunPlannerStatisticsMaintenance( + bool forceAnalyze, + CancellationToken cancellationToken = default) + { + if (_isReadOnly) + return null; + + using var cmd = _connection.CreateCommand(); + cmd.CommandText = forceAnalyze ? "ANALYZE" : "PRAGMA optimize"; + cancellationToken.ThrowIfCancellationRequested(); + using var cancellationRegistration = cancellationToken.UnsafeRegister( + static state => SQLitePCL.raw.sqlite3_interrupt(((SqliteConnection)state!).Handle), + _connection); + try + { + PlannerStatisticsCommandCreatedForTesting?.Invoke(cmd); + cmd.ExecuteNonQuery(); + cancellationToken.ThrowIfCancellationRequested(); + PlannerStatisticsCommandExecutedForTesting?.Invoke(_connection.DataSource, cmd.CommandText); + if (!forceAnalyze) + OptimizePragmaExecutedForTesting?.Invoke(_connection.DataSource); + _hasWriteWork = false; + return null; + } + catch (SqliteException ex) when (cancellationToken.IsCancellationRequested && ex.SqliteErrorCode == 9) + { + throw new OperationCanceledException("SQLite planner maintenance was interrupted.", ex, cancellationToken); + } + catch (SqliteException ex) + { + // Planner statistics are an index-performance aid. If SQLite rejects ANALYZE / + // optimize during cleanup (read-only handoff, transient filesystem state), keep + // the completed index usable instead of converting success into failure. + return new PlannerStatisticsMaintenanceFailure(cmd.CommandText, ex); + } + } + + private void RunOptimizeOnCloseIfNeeded() + { + if (!_hasWriteWork + || _isReadOnly + || _cancellation.IsCancellationRequested + || Volatile.Read(ref _suppressPlannerStatisticsMaintenanceOnClose)) + return; + + try + { + RunPlannerStatisticsMaintenance(forceAnalyze: false, _cancellation); + } + catch (OperationCanceledException) when (_cancellation.IsCancellationRequested) + { + // Dispose-time maintenance is best effort and must not outlive or fail the + // operation that owns this database context. + } + } + + public void Dispose() + { + DbSchemaCache? schemaCache; + lock (_schemaCacheLock) + { + if (_disposed) + return; + _disposed = true; + schemaCache = _schemaCache; + _schemaCache = null; + } + schemaCache?.Dispose(); + + // Dispose cached prepared statements before closing the connection so each + // SqliteCommand's finalizer does not race the connection teardown. + // connection を閉じる前にキャッシュ済み command を dispose し、finalizer と + // connection teardown の競合を防ぐ。 + _preparedCommands?.Dispose(); + _preparedCommands = null; + var hadWriteWork = _hasWriteWork; + var hadWalCheckpointableWriteWork = _hasWalCheckpointableWriteWork; + RunOptimizeOnCloseIfNeeded(); + if (hadWalCheckpointableWriteWork) + TryCheckpointWalTruncate(); + _connection.Dispose(); + } +} + +/// +/// Captured information about a single failed step inside +/// . Surfaced via +/// so a later "no such column" error coming +/// out of a read path can be traced back to the specific step that did not run. +/// で失敗したステップの情報。 +/// +public sealed record DbMigrationFailure( + string Step, + int SqliteErrorCode, + string SqliteMessage, + string SuggestedAction); + +internal static class DbColumnEnsurer +{ + internal static void EnsureColumn( + Func columnExists, + Action? beginImmediate, + Action? commit, + Action? rollback, + Action alterColumn) + { + if (columnExists()) + return; + + var hasTransactionHooks = beginImmediate != null && commit != null && rollback != null; + var transactionStarted = false; + try + { + if (hasTransactionHooks) + { + beginImmediate!(); + transactionStarted = true; + if (columnExists()) + { + commit!(); + transactionStarted = false; + return; + } + } + + alterColumn(); + if (transactionStarted) + { + commit!(); + transactionStarted = false; + } + } + catch (SqliteException ex) when (IsDuplicateColumnRace(ex, columnExists)) + { + // Another process or an earlier partial migration may have added the + // column between PRAGMA inspection and ALTER. Re-check PRAGMA-derived + // state and gate on SQLite's generic DDL error code so localized builds + // or future wording changes still recover (#1532, #1690). + // 列存在を PRAGMA 相当の状態で再確認し、SQLite の英語メッセージに依存せず + // 「移行済み」を判定する (#1532)。 + if (transactionStarted) + { + try { rollback!(); } catch (SqliteException) { } + transactionStarted = false; + } + } + catch + { + if (transactionStarted) + { + try { rollback!(); } catch (SqliteException) { } + } + throw; + } + } + + internal static void EnsureColumn(Func columnExists, Action alterColumn) + => EnsureColumn(columnExists, beginImmediate: null, commit: null, rollback: null, alterColumn); + + private static bool IsDuplicateColumnRace(SqliteException exception, Func columnExists) + { + if (!IsDuplicateColumnAddError(exception)) + return false; + + return columnExists(); + } + + private static bool IsDuplicateColumnAddError(SqliteException exception) + { + // SQLite reports duplicate-column ADD COLUMN as SQLITE_ERROR (1); callers + // confirm the column exists before treating it as a recovered race. + return exception.SqliteErrorCode == 1; + } +} diff --git a/src/CodeIndex/Database/DbContext.ReadMigrations.cs b/src/CodeIndex/Database/DbContext.ReadMigrations.cs new file mode 100644 index 000000000..759d1e418 --- /dev/null +++ b/src/CodeIndex/Database/DbContext.ReadMigrations.cs @@ -0,0 +1,437 @@ +using CodeIndex.Cli; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; +using System.Globalization; +using System.Runtime.ExceptionServices; + +namespace CodeIndex.Database; + +public partial class DbContext : IDisposable +{ + public DbMigrationFailure? LastMigrationFailure { get; private set; } + + /// + /// Attempt opportunistic schema migration for read-only query paths. + /// Failures are captured on and a single + /// actionable warning is written to so a later + /// "no such column" error can be tied back to the failing migration step. + /// 読み取り専用クエリパス向けの機会的スキーマ移行を試みる。 + /// 失敗時は に記録し、stderr に 1 行の警告を出す。 + /// + public void TryMigrateForRead() + { + // Skip migration entirely on read-only connections. Even CREATE TABLE IF NOT EXISTS + // fails with SQLITE_CANTOPEN on sandboxes that cannot create -journal side files — + // previously only SQLITE_READONLY was caught, so the normal --db /path flow threw + // on restricted mounts even after the constructor had already degraded to read-only. + // read-only 接続ではマイグレーション DDL 自体を走らせない。CANTOPEN が漏れて落ちるため。 + if (_isReadOnly) return; + + LastMigrationFailure = null; + if (ReadMigrationSchemaIsCurrent()) + return; + + try + { + if (IsSqliteTransactionActive()) + { + RunReadMigrationSteps(MigrationTransactionOwnership.External); + return; + } + + EnsureForeignKeysEnabled(); + SqliteTransaction transaction; + try + { + transaction = ReadMigrationTransactionFactoryForTesting?.Invoke(_connection) + ?? _connection.BeginTransaction(deferred: false); + } + catch (SqliteException ex) when (IsReadOnlyOpenError(ex, _connection.DataSource)) + { + RecordMigrationFailure("BEGIN IMMEDIATE schema migration", ex); + return; + } + + using (transaction) + { + _activeMigrationTransaction = transaction; + try + { + if (!RunReadMigrationSteps(MigrationTransactionOwnership.Owned)) + return; + transaction.Commit(); + } + finally + { + _activeMigrationTransaction = null; + } + } + + EnsureForeignKeysEnabled(); + } + finally + { + _activeMigrationTransaction = null; + // Migration may have added columns or indexes the schema cache had already + // resolved as missing; drop the cache so the next DbReader sees the new shape. + // マイグレーションで列・index が追加された可能性があるためキャッシュを破棄する。 + _schemaCache?.Refresh(); + } + } + + private bool RunReadMigrationSteps(MigrationTransactionOwnership ownership) + { + if (ownership == MigrationTransactionOwnership.None) + throw new InvalidOperationException("Read migration transaction ownership must be explicit."); + + var previousOwnership = _migrationTransactionOwnership; + _migrationTransactionOwnership = ownership; + try + { + foreach (var (description, action) in BuildReadMigrationSteps()) + { + try + { + action(); + } + catch (SqliteException ex) + { + RecordMigrationFailure(description, ex); + + // Read-only DB / filesystem / sandbox — stop further steps and degrade. + // Catches SQLITE_READONLY (8) and compatible SQLITE_CANTOPEN (14): + // some restricted environments report CANTOPEN when SQLite tries to create + // -journal side files for the DDL. DbReader.LoadColumns() / table-detection + // will drive the degraded read path; later read queries that hit a still- + // missing column will now have a single clear preceding diagnostic to refer to. + // 読み取り専用 DB・FS・サンドボックスでの DDL 失敗は縮退扱いで打ち切る。 + if (IsReadOnlyOpenError(ex, _connection.DataSource)) return false; + + // Other SQLite errors (e.g. corruption, full disk) are not opportunistic- + // migration concerns — preserve the existing surface-the-exception behavior. + // それ以外の SQLite エラーは従来通り上位に伝播させる。 + throw; + } + } + + return true; + } + finally + { + _migrationTransactionOwnership = previousOwnership; + } + } + + private void RecordMigrationFailure(string description, SqliteException exception) + { + var failure = new DbMigrationFailure( + description, + exception.SqliteErrorCode, + FormatMigrationSqliteMessage(exception), + BuildMigrationSuggestedAction(exception.SqliteErrorCode)); + LastMigrationFailure = failure; + EmitMigrationFailureWarning(failure); + } + + private IEnumerable<(string Description, Action Action)> BuildReadMigrationSteps() + { + // The order here matches the legacy inline migration: tables before the columns and + // indexes that reference them, and fold columns before the folded indexes (#86). + // 並び順は legacy インラインマイグレーションと同じ。テーブル→列→index、fold 列→folded index。 + yield return ("CREATE INDEX bounded resource read chunk indexes", EnsureBoundedResourceReadChunkIndexes); + yield return ("CREATE TABLE reference_lines", () => Execute(@" + CREATE TABLE IF NOT EXISTS reference_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + line INTEGER NOT NULL, + context TEXT NOT NULL, + UNIQUE(file_id, line, context) + )")); + yield return ("CREATE TABLE symbol_references", () => Execute(@" + CREATE TABLE IF NOT EXISTS symbol_references ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + symbol_name TEXT, + reference_kind TEXT, + line INTEGER, + column_number INTEGER, + context TEXT, + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, + container_kind TEXT, + container_name TEXT, + is_self_reference INTEGER NOT NULL DEFAULT 0, + is_mutual_recursion INTEGER NOT NULL DEFAULT 0 + )")); + yield return ("EnsureColumn symbol_references.reference_line_id", + () => EnsureColumn("symbol_references", "reference_line_id", "INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL")); + yield return ("EnsureColumn symbol_references.is_self_reference", + () => EnsureColumn("symbol_references", "is_self_reference", "INTEGER NOT NULL DEFAULT 0")); + yield return ("EnsureColumn symbol_references.is_mutual_recursion", + () => EnsureColumn("symbol_references", "is_mutual_recursion", "INTEGER NOT NULL DEFAULT 0")); + yield return ("CREATE TABLE hotspot_reference_counts", + () => Execute(HotspotReferenceAggregateSql.CreateTableSql)); + foreach (var indexSql in HotspotReferenceAggregateSql.CreateIndexSql) + yield return ("CREATE INDEX hotspot_reference_counts", () => Execute(indexSql)); + yield return ("EnsureColumn symbol_references.source_symbol_id", + () => EnsureColumn("symbol_references", "source_symbol_id", "INTEGER")); + yield return ("EnsureColumn symbol_references.target_symbol_id", + () => EnsureColumn("symbol_references", "target_symbol_id", "INTEGER")); + yield return ("EnsureColumn symbol_references.target_symbol_key", + () => EnsureColumn("symbol_references", "target_symbol_key", "TEXT")); + yield return ("EnsureColumn symbol_references.target_qualifier", + () => EnsureColumn("symbol_references", "target_qualifier", "TEXT")); + yield return ("EnsureColumn symbol_references.resolution_state", + () => EnsureColumn("symbol_references", "resolution_state", "TEXT")); + yield return ("EnsureColumn symbol_references.resolution_candidate_count", + () => EnsureColumn("symbol_references", "resolution_candidate_count", "INTEGER NOT NULL DEFAULT 0")); + yield return ("CREATE TABLE symbol_reference_candidates", () => Execute(@" + CREATE TABLE IF NOT EXISTS symbol_reference_candidates ( + reference_id INTEGER NOT NULL, + symbol_id INTEGER NOT NULL, + scope_rank INTEGER NOT NULL, + PRIMARY KEY(reference_id, symbol_id) + )")); + yield return ("CREATE INDEX idx_symbol_refs_name", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name ON symbol_references(symbol_name)")); + yield return ("CREATE INDEX idx_symbol_refs_file", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_file ON symbol_references(file_id)")); + yield return ("CREATE INDEX idx_symbol_refs_container", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container ON symbol_references(container_name)")); + yield return ("CREATE INDEX idx_symbol_refs_container_kind", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_kind ON symbol_references(container_name, reference_kind)")); + yield return ("CREATE INDEX idx_symbol_refs_name_kind", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_kind ON symbol_references(symbol_name, reference_kind)")); + yield return ("CREATE INDEX idx_symbol_refs_name_file", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_file ON symbol_references(symbol_name, file_id)")); + yield return ("CREATE INDEX idx_reference_lines_file_line", + () => Execute("CREATE INDEX IF NOT EXISTS idx_reference_lines_file_line ON reference_lines(file_id, line)")); + yield return ("CREATE INDEX idx_symbol_refs_reference_line", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_reference_line ON symbol_references(reference_line_id)")); + yield return ("CREATE INDEX idx_symbol_refs_name_nocase", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase ON symbol_references(symbol_name COLLATE NOCASE)")); + yield return ("CREATE INDEX idx_symbol_refs_container_nocase", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_nocase ON symbol_references(container_name COLLATE NOCASE)")); + yield return ("CREATE INDEX idx_symbol_refs_name_nocase_kind", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase_kind ON symbol_references(symbol_name COLLATE NOCASE, reference_kind)")); + yield return ("CREATE INDEX idx_symbol_refs_name_nocase_file", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase_file ON symbol_references(symbol_name COLLATE NOCASE, file_id)")); + yield return ("CREATE INDEX idx_symbol_refs_container_nocase_kind", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_nocase_kind ON symbol_references(container_name COLLATE NOCASE, reference_kind)")); + yield return ("CREATE INDEX idx_symbol_refs_source_symbol", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_source_symbol ON symbol_references(source_symbol_id)")); + yield return ("CREATE INDEX idx_symbol_refs_target_symbol", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_target_symbol ON symbol_references(target_symbol_id)")); + yield return ("CREATE INDEX idx_symbol_refs_resolved_source_target_kind", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_resolved_source_target_kind ON symbol_references(source_symbol_id, target_symbol_id, reference_kind) WHERE source_symbol_id IS NOT NULL AND target_symbol_id IS NOT NULL")); + yield return ("CREATE INDEX idx_symbol_ref_candidates_symbol", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_ref_candidates_symbol ON symbol_reference_candidates(symbol_id, reference_id)")); + + yield return ("EnsureColumn files.lang", () => EnsureColumn("files", "lang", "TEXT")); + yield return ("EnsureColumn files.checksum", () => EnsureColumn("files", "checksum", "TEXT")); + yield return ("EnsureColumn files.modified", () => EnsureColumn("files", "modified", "DATETIME")); + yield return ("EnsureColumn files.indexed_at", () => EnsureColumn("files", "indexed_at", "DATETIME")); + yield return ("EnsureColumn symbols.start_line", () => EnsureColumn("symbols", "start_line", "INTEGER")); + yield return ("EnsureColumn symbols.end_line", () => EnsureColumn("symbols", "end_line", "INTEGER")); + yield return ("EnsureColumn symbols.body_start_line", () => EnsureColumn("symbols", "body_start_line", "INTEGER")); + yield return ("EnsureColumn symbols.body_end_line", () => EnsureColumn("symbols", "body_end_line", "INTEGER")); + yield return ("EnsureColumn symbols.signature", () => EnsureColumn("symbols", "signature", "TEXT")); + yield return ("EnsureColumn symbols.container_kind", () => EnsureColumn("symbols", "container_kind", "TEXT")); + yield return ("EnsureColumn symbols.container_name", () => EnsureColumn("symbols", "container_name", "TEXT")); + yield return ("EnsureColumn symbols.container_qualified_name", () => EnsureColumn("symbols", "container_qualified_name", "TEXT")); + yield return ("EnsureColumn symbols.family_key", () => EnsureColumn("symbols", "family_key", "TEXT")); + yield return ("EnsureColumn symbols.visibility", () => EnsureColumn("symbols", "visibility", "TEXT")); + yield return ("EnsureColumn symbols.return_type", () => EnsureColumn("symbols", "return_type", "TEXT")); + yield return ("EnsureColumn symbols.is_metadata_target", () => EnsureColumn("symbols", "is_metadata_target", "INTEGER")); + yield return ("EnsureColumn symbols.metadata_target_source", () => EnsureColumn("symbols", "metadata_target_source", "TEXT")); + yield return ("CREATE INDEX idx_symbols_name_nocase", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_nocase ON symbols(name COLLATE NOCASE)")); + + // #86: fold columns must be ensured BEFORE the folded indexes so CREATE INDEX does + // not fail on legacy DBs where the column did not exist yet. + // #86: folded 列を追加してから folded index を作らないと legacy DB でクラッシュする。 + yield return ("EnsureColumn symbols.name_folded", () => EnsureColumn("symbols", "name_folded", "TEXT")); + yield return ("EnsureColumn symbol_references.symbol_name_folded", () => EnsureColumn("symbol_references", "symbol_name_folded", "TEXT")); + yield return ("EnsureColumn symbol_references.container_name_folded", () => EnsureColumn("symbol_references", "container_name_folded", "TEXT")); + yield return ("CREATE INDEX idx_symbols_name_folded", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded ON symbols(name_folded)")); + yield return ("CREATE INDEX idx_symbols_file_name_folded", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_folded ON symbols(file_id, name_folded)")); + yield return ("CREATE INDEX idx_symbols_file_name_nocase", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_nocase ON symbols(file_id, name COLLATE NOCASE)")); + yield return ("CREATE INDEX idx_symbols_name_folded_container_name_nocase", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded_container_name_nocase ON symbols(name_folded, container_name COLLATE NOCASE)")); + yield return ("CREATE INDEX idx_symbols_name_folded_container_qualified_name_nocase", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded_container_qualified_name_nocase ON symbols(name_folded, container_qualified_name COLLATE NOCASE)")); + yield return ("CREATE INDEX idx_symbol_refs_symbol_name_folded", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded ON symbol_references(symbol_name_folded)")); + yield return ("CREATE INDEX idx_symbol_refs_container_name_folded", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_name_folded ON symbol_references(container_name_folded)")); + yield return ("CREATE INDEX idx_symbol_refs_symbol_name_folded_kind", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded_kind ON symbol_references(symbol_name_folded, reference_kind)")); + yield return ("CREATE INDEX idx_symbol_refs_symbol_name_folded_file", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded_file ON symbol_references(symbol_name_folded, file_id)")); + yield return ("CREATE INDEX idx_symbol_refs_container_name_folded_kind", + () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_name_folded_kind ON symbol_references(container_name_folded, reference_kind)")); + yield return ("Backfill hotspot_reference_counts", + () => Execute(HotspotReferenceAggregateSql.BuildRefreshSql(singleFile: false))); + yield return ("Stamp hotspot_reference_counts readiness", MarkHotspotReferenceAggregateReady); + + yield return ("CREATE TABLE file_issues", () => Execute(@" + CREATE TABLE IF NOT EXISTS file_issues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + line INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL + )")); + yield return ("CREATE TABLE codeindex_meta", () => Execute(@" + CREATE TABLE IF NOT EXISTS codeindex_meta ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT + )")); + yield return ("Initialize resources/list generation", () => Execute(EnsureResourceListGenerationSql)); + yield return ("CREATE TRIGGER files_resource_generation_ai", () => Execute(CreateResourceListGenerationInsertTriggerSql)); + yield return ("CREATE TRIGGER files_resource_generation_ad", () => Execute(CreateResourceListGenerationDeleteTriggerSql)); + yield return ("CREATE TRIGGER files_resource_generation_au", () => Execute(CreateResourceListGenerationUpdateTriggerSql)); + } + + private void EnsureBoundedResourceReadChunkIndexes() + { + if (!TableExists("chunks")) + return; + + Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_end_start_nonnull ON chunks(file_id, end_line, start_line, chunk_index) WHERE content IS NOT NULL"); + Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_start_chunk_nonnull ON chunks(file_id, start_line, chunk_index, end_line) WHERE content IS NOT NULL"); + } + + private bool ReadMigrationSchemaIsCurrent() + { + if ((GetUserVersion() & HotspotReferenceAggregateReadyFlag) == 0) + return false; + + foreach (var table in ReadMigrationRequiredTables) + { + if (!TableExists(table)) + return false; + } + + foreach (var (table, column) in ReadMigrationRequiredColumns) + { + if (!ColumnExists(table, column)) + return false; + } + + foreach (var index in ReadMigrationRequiredIndexes) + { + if (!IndexExists(index)) + return false; + } + + foreach (var trigger in ResourceListGenerationTriggerNames) + { + if (!TriggerExists(trigger)) + return false; + } + + return true; + } + + private string BuildMigrationSuggestedAction(int sqliteErrorCode) + { + // 8 = SQLITE_READONLY, 10 = SQLITE_IOERR, 14 = SQLITE_CANTOPEN: classic restricted- + // mount signatures (network share, sandbox, WORM). Point the user at the same fix + // we already document for the read-only fallback so the message is actionable. + // 8/10/14 は restricted mount 系の典型シグネチャ。書き込み可能な場所での再実行を案内する。 + if (sqliteErrorCode is 8 or 10 or 14) + { + return "Re-run cdidx on writable storage, or grant write access to (for example, chmod +w ), so the schema migration can complete."; + } + + // Unknown SQLite codes — surface the code itself and point at integrity check. + // それ以外の SQLite エラーは integrity_check と error code を案内する。 + return $"Inspect the database with 'sqlite3 \"PRAGMA integrity_check\"' (SQLite error code {sqliteErrorCode})."; + } + + private static string FormatMigrationSqliteMessage(SqliteException exception) + => DiagnosticRedactor.FormatExceptionMessage(exception, MigrationDiagnosticTextLimit); + + private static void EmitMigrationFailureWarning(DbMigrationFailure failure) + { + // Single line so the next read attempt only sees one clear "migration partial" record + // even if multiple commands share the same process / log stream. + // 1 行に集約し、後続 read エラーと混在しても拾いやすい形にする。 + CommandErrorWriter.WriteStderr( + $"Warning: cdidx schema migration step \"{failure.Step}\" failed " + + $"(SQLite error {failure.SqliteErrorCode}: {failure.SqliteMessage.TrimEnd('.')}). " + + "Subsequent read queries may fail with 'no such column' until the migration completes. " + + failure.SuggestedAction); + } + + private void EnsureColumn(string tableName, string columnName, string definition) + { + var quotedTableName = SqliteIdentifier.Quote(tableName); + var quotedColumnName = SqliteIdentifier.Quote(columnName); + if (_migrationTransactionOwnership != MigrationTransactionOwnership.None) + { + DbColumnEnsurer.EnsureColumn( + () => ColumnExists(tableName, columnName), + () => Execute($"ALTER TABLE {quotedTableName} ADD COLUMN {quotedColumnName} {definition}")); + return; + } + + DbColumnEnsurer.EnsureColumn( + () => ColumnExists(tableName, columnName), + beginImmediate: () => Execute("BEGIN IMMEDIATE"), + commit: () => Execute("COMMIT"), + rollback: () => Execute("ROLLBACK"), + () => Execute($"ALTER TABLE {quotedTableName} ADD COLUMN {quotedColumnName} {definition}")); + } + + private bool ColumnExists(string tableName, string columnName) + { + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = SqliteCommandPolicy.TableInfoPragmaSql(tableName); + + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + { + if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + private bool IndexExists(string name) + { + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = @name"; + SqliteCommandPolicy.Add(cmd, "@name", name); + return cmd.ExecuteScalar() != null; + } + + private bool TriggerExists(string name) + { + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = @name"; + SqliteCommandPolicy.Add(cmd, "@name", name); + return cmd.ExecuteScalar() != null; + } + + private string ExecuteScalar(string sql) + { + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = sql; + return cmd.ExecuteScalar()?.ToString() ?? ""; + } + +} diff --git a/src/CodeIndex/Database/DbContext.SchemaInitialization.cs b/src/CodeIndex/Database/DbContext.SchemaInitialization.cs new file mode 100644 index 000000000..b3c40f993 --- /dev/null +++ b/src/CodeIndex/Database/DbContext.SchemaInitialization.cs @@ -0,0 +1,755 @@ +using CodeIndex.Cli; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; +using System.Globalization; +using System.Runtime.ExceptionServices; + +namespace CodeIndex.Database; + +public partial class DbContext : IDisposable +{ + public void InitializeSchema() + { + _rebuildTrigramFtsAfterSchemaMigration = + !TableExists(FtsChunksTrigramTableName) + || CountFtsChunksTrigramSyncTriggers() != 3; + var legacyAlterTable = ExecuteScalar("PRAGMA legacy_alter_table"); + try + { + RunWithForeignKeysDisabledForMigration( + "InitializeSchema", + () => InitializeSchemaInOwnedTransaction(legacyAlterTable)); + } + finally + { + _schemaCache?.Refresh(); + } + } + + private int CountFtsChunksTrigramSyncTriggers() + { + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = CountFtsChunksTrigramSyncTriggersSql; + return SqliteCommandPolicy.ReadInt32Scalar(cmd, "trigram FTS synchronization trigger count"); + } + + private void InitializeSchemaInOwnedTransaction(string legacyAlterTable) + { + SqliteTransaction? transaction = null; + try + { + Execute("PRAGMA legacy_alter_table=ON"); + transaction = _connection.BeginTransaction(deferred: false); + _activeMigrationTransaction = transaction; + _migrationTransactionOwnership = MigrationTransactionOwnership.Owned; + try + { + // Files table / ファイルテーブル + Execute(@" + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + lang TEXT, + size INTEGER, + lines INTEGER, + checksum TEXT, + modified DATETIME, + generated INTEGER NOT NULL DEFAULT 0, + indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP + )"); + + // Chunks table / チャンクテーブル + Execute(@" + CREATE TABLE IF NOT EXISTS chunks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + chunk_index INTEGER NOT NULL, + start_line INTEGER, + end_line INTEGER, + content TEXT, + UNIQUE(file_id, chunk_index) + )"); + + // Shared reference-line context table / 参照行コンテキスト共有テーブル + Execute(@" + CREATE TABLE IF NOT EXISTS reference_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + line INTEGER NOT NULL, + context TEXT NOT NULL, + UNIQUE(file_id, line, context) + )"); + + var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); + var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); + + // Symbols table / シンボルテーブル + Execute(@" + CREATE TABLE IF NOT EXISTS symbols ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + kind TEXT CHECK (kind IN (" + symbolKindCheck + @")), + sub_kind TEXT, + name TEXT, + line INTEGER, + start_line INTEGER, + start_column INTEGER, + end_line INTEGER, + body_start_line INTEGER, + body_end_line INTEGER, + signature TEXT, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN (" + symbolKindCheck + @")), + container_name TEXT, + container_qualified_name TEXT, + family_key TEXT, + visibility TEXT, + return_type TEXT, + is_metadata_target INTEGER, + metadata_target_source TEXT + )"); + + // Indexed references table / 参照インデックステーブル + Execute(@" + CREATE TABLE IF NOT EXISTS symbol_references ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + symbol_name TEXT, + reference_kind TEXT CHECK (reference_kind IN (" + referenceKindCheck + @")), + line INTEGER, + column_number INTEGER, + context TEXT, + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN (" + symbolKindCheck + @")), + container_name TEXT, + source_symbol_id INTEGER, + target_symbol_id INTEGER, + target_symbol_key TEXT, + target_qualifier TEXT, + resolution_state TEXT, + resolution_candidate_count INTEGER NOT NULL DEFAULT 0 + )"); + + var backfillHotspotReferenceCounts = !TableExists(HotspotReferenceAggregateSql.TableName) + || (GetUserVersion() & HotspotReferenceAggregateReadyFlag) == 0; + Execute(HotspotReferenceAggregateSql.CreateTableSql); + + // File validation issues table / ファイル検証問題テーブル + Execute(@" + CREATE TABLE IF NOT EXISTS file_issues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + line INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL, + origin TEXT, + severity TEXT + )"); + + // Key-value metadata: fold algorithm version, future per-subsystem schema markers + // that don't fit in PRAGMA user_version's readiness/storage-contract bitmap. See + // NameFold.Version and DbReader fold-ready gate. + // メタデータ用 key-value: fold のアルゴリズム版数など、user_version bitmap に収まらない情報。 + Execute(@" + CREATE TABLE IF NOT EXISTS codeindex_meta ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT + )"); + NormalizeCodeIndexMetaKeys(); + + // Schema migrations for existing DBs / 既存DB向けスキーマ移行 + EnsureColumn("files", "lang", "TEXT"); + EnsureColumn("files", "checksum", "TEXT"); + EnsureColumn("files", "modified", "DATETIME"); + EnsureColumn("files", "generated", "INTEGER NOT NULL DEFAULT 0"); + EnsureColumn("files", "indexed_at", "DATETIME"); + EnsureColumn("symbols", "start_line", "INTEGER"); + EnsureColumn("symbols", "sub_kind", "TEXT"); + EnsureColumn("symbols", "start_column", "INTEGER"); + EnsureColumn("symbols", "end_line", "INTEGER"); + EnsureColumn("symbols", "body_start_line", "INTEGER"); + EnsureColumn("symbols", "body_end_line", "INTEGER"); + EnsureColumn("symbols", "signature", "TEXT"); + EnsureColumn("symbols", "container_kind", "TEXT"); + EnsureColumn("symbols", "container_name", "TEXT"); + EnsureColumn("symbols", "container_qualified_name", "TEXT"); + EnsureColumn("symbols", "family_key", "TEXT"); + EnsureColumn("symbols", "visibility", "TEXT"); + EnsureColumn("symbols", "return_type", "TEXT"); + EnsureColumn("file_issues", "origin", "TEXT"); + EnsureColumn("file_issues", "severity", "TEXT"); + EnsureColumn("symbols", "is_metadata_target", "INTEGER"); + EnsureColumn("symbols", "metadata_target_source", "TEXT"); + var rebuildsSymbolReferences = !ColumnIsNotNull("symbol_references", "file_id"); + EnsureColumn( + "symbol_references", + "reference_line_id", + rebuildsSymbolReferences ? "INTEGER" : "INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL"); + // #86: Unicode-aware folded name columns for `--exact` name matching across all + // `--exact` command variants. Populated by the writer via NameFold.Fold; NULL on + // legacy rows until a full reindex, in which case the reader falls back to the + // COLLATE NOCASE path (correct for ASCII, misses non-ASCII casing — #86 fix). + // #86: --exact 用の Unicode 折り畳み列。レガシー行は NULL のまま、再 index で埋まる。 + EnsureColumn("symbols", "name_folded", "TEXT"); + EnsureColumn("symbol_references", "symbol_name_folded", "TEXT"); + EnsureColumn("symbol_references", "container_name_folded", "TEXT"); + EnsureColumn("symbol_references", "is_self_reference", "INTEGER NOT NULL DEFAULT 0"); + EnsureColumn("symbol_references", "is_mutual_recursion", "INTEGER NOT NULL DEFAULT 0"); + EnsureColumn("symbol_references", "source_symbol_id", "INTEGER"); + EnsureColumn("symbol_references", "target_symbol_id", "INTEGER"); + EnsureColumn("symbol_references", "target_symbol_key", "TEXT"); + EnsureColumn("symbol_references", "target_qualifier", "TEXT"); + EnsureColumn("symbol_references", "resolution_state", "TEXT"); + EnsureColumn("symbol_references", "resolution_candidate_count", "INTEGER NOT NULL DEFAULT 0"); + foreach (var indexSql in HotspotReferenceAggregateSql.CreateIndexSql) + Execute(indexSql); + if (backfillHotspotReferenceCounts) + { + Execute(HotspotReferenceAggregateSql.BuildRefreshSql(singleFile: false)); + MarkHotspotReferenceAggregateReady(); + } + EnforceRequiredFileIdConstraints(); + EnforceReferenceLineSetNullConstraint(); + EnsureReferenceLinesContextKey(); + EnsureKindCheckConstraintsCurrent(); + Execute(@" + CREATE TABLE IF NOT EXISTS symbol_reference_candidates ( + reference_id INTEGER NOT NULL, + symbol_id INTEGER NOT NULL, + scope_rank INTEGER NOT NULL, + PRIMARY KEY(reference_id, symbol_id) + )"); + + // Indexes / インデックス + Execute("CREATE INDEX IF NOT EXISTS idx_files_lang ON files(lang)"); + Execute("CREATE INDEX IF NOT EXISTS idx_files_modified ON files(modified)"); + Execute("CREATE INDEX IF NOT EXISTS idx_files_generated ON files(generated)"); + Execute("CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)"); + Execute("CREATE INDEX IF NOT EXISTS idx_files_path_nocase ON files(path COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_file_issues_file_kind ON file_issues(file_id, kind)"); + // The UNIQUE path constraint supplies the BINARY exact index. The separate + // NOCASE index is only for bounded ASCII case-alias candidate lookups. + // path の UNIQUE 制約が BINARY exact index を作り、別の NOCASE index は + // bounded ASCII case-alias candidate lookup 専用に使う。 + Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_end_start_nonnull ON chunks(file_id, end_line, start_line, chunk_index) WHERE content IS NOT NULL"); + Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_start_chunk_nonnull ON chunks(file_id, start_line, chunk_index, end_line) WHERE content IS NOT NULL"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)"); + // Case-insensitive exact-match index for `symbols --exact` (and MCP `symbols` exact=true). + // Without this, `name = @q COLLATE NOCASE` falls back to a full symbols scan per query name, + // which on multi-name exact lookups becomes O(names × symbols). + // `symbols --exact` 用の大文字小文字無視 index。無いと multi-name exact でフルスキャンが N 回走る。 + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_nocase ON symbols(name COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_start ON symbols(start_line)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name ON symbol_references(symbol_name)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_file ON symbol_references(file_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container ON symbol_references(container_name)"); + // Compound indexes for common query patterns / よくあるクエリパターン用の複合インデックス + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_kind ON symbols(file_id, kind)"); + Execute("CREATE INDEX IF NOT EXISTS idx_files_lang_modified ON files(lang, modified)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_kind ON symbol_references(container_name, reference_kind)"); + // Indexes for new query patterns: --kind filter, visibility ranking, hotspot/unused analysis + // 新しいクエリパターン用: --kind フィルタ、可視性ランキング、ホットスポット/未使用分析 + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_visibility ON symbols(visibility)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_kind ON symbol_references(symbol_name, reference_kind)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_file ON symbol_references(symbol_name, file_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_mutual_folded ON symbol_references(container_name_folded, symbol_name_folded, reference_kind, is_self_reference)"); + Execute("CREATE INDEX IF NOT EXISTS idx_reference_lines_file_line ON reference_lines(file_id, line)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_reference_line ON symbol_references(reference_line_id)"); + // Case-insensitive exact-match indexes for `references --exact` / `callers --exact` / `callees --exact` (#83). + // Mirror idx_symbols_name_nocase so `= @q COLLATE NOCASE` stays O(log n) per name across graph commands. + // `references / callers / callees --exact` 用の NOCASE index。idx_symbols_name_nocase と対になる。 + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase ON symbol_references(symbol_name COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_nocase ON symbol_references(container_name COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase_kind ON symbol_references(symbol_name COLLATE NOCASE, reference_kind)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase_file ON symbol_references(symbol_name COLLATE NOCASE, file_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_nocase_kind ON symbol_references(container_name COLLATE NOCASE, reference_kind)"); + // #86: Indexes on the Unicode-folded columns. Used when FoldReadyFlag is set on the + // DB (= the write path filled every folded column). Legacy / partial DBs keep using + // the NOCASE indexes above. Both sets coexist so mixed-state DBs cannot regress. + // #86: 折り畳み列のインデックス。FoldReadyFlag が立っている DB でだけ使う。 + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded ON symbols(name_folded)"); + // Reference-source and ranked-candidate resolution repeatedly combines the folded + // symbol name with file or container scope. Keep those probes bounded for every + // indexed language, including the NOCASE fallback used by partially migrated DBs. + // 参照元・rank 候補解決は folded 名と file/container scope を繰り返し組み合わせる。 + // 全言語と部分 migration DB の NOCASE fallback を複合 index で bounded に保つ。 + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_folded ON symbols(file_id, name_folded)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_nocase ON symbols(file_id, name COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded_container_name_nocase ON symbols(name_folded, container_name COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded_container_qualified_name_nocase ON symbols(name_folded, container_qualified_name COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded ON symbol_references(symbol_name_folded)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_name_folded ON symbol_references(container_name_folded)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded_kind ON symbol_references(symbol_name_folded, reference_kind)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded_file ON symbol_references(symbol_name_folded, file_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_name_folded_kind ON symbol_references(container_name_folded, reference_kind)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_source_symbol ON symbol_references(source_symbol_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_target_symbol ON symbol_references(target_symbol_id)"); + // Mutual-recursion refresh probes the reverse of every resolved edge. Restrict the + // covering index to rows that can participate so unresolved references add no write + // or storage cost during ordinary extraction. + // 相互再帰 refresh は解決済み edge ごとに逆辺を探す。参加可能な行だけを covering + // index に含め、通常抽出中の未解決参照には書き込み・容量コストを加えない。 + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_resolved_source_target_kind ON symbol_references(source_symbol_id, target_symbol_id, reference_kind) WHERE source_symbol_id IS NOT NULL AND target_symbol_id IS NOT NULL"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_ref_candidates_symbol ON symbol_reference_candidates(symbol_id, reference_id)"); + + // Full-text search / 全文検索 + Execute(@" + CREATE VIRTUAL TABLE IF NOT EXISTS fts_chunks USING fts5( + content, + content='chunks', + content_rowid='id' + )"); + Execute($@" + CREATE VIRTUAL TABLE IF NOT EXISTS {FtsChunksTrigramTableName} USING fts5( + content, + content='chunks', + content_rowid='id', + tokenize='trigram' + )"); + if (_rebuildFtsAfterSchemaMigration) + { + Execute("INSERT INTO fts_chunks(fts_chunks) VALUES('rebuild')"); + _rebuildFtsAfterSchemaMigration = false; + } + if (_rebuildTrigramFtsAfterSchemaMigration) + { + Execute($"INSERT INTO {FtsChunksTrigramTableName}({FtsChunksTrigramTableName}) VALUES('rebuild')"); + _rebuildTrigramFtsAfterSchemaMigration = false; + } + + // FTS5 content-synced triggers — keep both FTS indexes in sync with chunks. + // Without these, CASCADE DELETEs on chunks leave orphan entries in fts_chunks. + // FTS5 content-synced トリガー — 両方の FTS index を chunks と同期する。 + // これがないと chunks の CASCADE DELETE で FTS に孤立エントリが残る。 + Execute(CreateAllFtsChunksSyncTriggersSql); + // Keep MCP resources/list cursors tied to the exact indexed-file snapshot. + // MCP resources/list カーソルをインデックス済みファイルのスナップショットに結び付ける。 + Execute(EnsureResourceListGenerationSql); + Execute(CreateResourceListGenerationInsertTriggerSql); + Execute(CreateResourceListGenerationDeleteTriggerSql); + Execute(CreateResourceListGenerationUpdateTriggerSql); + transaction.Commit(); + } + finally + { + _activeMigrationTransaction = null; + _migrationTransactionOwnership = MigrationTransactionOwnership.None; + } + } + finally + { + try + { + transaction?.Dispose(); + } + finally + { + Execute($"PRAGMA legacy_alter_table={legacyAlterTable}"); + } + } + } + + private void EnforceRequiredFileIdConstraints() + { + var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); + var legacyAlterTable = ExecuteScalar("PRAGMA legacy_alter_table"); + RunWithForeignKeysDisabledForMigration( + "EnforceRequiredFileIdConstraints", + () => EnforceRequiredFileIdConstraintsCore(symbolKindCheck, legacyAlterTable)); + } + + private void EnforceRequiredFileIdConstraintsCore(string symbolKindCheck, string legacyAlterTable) + { + try + { + Execute("PRAGMA legacy_alter_table=ON"); + RebuildTableWithRequiredFileId( + "chunks", + """ + CREATE TABLE chunks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + chunk_index INTEGER NOT NULL, + start_line INTEGER, + end_line INTEGER, + content TEXT, + UNIQUE(file_id, chunk_index) + ) + """, + "id, file_id, chunk_index, start_line, end_line, content"); + RebuildTableWithRequiredFileId( + "symbols", + $""" + CREATE TABLE symbols ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + kind TEXT CHECK (kind IN ({symbolKindCheck})), + sub_kind TEXT, + name TEXT, + line INTEGER, + start_line INTEGER, + start_column INTEGER, + end_line INTEGER, + body_start_line INTEGER, + body_end_line INTEGER, + signature TEXT, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), + container_name TEXT, + container_qualified_name TEXT, + family_key TEXT, + visibility TEXT, + return_type TEXT, + is_metadata_target INTEGER, + metadata_target_source TEXT, + name_folded TEXT + ) + """, + "id, file_id, kind, sub_kind, name, line, start_line, start_column, end_line, body_start_line, body_end_line, signature, container_kind, container_name, container_qualified_name, family_key, visibility, return_type, is_metadata_target, metadata_target_source, name_folded"); + RebuildReferenceLineTablesWithRequiredFileId(); + RebuildTableWithRequiredFileId( + "file_issues", + """ + CREATE TABLE file_issues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + line INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL, + origin TEXT, + severity TEXT + ) + """, + "id, file_id, kind, line, message, origin, severity"); + } + finally + { + Execute($"PRAGMA legacy_alter_table={legacyAlterTable}"); + } + } + + private void RebuildReferenceLineTablesWithRequiredFileId() + { + if (ColumnIsNotNull("reference_lines", "file_id") && + ColumnIsNotNull("symbol_references", "file_id")) + { + return; + } + + var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); + var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); + const string referenceLinesCreateSql = + """ + CREATE TABLE reference_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + line INTEGER NOT NULL, + context TEXT NOT NULL, + UNIQUE(file_id, line, context) + ) + """; + const string referenceLinesColumns = "id, file_id, line, context"; + var symbolReferencesCreateSql = + $""" + CREATE TABLE symbol_references ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + symbol_name TEXT, + reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), + line INTEGER, + column_number INTEGER, + context TEXT, + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), + container_name TEXT, + symbol_name_folded TEXT, + container_name_folded TEXT, + is_self_reference INTEGER NOT NULL DEFAULT 0, + is_mutual_recursion INTEGER NOT NULL DEFAULT 0, + source_symbol_id INTEGER, + target_symbol_id INTEGER, + target_symbol_key TEXT, + target_qualifier TEXT, + resolution_state TEXT, + resolution_candidate_count INTEGER NOT NULL DEFAULT 0 + ) + """; + const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; + + const string oldReferenceLines = "_reference_lines_nullable_file_id"; + const string oldSymbolReferences = "_symbol_references_nullable_file_id"; + var quotedOldReferenceLines = SqliteIdentifier.Quote(oldReferenceLines); + var quotedOldSymbolReferences = SqliteIdentifier.Quote(oldSymbolReferences); + Execute($"DROP TABLE IF EXISTS {quotedOldSymbolReferences}"); + Execute($"DROP TABLE IF EXISTS {quotedOldReferenceLines}"); + Execute("DELETE FROM symbol_references WHERE file_id IS NULL"); + Execute("DELETE FROM reference_lines WHERE file_id IS NULL"); + Execute($"ALTER TABLE symbol_references RENAME TO {quotedOldSymbolReferences}"); + Execute($"ALTER TABLE reference_lines RENAME TO {quotedOldReferenceLines}"); + Execute(referenceLinesCreateSql); + Execute($"INSERT INTO reference_lines ({referenceLinesColumns}) SELECT {referenceLinesColumns} FROM {quotedOldReferenceLines}"); + Execute(symbolReferencesCreateSql); + Execute($"INSERT INTO symbol_references ({symbolReferencesColumns}) SELECT {symbolReferencesColumns} FROM {quotedOldSymbolReferences}"); + Execute($"DROP TABLE {quotedOldSymbolReferences}"); + Execute($"DROP TABLE {quotedOldReferenceLines}"); + } + + private void EnforceReferenceLineSetNullConstraint() + { + if (SymbolReferencesReferenceLineDeletesSetNull()) + return; + + var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); + var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); + var symbolReferencesCreateSql = + $""" + CREATE TABLE symbol_references ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + symbol_name TEXT, + reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), + line INTEGER, + column_number INTEGER, + context TEXT, + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), + container_name TEXT, + symbol_name_folded TEXT, + container_name_folded TEXT, + is_self_reference INTEGER NOT NULL DEFAULT 0, + is_mutual_recursion INTEGER NOT NULL DEFAULT 0, + source_symbol_id INTEGER, + target_symbol_id INTEGER, + target_symbol_key TEXT, + target_qualifier TEXT, + resolution_state TEXT, + resolution_candidate_count INTEGER NOT NULL DEFAULT 0 + ) + """; + const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; + const string oldSymbolReferences = "_symbol_references_reference_line_delete"; + var quotedOldSymbolReferences = SqliteIdentifier.Quote(oldSymbolReferences); + + Execute($"DROP TABLE IF EXISTS {quotedOldSymbolReferences}"); + Execute(@" + UPDATE symbol_references + SET reference_line_id = NULL + WHERE reference_line_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM reference_lines + WHERE reference_lines.id = symbol_references.reference_line_id + )"); + Execute($"ALTER TABLE symbol_references RENAME TO {quotedOldSymbolReferences}"); + Execute(symbolReferencesCreateSql); + Execute($"INSERT INTO symbol_references ({symbolReferencesColumns}) SELECT {symbolReferencesColumns} FROM {quotedOldSymbolReferences}"); + Execute($"DROP TABLE {quotedOldSymbolReferences}"); + } + + private bool SymbolReferencesReferenceLineDeletesSetNull() + { + using var cmd = _connection.CreateCommand(); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = "PRAGMA foreign_key_list('symbol_references')"; + + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + { + var table = reader.GetString(2); + var from = reader.GetString(3); + var onDelete = reader.GetString(6); + if (string.Equals(table, "reference_lines", StringComparison.OrdinalIgnoreCase) + && string.Equals(from, "reference_line_id", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(onDelete, "SET NULL", StringComparison.OrdinalIgnoreCase); + } + } + + return false; + } + + private void EnsureReferenceLinesContextKey() + { + if (ReferenceLinesHasContextUniqueKey()) + return; + + var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); + var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); + const string referenceLinesCreateSql = + """ + CREATE TABLE reference_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + line INTEGER NOT NULL, + context TEXT NOT NULL, + UNIQUE(file_id, line, context) + ) + """; + const string referenceLinesColumns = "id, file_id, line, context"; + var symbolReferencesCreateSql = + $""" + CREATE TABLE symbol_references ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + symbol_name TEXT, + reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), + line INTEGER, + column_number INTEGER, + context TEXT, + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), + container_name TEXT, + symbol_name_folded TEXT, + container_name_folded TEXT, + is_self_reference INTEGER NOT NULL DEFAULT 0, + is_mutual_recursion INTEGER NOT NULL DEFAULT 0, + source_symbol_id INTEGER, + target_symbol_id INTEGER, + target_symbol_key TEXT, + target_qualifier TEXT, + resolution_state TEXT, + resolution_candidate_count INTEGER NOT NULL DEFAULT 0 + ) + """; + const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; + + const string oldReferenceLines = "_reference_lines_file_line_key"; + const string oldSymbolReferences = "_symbol_references_file_line_key"; + var quotedOldReferenceLines = SqliteIdentifier.Quote(oldReferenceLines); + var quotedOldSymbolReferences = SqliteIdentifier.Quote(oldSymbolReferences); + RunWithForeignKeysDisabledForMigration("EnsureReferenceLinesContextKey", () => + { + Execute($"DROP TABLE IF EXISTS {quotedOldSymbolReferences}"); + Execute($"DROP TABLE IF EXISTS {quotedOldReferenceLines}"); + Execute($"ALTER TABLE symbol_references RENAME TO {quotedOldSymbolReferences}"); + Execute($"ALTER TABLE reference_lines RENAME TO {quotedOldReferenceLines}"); + Execute(referenceLinesCreateSql); + Execute($"INSERT INTO reference_lines ({referenceLinesColumns}) SELECT {referenceLinesColumns} FROM {quotedOldReferenceLines}"); + Execute(symbolReferencesCreateSql); + Execute($"INSERT INTO symbol_references ({symbolReferencesColumns}) SELECT {symbolReferencesColumns} FROM {quotedOldSymbolReferences}"); + Execute($"DROP TABLE {quotedOldSymbolReferences}"); + Execute($"DROP TABLE {quotedOldReferenceLines}"); + InvokeForeignKeyValidationBeforeCheckForTesting("reference_lines_context_key"); + }); + + ValidateForeignKeysAfterMigration("reference_lines_context_key"); + _schemaCache?.Refresh(); + } + + private bool ReferenceLinesHasContextUniqueKey() + { + using var listCmd = SqliteConnectionPolicy.CreateCommand(_connection); + listCmd.CommandText = "PRAGMA index_list('reference_lines')"; + using var indexReader = listCmd.ExecuteReader(); + var indexNames = new List(); + while (indexReader.Read()) + { + var isUnique = indexReader.GetInt32(2) == 1; + if (isUnique) + indexNames.Add(indexReader.GetString(1)); + } + + foreach (var indexName in indexNames) + { + using var infoCmd = SqliteConnectionPolicy.CreateCommand(_connection); + infoCmd.CommandText = $"PRAGMA index_info('{indexName.Replace("'", "''")}')"; + using var infoReader = infoCmd.ExecuteReader(); + var columns = new List(); + while (infoReader.Read()) + columns.Add(infoReader.GetString(2)); + + if (columns.SequenceEqual(["file_id", "line", "context"], StringComparer.Ordinal)) + return true; + } + + return false; + } + + private void EnsureKindCheckConstraintsCurrent() + { + var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); + var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); + var symbolsCreateSql = + $""" + CREATE TABLE symbols ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + kind TEXT CHECK (kind IN ({symbolKindCheck})), + sub_kind TEXT, + name TEXT, + line INTEGER, + start_line INTEGER, + start_column INTEGER, + end_line INTEGER, + body_start_line INTEGER, + body_end_line INTEGER, + signature TEXT, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), + container_name TEXT, + container_qualified_name TEXT, + family_key TEXT, + visibility TEXT, + return_type TEXT, + is_metadata_target INTEGER, + metadata_target_source TEXT, + name_folded TEXT + ) + """; + const string symbolsColumns = "id, file_id, kind, sub_kind, name, line, start_line, start_column, end_line, body_start_line, body_end_line, signature, container_kind, container_name, container_qualified_name, family_key, visibility, return_type, is_metadata_target, metadata_target_source, name_folded"; + var symbolReferencesCreateSql = + $""" + CREATE TABLE symbol_references ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + symbol_name TEXT, + reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), + line INTEGER, + column_number INTEGER, + context TEXT, + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), + container_name TEXT, + symbol_name_folded TEXT, + container_name_folded TEXT, + is_self_reference INTEGER NOT NULL DEFAULT 0, + is_mutual_recursion INTEGER NOT NULL DEFAULT 0, + source_symbol_id INTEGER, + target_symbol_id INTEGER, + target_symbol_key TEXT, + target_qualifier TEXT, + resolution_state TEXT, + resolution_candidate_count INTEGER NOT NULL DEFAULT 0 + ) + """; + const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; + + var rebuilt = false; + RunWithForeignKeysDisabledForMigration("EnsureKindCheckConstraintsCurrent", () => + { + if (!TableCheckContainsAll("symbols", SymbolKindCatalog.SymbolKinds)) + { + RebuildTableWithCurrentKindChecks("symbols", "_symbols_kind_check", symbolsCreateSql, symbolsColumns); + rebuilt = true; + } + + if (!TableCheckContainsAll("symbol_references", SymbolKindCatalog.SymbolKinds.Concat(SymbolKindCatalog.ReferenceKinds))) + { + RebuildTableWithCurrentKindChecks("symbol_references", "_symbol_references_kind_check", symbolReferencesCreateSql, symbolReferencesColumns); + rebuilt = true; + } + + if (rebuilt) + InvokeForeignKeyValidationBeforeCheckForTesting("kind_check_constraints"); + }); + + if (rebuilt) + ValidateForeignKeysAfterMigration("kind_check_constraints"); + } + +} diff --git a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs new file mode 100644 index 000000000..6343f8225 --- /dev/null +++ b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs @@ -0,0 +1,372 @@ +using CodeIndex.Cli; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; +using System.Globalization; +using System.Runtime.ExceptionServices; + +namespace CodeIndex.Database; + +public partial class DbContext : IDisposable +{ + /// + /// Initialize the database schema (tables, indexes, FTS). + /// データベーススキーマ(テーブル、インデックス、FTS)を初期化する。 + /// + // Readiness bitmap stamped into PRAGMA user_version at the end of a successful index. + // Split so the CLI (graph + issues) and MCP (graph only, no validation pass) can mark + // different subsets of trust independently. + // index の成功末尾で user_version に打つビットマップ。CLI と MCP が独立に立てる。 + public const int GraphReadyFlag = 1; + public const int IssuesReadyFlag = 2; + // bit 2 (FoldReadyFlag, #86) — name_folded columns (Unicode NFKC + lowerInvariant) fully + // backfilled on symbols and symbol_references. Set only after a full scan populates every + // row's folded value so `--exact` queries can use the folded index path for Unicode + // casing (Ä/ä). Legacy DBs without fold stay on the COLLATE NOCASE fallback until reindex. + // bit 2 (FoldReadyFlag, #86): name_folded 列の完全バックフィル完了を示す。 + public const int FoldReadyFlag = 4; + // bit 3 permanently protects the maintained hotspot aggregate from older writers that do not + // update it. bit 4 is the transient trust signal: reference mutations clear it before changing + // raw rows and restore it only after the aggregate is synchronized. ClearReadyFlags preserves + // both aggregate bits because ordinary index-run readiness changes do not invalidate the counts. + // bit 3 は旧 writer から maintained aggregate を永続的に保護し、bit 4 は同期状態を示す。 + public const int HotspotReferenceAggregateStorageContractFlag = 8; + public const int HotspotReferenceAggregateReadyFlag = 16; + public const int HotspotReferenceAggregateFlags = + HotspotReferenceAggregateStorageContractFlag | HotspotReferenceAggregateReadyFlag; + public const int CurrentSchemaVersion = + GraphReadyFlag | IssuesReadyFlag | FoldReadyFlag | HotspotReferenceAggregateFlags; // 31 + public const int CodeIndexMetaSchemaVersion = 1; + public const string CodeIndexMetaSchemaVersionMetaKey = "codeindex_meta_schema_version"; + // Query-semantic readiness for hotspot family grouping. Stored in codeindex_meta instead of + // PRAGMA user_version because this guards a higher-level interpretation contract + // (`family_key` / `container_qualified_name` are authoritative for the whole DB), not + // low-level table availability. + // hotspots family grouping 用 readiness。table の有無ではなく query 意味論の trust を表す。 + public const int HotspotFamilyVersion = 2; + public const string HotspotFamilyVersionMetaKey = "hotspot_family_version"; + public const string HotspotFamilyMarkerFingerprintMetaKey = "hotspot_family_marker_fingerprint"; + public const string HotspotFamilyIncompleteMarkerFingerprintPrefix = "incomplete:"; + public static string GetHotspotFamilyVersionMetaKey(string lang) => $"hotspot_family_version_{lang}"; + public static string GetHotspotFamilyMarkerFingerprintMetaKey(string lang) => $"hotspot_family_marker_fingerprint_{lang}"; + public static bool IsIncompleteHotspotFamilyMarkerFingerprint(string? fingerprint) + => !string.IsNullOrWhiteSpace(fingerprint) + && fingerprint.StartsWith(HotspotFamilyIncompleteMarkerFingerprintPrefix, StringComparison.Ordinal); + public static string BuildIncompleteHotspotFamilyMarkerFingerprint(string? fingerprint) + => HotspotFamilyIncompleteMarkerFingerprintPrefix + (string.IsNullOrWhiteSpace(fingerprint) ? "unknown" : fingerprint); + public const int CSharpSymbolNameContractVersion = 2; + public const string CSharpSymbolNameContractVersionMetaKey = "csharp_symbol_name_contract_version"; + public const string CSharpStaticInterfaceSourceEvidenceMetaKey = "csharp_static_interface_source_evidence"; + public const int SqlGraphContractVersion = 1; + public const string SqlGraphContractVersionMetaKey = "sql_graph_contract_version"; + public const int HdlGraphContractVersion = 1; + public const string HdlGraphContractVersionMetaKey = "hdl_graph_contract_version"; + public const int ReferenceIdentityContractVersion = 2; + public const string ReferenceIdentityContractVersionMetaKey = "reference_identity_contract_version"; + public static string GetDynamicReferenceGraphContractVersionMetaKey(string lang) => + $"dynamic_reference_graph_contract_version_{lang}"; + public const string SymbolsOnlyGraphOmittedMetaKey = "symbols_only_graph_omitted"; + public const string IndexedProjectRootMetaKey = "indexed_project_root"; + public const string IndexedFollowSymlinksPolicyMetaKey = "indexed_follow_symlinks_policy"; + // Git HEAD commit captured at the end of the most recent full-scan index run (`--rebuild` or + // the default incremental full scan). Reading this back lets the CLI detect that a user + // ran `cdidx index ` after switching branches / commits, where the DB still + // mirrors the previously-indexed worktree even though the on-disk file set has diverged. + // Partial update modes (`--commits` / `--files`) deliberately do NOT touch this key, so a + // post-branch-switch partial refresh still surfaces as stale until a real full scan + // republishes the captured HEAD. The same value is read at `status` time (without + // `--check`) to surface a worktree branch / HEAD switch via `worktree_head_changed`. + // Issues #1508 and #1512. + // 直近の full-scan 成功時点で記録した git HEAD。`cdidx index` 後にブランチが切り替わると + // DB は旧 worktree のスナップショットのまま残るため、ここを比較して「rebuild を勧める」 + // 警告を出す。partial update (`--commits` / `--files`) は本キーを更新せず、後続の + // full scan が改めて記録する。同じ値を `status` (no `--check`) でも参照し、 + // `worktree_head_changed` として worktree の HEAD 切替を素早く通知する。Issues #1508 / #1512。 + public const string IndexedHeadCommitMetaKey = "indexed_head_commit"; + public const string IndexedHeadCommitBranchMetaKey = "indexed_head_commit_branch"; + // #1509: full Git HEAD commit and short branch name captured at the end of every + // successful index run (full scan AND partial update), plus the UTC timestamp of that + // stamp. Together they let `status` (and any future cross-session staleness check) + // decide whether the index was built against the commit currently checked out, or + // whether the working tree has advanced since indexing. This is DIFFERENT from + // `IndexedHeadCommitMetaKey` above (#1508): that key only fires on full scans so it + // can drive "rebuild after branch switch" warnings, while these keys fire on every + // successful index so `commits_ahead_of_indexed_head` reflects the true last-touched + // HEAD regardless of update mode. Stored as plain strings to keep DbReader's inline + // codeindex_meta lookup degradation behavior intact on legacy / read-only DBs. + // #1509: 成功 index (full scan / partial 問わず) の終端で HEAD commit / branch 名 / + // stamp 時刻を保存する。これにより status などが「DB の HEAD が現在の HEAD と何コミット + // ズレているか」を検出できる。`IndexedHeadCommitMetaKey` (#1508) とは異なり、こちらは + // partial update でも更新するため commits_ahead_of_indexed_head が常に正確になる。 + // codeindex_meta が無い legacy DB では reader 側で null フォールバックする。 + public const string IndexedHeadShaMetaKey = "indexed_head_sha"; + public const string IndexedHeadBranchMetaKey = "indexed_head_branch"; + public const string IndexedHeadTimestampMetaKey = "indexed_head_timestamp"; + public const string CommitScopedFreshHeadShaMetaKey = "commit_scoped_fresh_head_sha"; + public const string LastFullScanElapsedMsMetaKey = "last_full_scan_elapsed_ms"; + public const string LastIndexRunModeMetaKey = "last_index_run_mode"; + public const string LastIndexRunStartedAtMetaKey = "last_index_run_started_at"; + public const string LastIndexRunDurationMsMetaKey = "last_index_run_duration_ms"; + public const string LastIndexRunFilesScannedMetaKey = "last_index_run_files_scanned"; + public const string LastIndexRunFilesSkippedMetaKey = "last_index_run_files_skipped"; + public const string LastIndexRunParseErrorsMetaKey = "last_index_run_parse_errors"; + public const string LastIndexRunBytesReadMetaKey = "last_index_run_bytes_read"; + public const string LastIndexRunBytesReadSkippedFileCountMetaKey = "last_index_run_bytes_read_skipped_file_count"; + public const string LastIndexRunBytesReadIncompleteMetaKey = "last_index_run_bytes_read_incomplete"; + public const string LastIndexRunRowsUpsertedMetaKey = "last_index_run_rows_upserted"; + public const string LastIndexRunRowsDeletedMetaKey = "last_index_run_rows_deleted"; + public const string LastIndexRunPeakMemoryMbMetaKey = "last_index_run_peak_memory_mb"; + public const string LastIndexRunDiagnosticsMetaKey = "last_index_run_diagnostics_json"; + public const string LastIndexRunDiagnosticCountMetaKey = "last_index_run_diagnostic_count"; + public const string LastIndexRunDiagnosticsTruncatedMetaKey = "last_index_run_diagnostics_truncated"; + public const string LastIndexRunReferenceExtractionCapHitsMetaKey = "last_index_run_reference_extraction_cap_hits_json"; + public const int LastIndexRunDiagnosticSampleLimit = 50; + public const string LastFailedIndexRunStatusMetaKey = "last_failed_index_run_status"; + public const string LastFailedIndexRunModeMetaKey = "last_failed_index_run_mode"; + public const string LastFailedIndexRunStartedAtMetaKey = "last_failed_index_run_started_at"; + public const string LastFailedIndexRunDurationMsMetaKey = "last_failed_index_run_duration_ms"; + public const string LastFailedIndexRunFilesProcessedMetaKey = "last_failed_index_run_files_processed"; + public const string LastFailedIndexRunFilesTotalMetaKey = "last_failed_index_run_files_total"; + public const string LastFailedIndexRunErrorCodeMetaKey = "last_failed_index_run_error_code"; + public const string LastFailedIndexRunReasonMetaKey = "last_failed_index_run_reason"; + public const string LastFailedIndexRunProgressPersistedMetaKey = "last_failed_index_run_progress_persisted"; + public const string LastFailedIndexRunRecoveryHintMetaKey = "last_failed_index_run_recovery_hint"; + public const string LastFailedIndexRunFileErrorsMetaKey = "last_failed_index_run_file_errors_json"; + public const string IndexCompletenessMetaKey = "index_completeness"; + public const string IndexIncompleteReasonsMetaKey = "index_incomplete_reasons_json"; + // Issue #1585: count of files seen by the most recent successful full-repository scan + // whose non-empty extension did not map to a known language. This is a scan coverage + // signal, not an indexed-file count, and is omitted by readers until a current index pass + // has stamped it. + // Issue #1585: 直近成功した全体 scan で、非空の拡張子が既知言語に対応しなかった + // ファイル数。index 済み件数ではなく scan coverage の信号であり、現行 index が stamp + // するまでは reader 側で省略する。 + public const string UnknownExtensionFileCountMetaKey = "unknown_extension_file_count"; + public const string UnknownExtensionFilePathsMetaKey = "unknown_extension_file_paths_json"; + public const string UnknownExtensionFilesTruncatedMetaKey = "unknown_extension_files_truncated"; + public const string UnknownExtensionFilePathLimitMetaKey = "unknown_extension_file_path_limit"; + public const string UnknownExtensionExtensionCountsMetaKey = "unknown_extension_extension_counts_json"; + public const string UnknownExtensionCategoryCountsMetaKey = "unknown_extension_category_counts_json"; + public const string UnknownExtensionGroupsMetaKey = "unknown_extension_groups_json"; + public const int UnknownExtensionFilePathSampleLimit = 50; + public const string BatchInProgressMetaKey = "batch_in_progress"; + // Issue #1546: case-sensitivity of the workspace filesystem the most recent successful + // index ran on, persisted as the string "true" / "false". Resolved via the probe in + // `PathCasing` (which honors `core.ignorecase` when the project is a git workspace and + // falls back to a per-volume probe otherwise) so case-sensitive APFS volumes on macOS, + // case-sensitive NTFS via WSL, and case-sensitive ReFS no longer collapse onto the OS + // family heuristic. Exposed back through `cdidx status` (`path_case_sensitive`) so + // operators can diagnose phantom path collapses / missing-file reports. + // #1546: 直近 index 時のワークスペース FS の大小区別を "true"/"false" で保存する。 + // OS 系列だけに依存していた既存ヒューリスティックでは case-sensitive APFS 等で + // ファイルが誤って同一視されるため、`PathCasing` の実 FS プローブで判定し、 + // `cdidx status` の `path_case_sensitive` で診断できるようにする。 + public const string WorkspacePathCaseSensitiveMetaKey = "workspace_path_case_sensitive"; + // Authoritative `symbols.is_metadata_target` flag readiness, per language. Stamped at the + // end of a successful index pass once extractor facts and the writer resolver have + // classified every class-like row for that language. Readers fall back to the legacy + // heuristic when the per-language stamp is absent or its version does not match. Issue #3524. + // 言語別 metadata-target 列の正式 readiness。index 終端で extractor fact と writer resolver が + // 当該言語の class-like 行を全部分類した後にだけ stamp する。stamp が無い・version 不一致の + // 言語については reader が legacy ヒューリスティックにフォールバックする。Issue #3524。 + // Version 2 (#435 iter 5) made the writer-side resolver import-aware: unqualified base + // identifiers now resolve through the deriving file's `using Namespace;` / `using Alias = + // FQN;` directives (plus `global using` aggregated across the repo) before falling back + // to the BCL `Attribute`-suffix convention. Iter 4 DBs that only resolved through the + // deriving class's own scope chain would miss `using A; class FooAttribute : BaseAttr` + // where `A.BaseAttr : Attribute` is indexed in a sibling file. Bumping the contract + // forces those DBs to degrade to the legacy `signature LIKE '%: %'` reader path until a + // reindex republishes `is_metadata_target`. + // Version 3 (#435 iter 6) normalizes C# verbatim-identifier `@` prefixes on the writer + // side so `using @Foo.@Bar;`, `using @AliasAttr = @Foo.@BaseAttr;`, and `class Foo : + // @BaseAttr` resolve identically to their non-verbatim counterparts. Iter-5 DBs stored + // the raw `@Foo.@Bar` token in the import map and never matched the qualified index, + // leaving `VerbatimImportAttribute : BaseAttr` as `is_metadata_target=0` and dropping + // the attribute-consumer edge from `deps` / `impact`. Bumping the contract degrades + // iter-5 DBs to the legacy reader path until reindexed. + // Version 4 (#435 iter 7) widens the C# namespace / class / struct / interface / enum + // declaration regexes to accept verbatim identifiers (`public class @BaseAttr : Attribute`, + // `namespace @Foo.@Bar`) and canonicalizes the persisted symbol name so the qualified + // index keys off `BaseAttr` / `Foo.Bar` regardless of source syntax. Iter-6 DBs never + // indexed verbatim class declarations at all (the extractor regex rejected them), so + // every derived `class X : @BaseAttr` stayed `is_metadata_target=0` and dropped the + // attribute edge even with iter-6's base-name stripping in place. Iter 7 also teaches + // `StripCSharpVerbatimPrefixes` about the `::` boundary so `global::@Foo.@Bar.BaseAttr` + // canonicalizes all the way to `global::Foo.Bar.BaseAttr` instead of leaving the first + // `@` after `::` intact. Bumping the contract forces iter-6 DBs to degrade to the + // legacy reader path until a reindex republishes `is_metadata_target`. + // バージョン 2 (#435 iter 5)で resolver が import を考慮するようになった。非修飾な基底は + // deriving ファイルの `using Namespace;` / `using Alias = FQN;`(および全ファイル集約の + // `global using`)を通して解決してから BCL の `Attribute` サフィックス規約にフォールバック + // する。iter 4 の DB は `using A; class FooAttribute : BaseAttr` のような一般的な C# パターンで + // 正しく解決できないため、契約バージョンを上げて reader を legacy ヒューリスティックに縮退 + // させ、再 index で republish されるまで metadata edge を誤って主張させない。 + // バージョン 3 (#435 iter 6) で書き込み側が C# verbatim 識別子の `@` 先頭を正規化するよう + // になった。`using @Foo.@Bar;` / `using @AliasAttr = @Foo.@BaseAttr;` / `class Foo : + // @BaseAttr` が非 verbatim 形と同じキーで解決される。iter-5 DB は import map に生の + // `@Foo.@Bar` を残していたため qualified 索引に当たらず、`VerbatimImportAttribute : + // BaseAttr` が `is_metadata_target=0` となり attribute consumer 側の edge が落ちていた。 + // 契約バージョンを上げて、再 index 前の iter-5 DB を reader の legacy パスに縮退させる。 + // バージョン 4 (#435 iter 7) で C# の namespace / class / struct / interface / enum 宣言 + // 正規表現が verbatim 識別子(`public class @BaseAttr : Attribute` / `namespace + // @Foo.@Bar`)を受理するようになり、永続化されるシンボル名も canonical 化される。qualified + // 索引は `BaseAttr` / `Foo.Bar` としてキー付けされ、ソース表記に依らない。iter-6 DB は + // verbatim class 宣言自体がインデックスされず(extractor の regex が弾いていた)、 + // `class X : @BaseAttr` のような派生は iter 6 の base 側 `@` 剥がしでも resolve できず + // `is_metadata_target=0` のまま attribute edge が落ちていた。iter 7 では + // `StripCSharpVerbatimPrefixes` も `::` 境界を処理するよう拡張し、`global::@Foo.@Bar.BaseAttr` + // を `global::Foo.Bar.BaseAttr` まで完全に canonical 化する(iter 6 は `::` 直後の `@` を + // 残していた)。契約バージョンを上げて iter-6 DB を reader の legacy パスに縮退させ、 + // 再 index で republish されるまで metadata edge を黙って誤るのを防ぐ。 + // Version 5 (#435 iter 8) teaches the resolver to expand alias-qualified bases + // such as `using Alias = A; class FooAttribute : Alias.MetaBase` into + // `A.MetaBase` before the qualified index lookup. Iter-5 only handled + // alias-unqualified bases (`class Foo : Alias` where the whole base name is the + // alias), and the qualified branch fell straight through to the BCL + // `Attribute`-suffix heuristic — which misses any `MetaBase` real attribute in + // the alias target namespace unless the derived class happens to be named + // `...Attribute`. Iter-7 DBs that indexed without this expansion therefore + // dropped every `[FooAttribute]` edge whose declaration used an alias-qualified + // base, so the contract is bumped to force a re-index. + // バージョン 5 (#435 iter 8) で resolver が alias 修飾された基底を展開するようになった。 + // `using Alias = A; class FooAttribute : Alias.MetaBase` の場合、qualified 索引を + // `A.MetaBase` で引けるようになり、従来は alias 展開が無いまま BCL の `Attribute` + // サフィックス規約までフォールバックしていたため、alias target 名前空間に居る本物の + // `MetaBase : Attribute` が同 repo にあっても、派生クラス名が `...Attribute` で終わる + // 偶然でしか metadata edge を張れなかった。iter-7 DB はこの展開なしで index された + // ため alias-qualified 基底の edge が黙って落ちていた。契約バージョンを上げて再 index + // を強制する。 + // Version 6 (#435 iter 9) extends alias-qualified expansion to the `::` + // separator. C# accepts both `Alias.X` (member access) and `Alias::X` + // (qualified-alias-member, §7.8) for using aliases that name a namespace, + // and production code uses the `::` form to disambiguate namespaces from + // type names. Iter-8 only split on `.` in the expansion helper, so + // `class FooAttribute : Alias::MetaBase` still fell through to the BCL + // suffix heuristic and dropped the `[FooAttribute]` edge. Iter-8 DBs that + // indexed without this expansion must degrade to the legacy reader path + // until a reindex republishes `is_metadata_target` with `::`-aware + // resolution. + // バージョン 6 (#435 iter 9) で alias 修飾展開が `::` 区切りにも対応した。C# では + // using alias が名前空間を指す場合、`Alias.X`(メンバ アクセス)と `Alias::X` + // (qualified-alias-member、§7.8)のどちらも許容され、現場コードは名前空間と型 + // 名を衝突させないために `::` を使うことがある。iter-8 の展開 helper は `.` のみで + // 区切っていたため `class FooAttribute : Alias::MetaBase` は BCL サフィックス規約 + // まで抜け落ち、`[FooAttribute]` の edge が落ちていた。iter-8 DB はこの展開なしで + // index されたため、再 index で `::` 対応の resolver が `is_metadata_target` を + // republish するまで reader を legacy 経路へ縮退させる。 + // Version 7 (#3524) persists metadata-target provenance in + // `symbols.metadata_target_source` so readers and diagnostics can tell direct extractor + // facts from writer-resolved transitive targets. Iter-6 DBs only stored the flattened + // `is_metadata_target` bit, so they must degrade until reindexed with source-aware + // storage. + // バージョン 7 (#3524) で `symbols.metadata_target_source` に provenance を保存する。 + // extractor が直接検出した fact と writer が推移的に解決した target を reader / diagnostics + // が区別できるようにするため、平坦な `is_metadata_target` だけを持つ iter-6 DB は + // source-aware storage で再 index されるまで縮退させる。 + public const int MetadataTargetVersion = 7; + public static string GetMetadataTargetVersionMetaKey(string lang) => $"metadata_target_version_{lang}"; + public const int TypeScriptAugmentationVersion = 1; + public const string TypeScriptAugmentationVersionMetaKey = "typescript_augmentation_version"; + // Audit trail: cdidx version string (e.g. "1.22.0") that produced the most recent + // successful end-of-index pass on this DB. Readers use it to surface "DB written by + // a newer cdidx" warnings when any persisted contract version exceeds this binary's + // compiled max so silent rollback / mixed-version-team degradation becomes visible. + // Issue #1515. + // 監査用: 成功 index の末尾に書き込んだ cdidx の version 文字列。reader はここと + // 各種 contract version の比較で「より新しい cdidx が書いた DB」を検知し、 + // 黙って縮退するのではなく status で警告するために利用する。Issue #1515。 + public const string CdidxWriterVersionMetaKey = "cdidx_writer_version"; + + public int GetUserVersion() + { + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + cmd.CommandText = "PRAGMA user_version"; + var result = cmd.ExecuteScalar(); + return result is long l ? (int)l : (result is int i ? i : 0); + } + + private void MarkHotspotReferenceAggregateReady() + { + var next = GetUserVersion() | HotspotReferenceAggregateFlags; + Execute($"PRAGMA user_version = {next}"); + } + + // Reset readiness bits. Called at the START of every index run so an interrupted run + // on an already-stamped DB demotes the trust signal to degraded until the end-of-run + // stamp is written on fully successful completion. + // index 開始時にビットをクリア。途中で落ちた場合は縮退状態のまま残す。 + public void ClearReadyFlags() + { + var aggregateContractBits = GetUserVersion() & HotspotReferenceAggregateFlags; + Execute($"PRAGMA user_version = {aggregateContractBits}"); + } + + /// + /// Read a string value from `codeindex_meta`. Returns null when absent or the table + /// hasn't been created (legacy DBs, read-only sandboxes where migration was skipped). + /// codeindex_meta からの読み取り。テーブル未作成や未登録キーは null を返す。 + /// + public string? GetMetaString(string key) + { + if (!TableExists("codeindex_meta")) return null; + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key"; + SqliteCommandPolicy.Add(cmd, "@key", key); + var raw = cmd.ExecuteScalar(); + return raw is string s ? s : null; + } + + public IReadOnlyDictionary GetMetaStrings(IReadOnlyList keys) + { + var values = new Dictionary(keys.Count, StringComparer.Ordinal); + foreach (var key in keys) + values[key] = null; + + if (keys.Count == 0 || !TableExists("codeindex_meta")) + return values; + + var parameterNames = new string[keys.Count]; + for (var i = 0; i < keys.Count; i++) + parameterNames[i] = "@key" + i.ToString(CultureInfo.InvariantCulture); + + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + cmd.CommandText = "SELECT key, value FROM codeindex_meta WHERE key IN (" + string.Join(", ", parameterNames) + ")"; + for (var i = 0; i < keys.Count; i++) + SqliteCommandPolicy.Add(cmd, parameterNames[i], keys[i]); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var key = reader.GetString(0); + values[key] = reader.IsDBNull(1) ? null : reader.GetString(1); + } + + return values; + } + + public bool TryValidateIsCodeIndexDb(out string? reason) + { + var requiredTables = new[] { "files", "symbols" }; + foreach (var table in requiredTables) + { + if (!TableExists(table)) + { + reason = $"missing required table `{table}`"; + return false; + } + } + + reason = null; + return true; + } + + private bool TableExists(string name) + { + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = @name"; + SqliteCommandPolicy.Add(cmd, "@name", name); + return cmd.ExecuteScalar() != null; + } + +} diff --git a/src/CodeIndex/Database/DbContext.SchemaRebuild.cs b/src/CodeIndex/Database/DbContext.SchemaRebuild.cs new file mode 100644 index 000000000..70008ebf9 --- /dev/null +++ b/src/CodeIndex/Database/DbContext.SchemaRebuild.cs @@ -0,0 +1,302 @@ +using CodeIndex.Cli; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; +using System.Globalization; +using System.Runtime.ExceptionServices; + +namespace CodeIndex.Database; + +public partial class DbContext : IDisposable +{ + private void RunWithForeignKeysDisabledForMigration(string operation, Action action) + { + if (IsSqliteTransactionActive()) + { + AssertForeignKeyMode(operation, expected: 0); + ForeignKeysDisabledForTesting?.Invoke(operation); + action(); + return; + } + + var foreignKeys = ReadPragmaLong("foreign_keys"); + ExceptionDispatchInfo? operationFailure = null; + try + { + SetForeignKeyModeAndVerify(operation, expected: 0); + ForeignKeysDisabledForTesting?.Invoke(operation); + action(); + } + catch (Exception ex) + { + operationFailure = ExceptionDispatchInfo.Capture(ex); + } + + try + { + ForeignKeysRestoringForTesting?.Invoke(operation, foreignKeys); + SetForeignKeyModeAndVerify(operation, foreignKeys); + } + catch (Exception ex) + { + throw new CodeIndexException( + code: CommandErrorCodes.DbError, + category: CodeIndexExceptionCategory.Database, + message: $"Failed to restore PRAGMA foreign_keys after {operation}.", + path: _connection.DataSource, + hint: "Close other database connections, restore write access if needed, and rerun the command before trusting further migration work.", + innerException: ex); + } + + operationFailure?.Throw(); + } + + private void SetForeignKeyModeAndVerify(string operation, long expected) + { + Execute($"PRAGMA foreign_keys={expected}"); + AssertForeignKeyMode(operation, expected); + } + + private void AssertForeignKeyMode(string operation, long expected) + { + var effective = ReadPragmaLong("foreign_keys"); + if (effective == expected) + return; + + throw new CodeIndexException( + code: CommandErrorCodes.DbError, + category: CodeIndexExceptionCategory.Database, + message: $"PRAGMA foreign_keys remained {effective} while schema migration operation '{operation}' required {expected}.", + path: _connection.DataSource, + hint: "Finish or roll back the external transaction, then rerun the migration on a writable database connection."); + } + + private bool IsSqliteTransactionActive() + => SQLitePCL.raw.sqlite3_get_autocommit(_connection.Handle) == 0; + + private void InvokeForeignKeyValidationBeforeCheckForTesting(string phase) + { + var boundedPhase = DiagnosticRedactor.BoundDiagnosticText(phase, MigrationDiagnosticTextLimit); + ForeignKeyValidationBeforeCheckForTesting?.Invoke(_connection, boundedPhase); + } + + private void ValidateForeignKeysAfterMigration(string phase) + { + var boundedPhase = DiagnosticRedactor.BoundDiagnosticText(phase, MigrationDiagnosticTextLimit); + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = "PRAGMA foreign_key_check"; + + var violations = new List(); + var violationCount = 0; + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + { + violationCount++; + if (violations.Count < MigrationForeignKeyViolationSampleLimit) + violations.Add(FormatForeignKeyViolation(reader)); + } + + if (violationCount == 0) + return; + + var sample = string.Join("; ", violations); + var truncated = violationCount > violations.Count + ? $" (showing {violations.Count.ToString(CultureInfo.InvariantCulture)})" + : string.Empty; + throw new CodeIndexException( + code: CommandErrorCodes.DbIntegrityFailed, + category: CodeIndexExceptionCategory.Database, + message: $"Foreign key validation failed after schema migration phase '{boundedPhase}' with {violationCount.ToString(CultureInfo.InvariantCulture)} violation(s){truncated}: {sample}.", + hint: "Run `cdidx db --integrity-check --db ` and rebuild the index on writable storage if violations persist."); + } + + private static string FormatForeignKeyViolation(SqliteDataReader reader) + { + var table = FormatForeignKeyCheckValue(reader.IsDBNull(0) ? "" : reader.GetString(0)); + var rowId = reader.IsDBNull(1) + ? "" + : Convert.ToInt64(reader.GetValue(1), CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture); + var parent = FormatForeignKeyCheckValue(reader.IsDBNull(2) ? "" : reader.GetString(2)); + var fkId = reader.IsDBNull(3) + ? "" + : Convert.ToInt64(reader.GetValue(3), CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture); + return $"table={table}, rowid={rowId}, parent={parent}, fkid={fkId}"; + } + + private static string FormatForeignKeyCheckValue(string value) + => DiagnosticRedactor.BoundDiagnosticText( + DiagnosticRedactor.RedactSensitiveText(value, redactPaths: true), + MigrationDiagnosticTextLimit); + + private bool TableCheckContainsAll(string tableName, IEnumerable allowedValues) + { + var createSql = GetTableCreateSql(tableName); + if (createSql == null) + return true; + + if (!createSql.Contains("CHECK", StringComparison.OrdinalIgnoreCase)) + return true; + + return allowedValues.All(value => createSql.Contains($"'{value.Replace("'", "''")}'", StringComparison.Ordinal)); + } + + private string? GetTableCreateSql(string tableName) + { + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = @table"; + SqliteCommandPolicy.AddText(cmd, "@table", tableName); + return cmd.ExecuteScalar() as string; + } + + private string BuildRebuildSelectProjection(string sourceTableName, string columns) + { + var existingColumns = LoadColumnNames(sourceTableName); + var projectedColumns = columns + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(column => existingColumns.Contains(column) ? column : $"NULL AS {column}"); + return string.Join(", ", projectedColumns); + } + + private HashSet LoadColumnNames(string tableName) + { + var columns = new HashSet(StringComparer.OrdinalIgnoreCase); + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = SqliteCommandPolicy.TableInfoPragmaSql(tableName); + + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + columns.Add(reader.GetString(1)); + return columns; + } + + private void RebuildTableWithCurrentKindChecks(string tableName, string oldTableName, string createSql, string columns) + { + var quotedTableName = SqliteIdentifier.Quote(tableName); + var quotedOldTableName = SqliteIdentifier.Quote(oldTableName); + Execute($"DROP TABLE IF EXISTS {quotedOldTableName}"); + Execute($"ALTER TABLE {quotedTableName} RENAME TO {quotedOldTableName}"); + Execute(createSql); + var sourceColumns = BuildRebuildSelectProjection(oldTableName, columns); + Execute($"INSERT INTO {quotedTableName} ({columns}) SELECT {sourceColumns} FROM {quotedOldTableName}"); + Execute($"DROP TABLE {quotedOldTableName}"); + } + + private void RebuildTableWithRequiredFileId(string tableName, string createSql, string columns) + { + if (ColumnIsNotNull(tableName, "file_id")) + return; + + var oldTableName = $"_{tableName}_nullable_file_id"; + var quotedTableName = SqliteIdentifier.Quote(tableName); + var quotedOldTableName = SqliteIdentifier.Quote(oldTableName); + Execute($"DROP TABLE IF EXISTS {quotedOldTableName}"); + Execute(DropAllFtsChunksSyncTriggersSql); + if (string.Equals(tableName, "chunks", StringComparison.Ordinal)) + { + Execute("DROP TABLE IF EXISTS fts_chunks"); + Execute($"DROP TABLE IF EXISTS {FtsChunksTrigramTableName}"); + _rebuildFtsAfterSchemaMigration = true; + _rebuildTrigramFtsAfterSchemaMigration = true; + } + Execute($"DELETE FROM {quotedTableName} WHERE file_id IS NULL"); + Execute($"ALTER TABLE {quotedTableName} RENAME TO {quotedOldTableName}"); + Execute(createSql); + var sourceColumns = BuildRebuildSelectProjection(oldTableName, columns); + Execute($"INSERT INTO {quotedTableName} ({columns}) SELECT {sourceColumns} FROM {quotedOldTableName}"); + if (!string.Equals(tableName, "reference_lines", StringComparison.Ordinal)) + Execute($"DROP TABLE {quotedOldTableName}"); + } + + private bool ColumnIsNotNull(string tableName, string columnName) + { + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = SqliteCommandPolicy.TableInfoPragmaSql(tableName); + + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + { + if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase)) + return reader.GetInt32(3) != 0; + } + return false; + } + + /// + /// Delete all data for a full rebuild. + /// 全データを削除して完全再構築する。 + /// + public void DropAll() + { + // A rebuild that produces zero files must still invalidate an outstanding resource cursor. + // 0 件になる rebuild でも既存の resource cursor を必ず無効化する。 + // A fresh database has no cursor to invalidate and creates this table after DropAll. + // fresh database には無効化対象がなく、この table は DropAll 後に作成される。 + if (TableExists("codeindex_meta")) + Execute(IncrementResourceListGenerationSql); + Execute(DropAllFtsChunksSyncTriggersSql); + Execute($"DROP TABLE IF EXISTS {FtsChunksTrigramTableName}"); + Execute("DROP TABLE IF EXISTS fts_chunks"); + Execute("DROP TABLE IF EXISTS file_issues"); + Execute("DROP TABLE IF EXISTS hotspot_reference_counts"); + Execute("DROP TABLE IF EXISTS symbol_references"); + Execute("DROP TABLE IF EXISTS reference_lines"); + Execute("DROP TABLE IF EXISTS symbols"); + Execute("DROP TABLE IF EXISTS chunks"); + Execute("DROP TABLE IF EXISTS files"); + _schemaCache?.Refresh(); + } + + private void Execute(string sql) + { + using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = sql; + using var cancellationRegistration = _cancellation.CanBeCanceled + ? _cancellation.UnsafeRegister( + static state => SQLitePCL.raw.sqlite3_interrupt(((SqliteConnection)state!).Handle), + _connection) + : default; + _cancellation.ThrowIfCancellationRequested(); + try + { + cmd.ExecuteNonQuery(); + } + catch (SqliteException exception) when ( + _cancellation.IsCancellationRequested && exception.SqliteErrorCode == 9) + { + throw new OperationCanceledException( + "SQLite schema or aggregate maintenance was interrupted.", + exception, + _cancellation); + } + _cancellation.ThrowIfCancellationRequested(); + MarkWriteWork(walCheckpointable: false); + } + + private void EnsureForeignKeysEnabled() + { + Execute("PRAGMA foreign_keys=ON"); + var fkResult = ExecuteScalar("PRAGMA foreign_keys"); + if (fkResult != "1") + CommandErrorWriter.WriteStderr("Warning: foreign_keys pragma not enabled"); + } + + /// + /// Latest opportunistic-migration failure captured by . + /// Null when the most recent migration attempt completed every step (or was skipped on a + /// read-only connection). Callers can surface this to explain a later "no such column" + /// error coming out of a read path. + /// 直前の 実行で発生した部分マイグレーション失敗の情報。 + /// 全ステップ完了時、または読み取り専用接続でスキップされた場合は null。 + /// +} diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 96e008a02..beb44cabc 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -12,7 +12,7 @@ namespace CodeIndex.Database; /// Manages SQLite connection and schema initialization. /// SQLite接続とスキーマ初期化を管理する。 /// -public class DbContext : IDisposable +public partial class DbContext : IDisposable { public const int ApplicationId = 0x43444958; // "CDIX" public const int DefaultCacheSizeKb = 65536; @@ -854,3183 +854,4 @@ internal DbContext( _suppressWriteWorkTracking = false; } - private void OpenQueryOnly(string dbPath, CancellationToken cancellationToken) - { - if (SqliteFileUri.StartsWithFileScheme(dbPath) - && !SqliteFileUri.TryValidateBounds(dbPath, out var boundsError)) - { - throw boundsError ?? new FormatException("Invalid SQLite file URI."); - } - - try - { - var immutableSnapshot = false; - var immutableWalRisk = false; - var detachedSnapshot = false; - DbConnectionFactory.QueryOnlySnapshotSourceState? snapshotSourceState = null; - _connection = OpenSqliteConnectionWithRetry( - () => DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( - dbPath, - pooling: false, - out immutableSnapshot, - out immutableWalRisk, - out detachedSnapshot, - out snapshotSourceState, - cancellationToken), - static connection => connection.Open(), - dbPath: dbPath, - cancellationToken: cancellationToken); - Execute("PRAGMA query_only=ON"); - ApplyBusyTimeoutPragma(); - ApplyConnectionPerformancePragmas(); - RegisterConnectionFunctionsWithRetry(_connection, cancellationToken: cancellationToken); - _isReadOnly = true; - _immutableReadOnly = immutableSnapshot; - _immutableReadOnlyWalRisk = immutableWalRisk; - _connectionPooling = false; - _queryOnlySnapshotRequiresRefresh = detachedSnapshot; - _queryOnlySnapshotSourcePath = detachedSnapshot ? dbPath : null; - _queryOnlySnapshotSourceState = snapshotSourceState; - WarnIfBatchInProgress(); - } - catch - { - _connection?.Dispose(); - throw; - } - } - - private void OpenReadOnlyFallback(string dbPath, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - _connection = OpenReadOnly(dbPath, out _readOnlyImmutableFallback); - _immutableReadOnly = _readOnlyImmutableFallback; - ApplyBusyTimeoutPragma(); - ApplyConnectionPerformancePragmas(); - RegisterConnectionFunctionsWithRetry(_connection, cancellationToken: cancellationToken); - _isReadOnly = true; - WarnIfBatchInProgress(); - } - - internal static WalCheckpointResult CheckpointWalBeforeReadOnlyFallback( - string dbPath, - CancellationToken cancellationToken) - { - try - { - var connectionString = SqliteConnectionPolicy.BuildConnectionString(dbPath, SqliteConnectionPolicyMode.ReadWrite); - using var connection = OpenSqliteConnectionWithRetry( - () => new SqliteConnection(connectionString), - static connection => connection.Open(), - maxOpenAttempts: 1, - dbPath: dbPath, - cancellationToken: cancellationToken); - return ExecuteWalCheckpointTruncate(connection, cancellationToken, invokeTestingHook: true); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return WalCheckpointResult.Failed(FormatWalCheckpointFailureReason(ex)); - } - } - - private static string FormatWalCheckpointFailureReason(Exception ex) => ex switch - { - SqliteException { SqliteErrorCode: 3 } => "sqlite_permission_denied", - SqliteException { SqliteErrorCode: 5 } => "sqlite_busy", - SqliteException { SqliteErrorCode: 6 } => "sqlite_locked", - SqliteException { SqliteErrorCode: 8 } => "sqlite_read_only", - SqliteException { SqliteErrorCode: 10 } => "sqlite_io_error", - SqliteException { SqliteErrorCode: 11 } => "sqlite_corrupt", - SqliteException { SqliteErrorCode: 13 } => "sqlite_full", - SqliteException { SqliteErrorCode: 14 } => "sqlite_cannot_open", - SqliteException { SqliteErrorCode: 26 } => "sqlite_not_a_database", - SqliteException sqlite => $"sqlite_error_{sqlite.SqliteErrorCode.ToString(CultureInfo.InvariantCulture)}", - CodeIndexException codeIndexException => codeIndexException.Code, - _ => WalCheckpointResult.GenericFailureReason, - }; - - public bool TryCheckpointWalTruncate() - => TryCheckpointWalTruncate(CancellationToken.None); - - public bool TryCheckpointWalTruncate(CancellationToken cancellationToken) - => CheckpointWalTruncate(cancellationToken).Succeeded; - - public WalCheckpointResult CheckpointWalTruncate() - => CheckpointWalTruncate(CancellationToken.None); - - public WalCheckpointResult CheckpointWalTruncate(CancellationToken cancellationToken) - { - if (_isReadOnly) - { - var result = WalCheckpointResult.NotAttempted(WalCheckpointResult.ReadOnlySkippedReason); - ApplyWalCheckpointResult(result); - return result; - } - - cancellationToken.ThrowIfCancellationRequested(); - try - { - var result = ExecuteWalCheckpointTruncate(_connection, cancellationToken, invokeTestingHook: true); - ApplyWalCheckpointResult(result); - return result; - } - catch (OperationCanceledException) - { - ApplyWalCheckpointResult(WalCheckpointResult.Failed(WalCheckpointResult.CancelledFailureReason)); - throw; - } - } - - private static WalCheckpointResult ExecuteWalCheckpointTruncate( - SqliteConnection connection, - CancellationToken cancellationToken, - bool invokeTestingHook) - { - try - { - using var cmd = SqliteConnectionPolicy.CreateCommand(connection, "PRAGMA wal_checkpoint(TRUNCATE)"); - if (invokeTestingHook) - WalCheckpointTruncateExecutedForTesting?.Invoke(connection.DataSource); - - cancellationToken.ThrowIfCancellationRequested(); - ReportMaintenanceProgress("wal_checkpoint", "truncate_start", connection.DataSource); - using var reader = cmd.ExecuteReader(); - if (!reader.Read()) - return WalCheckpointResult.Failed(WalCheckpointResult.MissingResultFailureReason); - - long busy; - long logPageCount; - long checkpointedPageCount; - try - { - busy = reader.GetInt64(0); - logPageCount = reader.GetInt64(1); - checkpointedPageCount = reader.GetInt64(2); - } - catch (Exception ex) when (ex is ArgumentOutOfRangeException - or InvalidCastException - or InvalidOperationException - or IndexOutOfRangeException) - { - return WalCheckpointResult.Failed(WalCheckpointResult.InvalidResultFailureReason); - } - - ReportMaintenanceProgress("wal_checkpoint", "truncate_complete", connection.DataSource); - cancellationToken.ThrowIfCancellationRequested(); - - var notWalMode = busy == 0 && logPageCount == -1 && checkpointedPageCount == -1; - if (!notWalMode && - (busy < 0 || logPageCount < 0 || checkpointedPageCount < 0 || checkpointedPageCount > logPageCount)) - { - return new WalCheckpointResult( - true, - false, - busy, - logPageCount, - checkpointedPageCount, - null, - null, - WalCheckpointResult.InvalidResultFailureReason); - } - - var remainingPageCount = notWalMode ? 0 : logPageCount - checkpointedPageCount; - var failureReason = busy != 0 - ? WalCheckpointResult.BusyFailureReason - : remainingPageCount != 0 - ? WalCheckpointResult.PagesRemainingFailureReason - : null; - - return new WalCheckpointResult( - true, - failureReason == null, - busy, - logPageCount, - checkpointedPageCount, - remainingPageCount, - null, - failureReason); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return WalCheckpointResult.Failed(FormatWalCheckpointFailureReason(ex)); - } - } - - private void ApplyWalCheckpointResult(WalCheckpointResult result) - { - _walCheckpointAttempted = result.Attempted; - _walCheckpointSucceeded = result.Succeeded; - _walCheckpointBusy = result.Busy; - _walCheckpointLogPageCount = result.LogPageCount; - _walCheckpointCheckpointedPageCount = result.CheckpointedPageCount; - _walCheckpointRemainingPageCount = result.RemainingPageCount; - _walCheckpointSkippedReason = result.SkippedReason; - _walCheckpointFailureReason = result.FailureReason; - } - - public static string ToReadOnlyUri(string dbPath) - => SqliteConnectionPolicy.ToReadOnlyUri(dbPath); - - private void ApplyPrivateDatabaseFileModes(string dbPath) - { - if (!_databaseFileModeProvider.SupportsUnixFileModes || - dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - ApplyPrivateFileModeIfExists(dbPath, "database"); - ApplyPrivateFileModeIfExists(dbPath + "-wal", "wal"); - ApplyPrivateFileModeIfExists(dbPath + "-shm", "shm"); - } - - private void ApplyPrivateFileModeIfExists(string path, string target) - { - var normalizedPath = LongPath.EnsureWindowsPrefix(path); - try - { - if (!_databaseFileModeProvider.FileExists(normalizedPath)) - return; - -#pragma warning disable CA1416 - _databaseFileModeProvider.SetUnixFileMode( - normalizedPath, - UnixFileMode.UserRead | UnixFileMode.UserWrite); -#pragma warning restore CA1416 - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) - { - HandleDatabasePermissionFailure("set", target, ex); - } - } - - public static string? GetUnixFileModeString(string? path) - => GetUnixFileModeString( - path, - DatabasePermissionPolicyMode.BestEffort, - SystemDatabaseFileModeProvider.Instance, - out _); - - internal static string? GetUnixFileModeString( - string? path, - string policyName, - out StatusDatabasePermissionDiagnostic? diagnostic) - => GetUnixFileModeString( - path, - string.Equals(policyName, DatabasePermissionPolicy.StrictName, StringComparison.Ordinal) - ? DatabasePermissionPolicyMode.Strict - : DatabasePermissionPolicyMode.BestEffort, - SystemDatabaseFileModeProvider.Instance, - out diagnostic); - - internal static string? GetUnixFileModeString( - string? path, - DatabasePermissionPolicyMode policy, - IDatabaseFileModeProvider fileModeProvider, - out StatusDatabasePermissionDiagnostic? diagnostic) - { - diagnostic = null; - if (string.IsNullOrWhiteSpace(path) || - !fileModeProvider.SupportsUnixFileModes || - path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - try - { - if (!fileModeProvider.FileExists(path)) - return null; - - var mode = fileModeProvider.GetUnixFileMode(path) & - (UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute); - return Convert.ToString((int)mode, 8).PadLeft(4, '0'); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) - { - diagnostic = DatabasePermissionPolicy.CreateDiagnostic("read", "database", ex); - if (policy == DatabasePermissionPolicyMode.Strict) - throw DatabasePermissionPolicy.CreateStrictFailure(diagnostic, ex); - - WriteBestEffortDatabasePermissionWarning(diagnostic); - return null; - } - } - - private void HandleDatabasePermissionFailure(string operation, string target, Exception exception) - { - var diagnostic = DatabasePermissionPolicy.CreateDiagnostic(operation, target, exception); - if (_databasePermissionPolicy == DatabasePermissionPolicyMode.Strict) - throw DatabasePermissionPolicy.CreateStrictFailure(diagnostic, exception); - - if (_databasePermissionDiagnostics.Any(existing => - existing.Operation == diagnostic.Operation && - existing.Target == diagnostic.Target && - existing.Reason == diagnostic.Reason)) - { - return; - } - - _databasePermissionDiagnostics.Add(diagnostic); - WriteBestEffortDatabasePermissionWarning(diagnostic); - } - - private static void WriteBestEffortDatabasePermissionWarning(StatusDatabasePermissionDiagnostic diagnostic) - => CommandErrorWriter.WriteStderr( - $"Warning [{DatabasePermissionPolicy.FailureCode}]: policy={DatabasePermissionPolicy.BestEffortName} " - + $"operation={diagnostic.Operation} target={diagnostic.Target} reason={diagnostic.Reason}; " - + diagnostic.RecommendedAction); - - private static string? TryCreateSchemaCacheKey(string dbPath) - { - if (string.IsNullOrWhiteSpace(dbPath)) - return null; - - if (dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - { - var localPath = TryGetLocalPath(dbPath); - if (localPath == null) - return null; - dbPath = localPath; - } - - try - { - return Path.GetFullPath(dbPath); - } - catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) - { - return null; - } - } - - private void WarnIfBatchInProgress() - { - var raw = GetMetaString(BatchInProgressMetaKey); - if (string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)) - CommandErrorWriter.WriteStderr("Warning: Last batch did not complete; run `cdidx index --rebuild` to re-index from a known clean state."); - } - - /// - /// Demote readiness after an interrupted batch only from an explicitly selected repair path. - /// interrupted batch 後の readiness demotion は、明示的な repair path からのみ実行する。 - /// - public bool RepairIncompleteBatchReadiness() - { - if (_openIntent != DbOpenIntent.Repair) - throw new InvalidOperationException("Incomplete-batch readiness repair requires DbOpenIntent.Repair."); - - var raw = GetMetaString(BatchInProgressMetaKey); - if (!string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)) - return false; - - ClearReadyFlags(); - return true; - } - - private void ApplyConnectionPerformancePragmas() - { - var settings = DbPragmaPolicy.ReadConnectionPragmaSettings( - CacheSizeEnvironmentVariable, - DefaultCacheSizeKb, - MaxCacheSizeKb, - MmapSizeEnvironmentVariable, - DefaultMmapSizeBytes, - MaxMmapSizeBytes, - Environment.Is64BitProcess); - DbPragmaPolicy.ApplyConnectionPerformancePragmas(Execute, settings); - } - - private void ConfigureAutoVacuumForEmptyDatabase() - { - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'"; - var objectCount = SqliteCommandPolicy.ReadInt64Scalar(cmd, "sqlite_master object count"); - if (objectCount == 0) - Execute(DbPragmaPolicy.AutoVacuumIncrementalPragmaSql); - } - - public VacuumResult RunIncrementalVacuum(bool dryRun = false) - => RunIncrementalVacuum(dryRun, CancellationToken.None); - - public VacuumResult RunIncrementalVacuum(bool dryRun, CancellationToken cancellationToken) - { - if (_isReadOnly && !dryRun) - { - throw new CodeIndexException( - code: CommandErrorCodes.DbNotWritable, - category: CodeIndexExceptionCategory.Database, - message: "database must be writable for vacuum", - path: _connection.DataSource, - hint: "Copy the database to writable storage or rerun cdidx without a read-only --db URI."); - } - - cancellationToken.ThrowIfCancellationRequested(); - ReportMaintenanceProgress("vacuum", "metrics_before", _connection.DataSource); - var before = ReadVacuumMetrics(); - cancellationToken.ThrowIfCancellationRequested(); - if (!dryRun && before.AutoVacuumMode == 2) - { - ReportMaintenanceProgress("vacuum", "incremental_vacuum", _connection.DataSource); - Execute(DbPragmaPolicy.IncrementalVacuumPragmaSql(before.FreelistCount)); - } - else if (!dryRun) - { - ReportMaintenanceProgress("vacuum", "enable_incremental_autovacuum", _connection.DataSource); - Execute("PRAGMA auto_vacuum=INCREMENTAL"); - cancellationToken.ThrowIfCancellationRequested(); - ReportMaintenanceProgress("vacuum", "vacuum_rebuild", _connection.DataSource); - Execute("VACUUM"); - } - cancellationToken.ThrowIfCancellationRequested(); - ReportMaintenanceProgress("vacuum", "metrics_after", _connection.DataSource); - var after = dryRun ? before : ReadVacuumMetrics(); - cancellationToken.ThrowIfCancellationRequested(); - var pagesReclaimed = dryRun ? 0 : Math.Max(0, before.PageCount - after.PageCount); - var bytesReclaimed = pagesReclaimed * after.PageSize; - var estimatedPagesReclaimable = Math.Max(0, before.FreelistCount); - var estimatedBytesReclaimable = estimatedPagesReclaimable * before.PageSize; - var guidance = MaintenanceGuidanceBuilder.Build(new MaintenanceMetrics( - after.PageCount, - after.FreelistCount, - after.PageSize, - after.WalSizeBytes, - after.DbSizeBytes, - after.AutoVacuumMode)); - return new VacuumResult( - Status: dryRun ? "dry_run" : "ok", - DryRun: dryRun, - PageSize: after.PageSize, - PageCountBefore: before.PageCount, - FreelistCountBefore: before.FreelistCount, - PageCountAfter: after.PageCount, - FreelistCountAfter: after.FreelistCount, - PagesReclaimed: pagesReclaimed, - BytesReclaimed: bytesReclaimed, - EstimatedPagesReclaimable: estimatedPagesReclaimable, - EstimatedBytesReclaimable: estimatedBytesReclaimable, - DbSizeBytesBefore: before.DbSizeBytes, - WalSizeBytesBefore: before.WalSizeBytes, - DbSizeBytesAfter: after.DbSizeBytes, - WalSizeBytesAfter: after.WalSizeBytes, - WalCheckpointTimingNote: BuildWalCheckpointTimingNote(dryRun), - AutoVacuumModeBefore: before.AutoVacuumMode, - AutoVacuumModeBeforeName: MaintenanceGuidanceBuilder.FormatAutoVacuumMode(before.AutoVacuumMode) ?? "unknown", - AutoVacuumModeAfter: after.AutoVacuumMode, - AutoVacuumModeAfterName: MaintenanceGuidanceBuilder.FormatAutoVacuumMode(after.AutoVacuumMode) ?? "unknown", - MaintenanceGuidance: guidance); - } - - private static string? BuildWalCheckpointTimingNote(bool dryRun) - => dryRun - ? null - : "wal_size_bytes_after is sampled before the vacuum connection closes; SQLite may checkpoint or truncate WAL pages after command cleanup, so a later status call can report a smaller wal_size_bytes value."; - - private static void ReportMaintenanceProgress(string operation, string phase, string dbPath) - { - GlobalToolLog.Info($"db_maintenance_progress operation={operation} phase={phase} db_path={ConsoleUi.FormatBoundedValue(dbPath)}"); - MaintenanceProgressForTesting?.Invoke(operation, phase); - } - - private VacuumMetrics ReadVacuumMetrics() - => new( - ReadPragmaLong("page_count"), - ReadPragmaLong("freelist_count"), - ReadPragmaLong("page_size"), - ReadAutoVacuumMode(), - TryGetDatabaseFileSize(), - TryGetWalFileSize()); - - private long ReadAutoVacuumMode() => ReadPragmaLong("auto_vacuum"); - - private void ApplyBusyTimeoutPragma() - { - var busyTimeoutMs = DbPragmaPolicy.ReadBusyTimeoutMs(BusyTimeoutEnvironmentVariable); - Execute(DbPragmaPolicy.BusyTimeoutPragmaSql(busyTimeoutMs)); - } - - private long? TryGetDatabaseFileSize() - { - var path = _connection.DataSource; - if (string.IsNullOrWhiteSpace(path) || path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - return null; - - try - { - var info = new FileInfo(path); - return info.Exists ? info.Length : null; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) - { - return null; - } - } - - private long? TryGetWalFileSize() - { - var path = _connection.DataSource; - if (string.IsNullOrWhiteSpace(path) || path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - return null; - - try - { - var info = new FileInfo(path + "-wal"); - return info.Exists ? info.Length : 0; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) - { - return null; - } - } - - private long ReadPragmaLong(string name) - { - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - cmd.CommandText = SqliteCommandPolicy.PragmaSql(name); - return SqliteCommandPolicy.ReadInt64Scalar(cmd, $"pragma {name}"); - } - - private readonly record struct VacuumMetrics( - long PageCount, - long FreelistCount, - long PageSize, - long AutoVacuumMode, - long? DbSizeBytes, - long? WalSizeBytes); - - private void EnsureWritableUserVersionSupported(string dbPath) - { - var userVersion = GetUserVersion(); - var unknownBits = userVersion & ~CurrentSchemaVersion; - if (unknownBits == 0) - return; - - _connection.Dispose(); - throw new CodeIndexException( - code: CommandErrorCodes.SchemaTooNew, - category: CodeIndexExceptionCategory.Database, - message: $"This DB was written by a newer cdidx schema stamp (user_version {userVersion}); this binary supports up to {CurrentSchemaVersion}.", - path: dbPath, - hint: "Run with a current cdidx binary or rebuild the index with this version before writing to the database."); - } - - internal static void ExecuteSynchronousPragmaWithFallback(Action execute) - => DbPragmaPolicy.ExecuteSynchronousPragmaWithFallback(execute, DefaultSynchronousMode); - - internal static bool IsSafetyLevelTransactionError(SqliteException ex) => - DbPragmaPolicy.IsSafetyLevelTransactionError(ex); - - private static bool IsReadOnlyOpenError(SqliteException ex, string dbPath) => - DbConnectionFactory.IsReadOnlyOpenError(ex, dbPath); - - internal static SqliteConnection OpenSqliteConnectionWithRetry( - Func createConnection, - Action openConnection, - Action? sleep = null, - int maxOpenAttempts = 5, - string? dbPath = null, - CancellationToken cancellationToken = default) - => DbConnectionFactory.OpenWithRetry( - createConnection, - openConnection, - sleep, - maxOpenAttempts, - dbPath, - cancellationToken); - - private static string? TryGetLocalPath(string uriText) - => DbConnectionFactory.TryGetLocalPath(uriText); - - private static bool TryGetLocalPath(string uriText, out string? localPath, out string? failureReason) - => DbConnectionFactory.TryGetLocalPath(uriText, out localPath, out failureReason); - - private static SqliteConnection OpenReadOnly(string dbPath) - => DbConnectionFactory.OpenReadOnly(dbPath); - - private static SqliteConnection OpenReadOnly(string dbPath, out bool usedImmutableFallback) - => DbConnectionFactory.OpenReadOnly(dbPath, out usedImmutableFallback); - - private static SqliteConnection CreateArtifactPreservingQueryOnlyConnection( - string dbPath, - bool pooling, - out bool immutableSnapshot, - out bool immutableWalRisk, - out bool detachedSnapshot) - => DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( - dbPath, - pooling, - out immutableSnapshot, - out immutableWalRisk, - out detachedSnapshot); - - private static SqliteConnection OpenArtifactPreservingQueryOnly(string dbPath) - { - var connection = CreateArtifactPreservingQueryOnlyConnection( - dbPath, - pooling: false, - out _, - out _, - out _); - connection.Open(); - return connection; - } - - internal static void RegisterConnectionFunctions(SqliteConnection connection) - { - static int? ToNullableInt(long? value) - => value is null || value < int.MinValue || value > int.MaxValue ? null : (int)value.Value; - - connection.CreateFunction( - "markdown_resolve_path", - (string? sourcePath, string? targetPath) => DbReader.ResolveMarkdownDependencyPath(sourcePath, targetPath)); - connection.CreateFunction( - "python_import_resolves", - (string? sourcePath, string? targetPath, string? referenceName, string? referenceKind, string? context, long? columnNumber, string? signature) => - PythonImportBindingResolver.ResolvesDependency(sourcePath, targetPath, referenceName, referenceKind, context, columnNumber, signature)); - connection.CreateFunction( - "python_import_target_name", - (string? sourcePath, string? referenceName, string? context, long? columnNumber, string? signature) => - PythonImportBindingResolver.ResolveTargetName(sourcePath, referenceName, context, columnNumber, signature)); - connection.CreateFunction( - "sql_leaf_name", - (string? name) => string.IsNullOrWhiteSpace(name) ? null : SqlNameResolver.GetLeafName(name)); - connection.CreateFunction( - "sql_leaf_name_folded", - (string? name) => - { - if (string.IsNullOrWhiteSpace(name)) - return null; - - var leafName = SqlNameResolver.GetLeafName(name); - return leafName.Length == 0 ? null : NameFold.Fold(leafName) ?? leafName; - }); - connection.CreateFunction( - "sql_normalize_name", - (string? name) => string.IsNullOrWhiteSpace(name) ? null : SqlNameResolver.NormalizeQualifiedName(name)); - connection.CreateFunction( - "sql_normalize_name_folded", - (string? name) => - { - if (string.IsNullOrWhiteSpace(name)) - return null; - - var normalizedName = SqlNameResolver.NormalizeQualifiedName(name); - return normalizedName.Length == 0 ? null : NameFold.Fold(normalizedName) ?? normalizedName; - }); - connection.CreateFunction( - "sql_normalize_csharp_verbatim_name", - (string? text) => string.IsNullOrWhiteSpace(text) ? null : CSharpVerbatimNameNormalizer.Normalize(text)); - connection.CreateFunction( - "csharp_identifier_occurrence_count", - (string? text, string? identifier) => CountCSharpIdentifierOccurrences(text, identifier)); - connection.CreateFunction( - "sql_normalize_exact_source_name", - (string? text, string? lang) => string.IsNullOrWhiteSpace(text) ? null : ExactSourceSearchNormalizer.Normalize(text, lang)); - connection.CreateFunction( - "sql_segment_count", - (string? name) => string.IsNullOrWhiteSpace(name) ? (int?)null : SqlNameResolver.GetSegmentCount(name)); - connection.CreateFunction( - "sql_context_has_name", - (string? context, string? query) => SqlNameResolver.ContextContainsQualifiedName(context, query) ? 1 : 0); - connection.CreateFunction( - "sql_context_has_name_folded", - (string? context, string? query) => SqlNameResolver.ContextContainsQualifiedNameFolded(context, query) ? 1 : 0); - connection.CreateFunction( - "sql_context_has_name_at", - (string? context, string? query, long? columnNumber) => - SqlNameResolver.ContextContainsQualifiedNameAtColumn(context, query, ToNullableInt(columnNumber)) ? 1 : 0); - connection.CreateFunction( - "sql_context_has_name_folded_at", - (string? context, string? query, long? columnNumber) => - SqlNameResolver.ContextContainsQualifiedNameFoldedAtColumn(context, query, ToNullableInt(columnNumber)) ? 1 : 0); - connection.CreateFunction( - "sql_context_like_name_at", - (string? context, string? query, long? columnNumber) => - SqlNameResolver.ContextContainsQualifiedNameLikeAtColumn(context, query, ToNullableInt(columnNumber)) ? 1 : 0); - connection.CreateFunction( - "sql_context_like_name_folded_at", - (string? context, string? query, long? columnNumber) => - SqlNameResolver.ContextContainsQualifiedNameLikeFoldedAtColumn(context, query, ToNullableInt(columnNumber)) ? 1 : 0); - connection.CreateFunction( - "sql_resolve_reference_name", - (string? symbolName, string? context, string? containerName) => - { - var resolved = SqlNameResolver.ResolveReferenceName(symbolName, context, containerName); - return resolved.Length == 0 ? null : resolved; - }); - connection.CreateFunction( - "sql_resolve_reference_name_folded", - (string? symbolName, string? context, string? containerName) => - { - var resolved = SqlNameResolver.ResolveReferenceNameFolded(symbolName, context, containerName); - return resolved.Length == 0 ? null : resolved; - }); - connection.CreateFunction( - "sql_resolve_reference_name_at", - (string? symbolName, string? context, string? containerName, long? columnNumber) => - { - var resolved = SqlNameResolver.ResolveReferenceNameAtColumn(symbolName, context, containerName, ToNullableInt(columnNumber)); - return resolved.Length == 0 ? null : resolved; - }); - connection.CreateFunction( - "sql_resolve_reference_name_folded_at", - (string? symbolName, string? context, string? containerName, long? columnNumber) => - { - var resolved = SqlNameResolver.ResolveReferenceNameFoldedAtColumn(symbolName, context, containerName, ToNullableInt(columnNumber)); - return resolved.Length == 0 ? null : resolved; - }); - connection.CreateFunction( - "sql_resolve_reference_segment_count_at", - (string? symbolName, string? context, string? containerName, long? columnNumber) => (int?)( - SqlNameResolver.ResolveReferenceSegmentCountAtColumn(symbolName, context, containerName, ToNullableInt(columnNumber)) is var segmentCount - && segmentCount > 0 - ? segmentCount - : null)); - connection.CreateFunction( - "sql_reference_matches_target_at", - (string? symbolName, string? context, string? containerName, long? columnNumber, string? targetName) => - SqlNameResolver.ReferenceMatchesTargetAtColumn(symbolName, context, containerName, ToNullableInt(columnNumber), targetName) ? 1 : 0); - connection.CreateFunction( - "sql_allow_leaf_fallback_at", - (string? symbolName, string? context, string? containerName, long? columnNumber) => - SqlNameResolver.AllowLeafFallbackAtColumn(symbolName, context, containerName, ToNullableInt(columnNumber)) ? 1 : 0); - } - - internal static int CountCSharpIdentifierOccurrences(string? text, string? identifier) - { - if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(identifier)) - return 0; - - text = MaskCSharpCommentsAndStrings(text); - var count = 0; - var searchIndex = 0; - while (searchIndex < text.Length) - { - var index = text.IndexOf(identifier, searchIndex, StringComparison.Ordinal); - if (index < 0) - break; - - var beforeIndex = index - 1; - var afterIndex = index + identifier.Length; - var hasIdentifierBefore = beforeIndex >= 0 && IsCSharpIdentifierPart(text[beforeIndex]); - var hasIdentifierAfter = afterIndex < text.Length && IsCSharpIdentifierPart(text[afterIndex]); - if (!hasIdentifierBefore && !hasIdentifierAfter) - count++; - - searchIndex = index + identifier.Length; - } - - return count; - } - - internal static bool HasCSharpIdentifierOccurrenceOutsideLineRange(string? text, string? identifier, int startLine, int endLine) - { - if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(identifier)) - return false; - - var normalizedStartLine = Math.Max(1, startLine); - var normalizedEndLine = Math.Max(normalizedStartLine, endLine); - text = MaskCSharpCommentsAndStrings(text); - - var inRangeOccurrences = 0; - var lineNumber = 1; - var lineStart = 0; - while (lineStart <= text.Length) - { - var lineEnd = text.IndexOf('\n', lineStart); - if (lineEnd < 0) - lineEnd = text.Length; - - var lineOccurrences = CountCSharpIdentifierOccurrencesInRange(text, identifier, lineStart, lineEnd); - if (lineOccurrences > 0) - { - if (lineNumber < normalizedStartLine || lineNumber > normalizedEndLine) - return true; - - inRangeOccurrences += lineOccurrences; - if (inRangeOccurrences > 1) - return true; - } - - if (lineEnd == text.Length) - break; - - lineStart = lineEnd + 1; - lineNumber++; - } - - return false; - } - - private static int CountCSharpIdentifierOccurrencesInRange(string text, string identifier, int start, int end) - { - var count = 0; - var searchIndex = start; - while (searchIndex < end) - { - var index = text.IndexOf(identifier, searchIndex, end - searchIndex, StringComparison.Ordinal); - if (index < 0) - break; - - var beforeIndex = index - 1; - var afterIndex = index + identifier.Length; - var hasIdentifierBefore = beforeIndex >= start && IsCSharpIdentifierPart(text[beforeIndex]); - var hasIdentifierAfter = afterIndex < end && IsCSharpIdentifierPart(text[afterIndex]); - if (!hasIdentifierBefore && !hasIdentifierAfter) - count++; - - searchIndex = index + identifier.Length; - } - - return count; - } - - private static bool IsCSharpIdentifierPart(char ch) - { - return ch == '_' || char.IsLetterOrDigit(ch); - } - - private static string MaskCSharpCommentsAndStrings(string text) - { - var chars = text.ToCharArray(); - var inBlockComment = false; - var inLineComment = false; - var inString = false; - var inChar = false; - var inVerbatimString = false; - - for (var i = 0; i < chars.Length; i++) - { - var ch = chars[i]; - var next = i + 1 < chars.Length ? chars[i + 1] : '\0'; - - if (inLineComment) - { - if (ch is '\r' or '\n') - inLineComment = false; - else - chars[i] = ' '; - continue; - } - - if (inBlockComment) - { - if (ch == '*' && next == '/') - { - chars[i] = ' '; - chars[i + 1] = ' '; - i++; - inBlockComment = false; - } - else if (ch is not ('\r' or '\n')) - { - chars[i] = ' '; - } - continue; - } - - if (inString) - { - if (ch == '\\' && !inVerbatimString && next != '\0') - { - chars[i] = ' '; - if (next is not ('\r' or '\n')) - chars[i + 1] = ' '; - i++; - continue; - } - - if (inVerbatimString && ch == '"' && next == '"') - { - chars[i] = ' '; - chars[i + 1] = ' '; - i++; - continue; - } - - if (ch == '"') - inString = false; - - chars[i] = ch is '\r' or '\n' ? ch : ' '; - continue; - } - - if (inChar) - { - if (ch == '\\' && next != '\0') - { - chars[i] = ' '; - if (next is not ('\r' or '\n')) - chars[i + 1] = ' '; - i++; - continue; - } - - if (ch == '\'') - inChar = false; - - chars[i] = ch is '\r' or '\n' ? ch : ' '; - continue; - } - - if (ch == '/' && next == '/') - { - chars[i] = ' '; - chars[i + 1] = ' '; - i++; - inLineComment = true; - continue; - } - - if (ch == '/' && next == '*') - { - chars[i] = ' '; - chars[i + 1] = ' '; - i++; - inBlockComment = true; - continue; - } - - if (TryMaskCSharpRawString(chars, ref i)) - continue; - - if (TryMaskCSharpInterpolatedString(chars, ref i)) - continue; - - if (ch == '@' && next == '"') - { - chars[i] = ' '; - chars[i + 1] = ' '; - i++; - inString = true; - inVerbatimString = true; - continue; - } - - if (ch == '"') - { - chars[i] = ' '; - inString = true; - inVerbatimString = false; - continue; - } - - if (ch == '\'') - { - chars[i] = ' '; - inChar = true; - } - } - - return new string(chars); - } - - private static bool TryMaskCSharpRawString(char[] chars, ref int index) - { - var start = index; - var cursor = start; - while (cursor < chars.Length && chars[cursor] == '$') - cursor++; - - if (cursor + 2 >= chars.Length - || chars[cursor] != '"' - || chars[cursor + 1] != '"' - || chars[cursor + 2] != '"') - { - return false; - } - - var quoteCount = 0; - while (cursor + quoteCount < chars.Length && chars[cursor + quoteCount] == '"') - quoteCount++; - if (quoteCount < 3) - return false; - - var interpolationDollarCount = cursor - start; - MaskRangePreservingNewLines(chars, start, cursor + quoteCount); - var search = cursor + quoteCount; - var interpolationBraceDepth = 0; - while (search < chars.Length) - { - if (interpolationBraceDepth == 0 && HasQuoteRun(chars, search, quoteCount)) - { - MaskRangePreservingNewLines(chars, search, search + quoteCount); - index = search + quoteCount - 1; - return true; - } - - if (interpolationDollarCount > 0 && chars[search] == '{') - { - interpolationBraceDepth++; - } - else if (interpolationBraceDepth > 0 && chars[search] == '}') - { - interpolationBraceDepth--; - } - else if (interpolationBraceDepth == 0 && chars[search] is not ('\r' or '\n')) - { - chars[search] = ' '; - } - search++; - } - - index = chars.Length - 1; - return true; - } - - private static bool TryMaskCSharpInterpolatedString(char[] chars, ref int index) - { - var start = index; - if (chars[start] != '$') - return false; - - var cursor = start + 1; - var verbatim = false; - if (cursor < chars.Length && chars[cursor] == '@') - { - verbatim = true; - cursor++; - } - - if (cursor >= chars.Length || chars[cursor] != '"') - return false; - - MaskRangePreservingNewLines(chars, start, cursor + 1); - var braceDepth = 0; - for (var i = cursor + 1; i < chars.Length; i++) - { - var ch = chars[i]; - var next = i + 1 < chars.Length ? chars[i + 1] : '\0'; - - if (braceDepth == 0 && ch == '"' && !(verbatim && next == '"')) - { - chars[i] = ' '; - index = i; - return true; - } - - if (verbatim && braceDepth == 0 && ch == '"' && next == '"') - { - chars[i] = ' '; - chars[i + 1] = ' '; - i++; - continue; - } - - if (!verbatim && braceDepth == 0 && ch == '\\' && next != '\0') - { - chars[i] = ' '; - if (next is not ('\r' or '\n')) - chars[i + 1] = ' '; - i++; - continue; - } - - if (ch == '{') - { - braceDepth++; - continue; - } - - if (braceDepth > 0 && ch == '}') - { - braceDepth--; - continue; - } - - if (braceDepth == 0 && ch is not ('\r' or '\n')) - chars[i] = ' '; - } - - index = chars.Length - 1; - return true; - } - - private static bool HasQuoteRun(char[] chars, int start, int quoteCount) - { - if (start + quoteCount > chars.Length) - return false; - for (var i = 0; i < quoteCount; i++) - { - if (chars[start + i] != '"') - return false; - } - return true; - } - - private static void MaskRangePreservingNewLines(char[] chars, int start, int end) - { - for (var i = start; i < end && i < chars.Length; i++) - { - if (chars[i] is not ('\r' or '\n')) - chars[i] = ' '; - } - } - - internal static void RegisterConnectionFunctionsWithRetry( - SqliteConnection connection, - Action? sleep = null, - int maxAttempts = 5, - CancellationToken cancellationToken = default, - Action? registerConnectionFunctions = null) - { - if (maxAttempts <= 0) - throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "Must be at least 1."); - - cancellationToken.ThrowIfCancellationRequested(); - registerConnectionFunctions ??= RegisterConnectionFunctions; - for (var attempt = 1; attempt <= maxAttempts; attempt++) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - registerConnectionFunctions(connection); - return; - } - catch (SqliteException ex) when (DbConnectionFactory.IsTransientBusyError(ex) && attempt < maxAttempts) - { - DbConnectionFactory.SleepBeforeRetry(50 * attempt, sleep, cancellationToken); - } - } - } - - /// - /// Initialize the database schema (tables, indexes, FTS). - /// データベーススキーマ(テーブル、インデックス、FTS)を初期化する。 - /// - // Readiness bitmap stamped into PRAGMA user_version at the end of a successful index. - // Split so the CLI (graph + issues) and MCP (graph only, no validation pass) can mark - // different subsets of trust independently. - // index の成功末尾で user_version に打つビットマップ。CLI と MCP が独立に立てる。 - public const int GraphReadyFlag = 1; - public const int IssuesReadyFlag = 2; - // bit 2 (FoldReadyFlag, #86) — name_folded columns (Unicode NFKC + lowerInvariant) fully - // backfilled on symbols and symbol_references. Set only after a full scan populates every - // row's folded value so `--exact` queries can use the folded index path for Unicode - // casing (Ä/ä). Legacy DBs without fold stay on the COLLATE NOCASE fallback until reindex. - // bit 2 (FoldReadyFlag, #86): name_folded 列の完全バックフィル完了を示す。 - public const int FoldReadyFlag = 4; - // bit 3 permanently protects the maintained hotspot aggregate from older writers that do not - // update it. bit 4 is the transient trust signal: reference mutations clear it before changing - // raw rows and restore it only after the aggregate is synchronized. ClearReadyFlags preserves - // both aggregate bits because ordinary index-run readiness changes do not invalidate the counts. - // bit 3 は旧 writer から maintained aggregate を永続的に保護し、bit 4 は同期状態を示す。 - public const int HotspotReferenceAggregateStorageContractFlag = 8; - public const int HotspotReferenceAggregateReadyFlag = 16; - public const int HotspotReferenceAggregateFlags = - HotspotReferenceAggregateStorageContractFlag | HotspotReferenceAggregateReadyFlag; - public const int CurrentSchemaVersion = - GraphReadyFlag | IssuesReadyFlag | FoldReadyFlag | HotspotReferenceAggregateFlags; // 31 - public const int CodeIndexMetaSchemaVersion = 1; - public const string CodeIndexMetaSchemaVersionMetaKey = "codeindex_meta_schema_version"; - // Query-semantic readiness for hotspot family grouping. Stored in codeindex_meta instead of - // PRAGMA user_version because this guards a higher-level interpretation contract - // (`family_key` / `container_qualified_name` are authoritative for the whole DB), not - // low-level table availability. - // hotspots family grouping 用 readiness。table の有無ではなく query 意味論の trust を表す。 - public const int HotspotFamilyVersion = 2; - public const string HotspotFamilyVersionMetaKey = "hotspot_family_version"; - public const string HotspotFamilyMarkerFingerprintMetaKey = "hotspot_family_marker_fingerprint"; - public const string HotspotFamilyIncompleteMarkerFingerprintPrefix = "incomplete:"; - public static string GetHotspotFamilyVersionMetaKey(string lang) => $"hotspot_family_version_{lang}"; - public static string GetHotspotFamilyMarkerFingerprintMetaKey(string lang) => $"hotspot_family_marker_fingerprint_{lang}"; - public static bool IsIncompleteHotspotFamilyMarkerFingerprint(string? fingerprint) - => !string.IsNullOrWhiteSpace(fingerprint) - && fingerprint.StartsWith(HotspotFamilyIncompleteMarkerFingerprintPrefix, StringComparison.Ordinal); - public static string BuildIncompleteHotspotFamilyMarkerFingerprint(string? fingerprint) - => HotspotFamilyIncompleteMarkerFingerprintPrefix + (string.IsNullOrWhiteSpace(fingerprint) ? "unknown" : fingerprint); - public const int CSharpSymbolNameContractVersion = 2; - public const string CSharpSymbolNameContractVersionMetaKey = "csharp_symbol_name_contract_version"; - public const string CSharpStaticInterfaceSourceEvidenceMetaKey = "csharp_static_interface_source_evidence"; - public const int SqlGraphContractVersion = 1; - public const string SqlGraphContractVersionMetaKey = "sql_graph_contract_version"; - public const int HdlGraphContractVersion = 1; - public const string HdlGraphContractVersionMetaKey = "hdl_graph_contract_version"; - public const int ReferenceIdentityContractVersion = 2; - public const string ReferenceIdentityContractVersionMetaKey = "reference_identity_contract_version"; - public static string GetDynamicReferenceGraphContractVersionMetaKey(string lang) => - $"dynamic_reference_graph_contract_version_{lang}"; - public const string SymbolsOnlyGraphOmittedMetaKey = "symbols_only_graph_omitted"; - public const string IndexedProjectRootMetaKey = "indexed_project_root"; - public const string IndexedFollowSymlinksPolicyMetaKey = "indexed_follow_symlinks_policy"; - // Git HEAD commit captured at the end of the most recent full-scan index run (`--rebuild` or - // the default incremental full scan). Reading this back lets the CLI detect that a user - // ran `cdidx index ` after switching branches / commits, where the DB still - // mirrors the previously-indexed worktree even though the on-disk file set has diverged. - // Partial update modes (`--commits` / `--files`) deliberately do NOT touch this key, so a - // post-branch-switch partial refresh still surfaces as stale until a real full scan - // republishes the captured HEAD. The same value is read at `status` time (without - // `--check`) to surface a worktree branch / HEAD switch via `worktree_head_changed`. - // Issues #1508 and #1512. - // 直近の full-scan 成功時点で記録した git HEAD。`cdidx index` 後にブランチが切り替わると - // DB は旧 worktree のスナップショットのまま残るため、ここを比較して「rebuild を勧める」 - // 警告を出す。partial update (`--commits` / `--files`) は本キーを更新せず、後続の - // full scan が改めて記録する。同じ値を `status` (no `--check`) でも参照し、 - // `worktree_head_changed` として worktree の HEAD 切替を素早く通知する。Issues #1508 / #1512。 - public const string IndexedHeadCommitMetaKey = "indexed_head_commit"; - public const string IndexedHeadCommitBranchMetaKey = "indexed_head_commit_branch"; - // #1509: full Git HEAD commit and short branch name captured at the end of every - // successful index run (full scan AND partial update), plus the UTC timestamp of that - // stamp. Together they let `status` (and any future cross-session staleness check) - // decide whether the index was built against the commit currently checked out, or - // whether the working tree has advanced since indexing. This is DIFFERENT from - // `IndexedHeadCommitMetaKey` above (#1508): that key only fires on full scans so it - // can drive "rebuild after branch switch" warnings, while these keys fire on every - // successful index so `commits_ahead_of_indexed_head` reflects the true last-touched - // HEAD regardless of update mode. Stored as plain strings to keep DbReader's inline - // codeindex_meta lookup degradation behavior intact on legacy / read-only DBs. - // #1509: 成功 index (full scan / partial 問わず) の終端で HEAD commit / branch 名 / - // stamp 時刻を保存する。これにより status などが「DB の HEAD が現在の HEAD と何コミット - // ズレているか」を検出できる。`IndexedHeadCommitMetaKey` (#1508) とは異なり、こちらは - // partial update でも更新するため commits_ahead_of_indexed_head が常に正確になる。 - // codeindex_meta が無い legacy DB では reader 側で null フォールバックする。 - public const string IndexedHeadShaMetaKey = "indexed_head_sha"; - public const string IndexedHeadBranchMetaKey = "indexed_head_branch"; - public const string IndexedHeadTimestampMetaKey = "indexed_head_timestamp"; - public const string CommitScopedFreshHeadShaMetaKey = "commit_scoped_fresh_head_sha"; - public const string LastFullScanElapsedMsMetaKey = "last_full_scan_elapsed_ms"; - public const string LastIndexRunModeMetaKey = "last_index_run_mode"; - public const string LastIndexRunStartedAtMetaKey = "last_index_run_started_at"; - public const string LastIndexRunDurationMsMetaKey = "last_index_run_duration_ms"; - public const string LastIndexRunFilesScannedMetaKey = "last_index_run_files_scanned"; - public const string LastIndexRunFilesSkippedMetaKey = "last_index_run_files_skipped"; - public const string LastIndexRunParseErrorsMetaKey = "last_index_run_parse_errors"; - public const string LastIndexRunBytesReadMetaKey = "last_index_run_bytes_read"; - public const string LastIndexRunBytesReadSkippedFileCountMetaKey = "last_index_run_bytes_read_skipped_file_count"; - public const string LastIndexRunBytesReadIncompleteMetaKey = "last_index_run_bytes_read_incomplete"; - public const string LastIndexRunRowsUpsertedMetaKey = "last_index_run_rows_upserted"; - public const string LastIndexRunRowsDeletedMetaKey = "last_index_run_rows_deleted"; - public const string LastIndexRunPeakMemoryMbMetaKey = "last_index_run_peak_memory_mb"; - public const string LastIndexRunDiagnosticsMetaKey = "last_index_run_diagnostics_json"; - public const string LastIndexRunDiagnosticCountMetaKey = "last_index_run_diagnostic_count"; - public const string LastIndexRunDiagnosticsTruncatedMetaKey = "last_index_run_diagnostics_truncated"; - public const string LastIndexRunReferenceExtractionCapHitsMetaKey = "last_index_run_reference_extraction_cap_hits_json"; - public const int LastIndexRunDiagnosticSampleLimit = 50; - public const string LastFailedIndexRunStatusMetaKey = "last_failed_index_run_status"; - public const string LastFailedIndexRunModeMetaKey = "last_failed_index_run_mode"; - public const string LastFailedIndexRunStartedAtMetaKey = "last_failed_index_run_started_at"; - public const string LastFailedIndexRunDurationMsMetaKey = "last_failed_index_run_duration_ms"; - public const string LastFailedIndexRunFilesProcessedMetaKey = "last_failed_index_run_files_processed"; - public const string LastFailedIndexRunFilesTotalMetaKey = "last_failed_index_run_files_total"; - public const string LastFailedIndexRunErrorCodeMetaKey = "last_failed_index_run_error_code"; - public const string LastFailedIndexRunReasonMetaKey = "last_failed_index_run_reason"; - public const string LastFailedIndexRunProgressPersistedMetaKey = "last_failed_index_run_progress_persisted"; - public const string LastFailedIndexRunRecoveryHintMetaKey = "last_failed_index_run_recovery_hint"; - public const string LastFailedIndexRunFileErrorsMetaKey = "last_failed_index_run_file_errors_json"; - public const string IndexCompletenessMetaKey = "index_completeness"; - public const string IndexIncompleteReasonsMetaKey = "index_incomplete_reasons_json"; - // Issue #1585: count of files seen by the most recent successful full-repository scan - // whose non-empty extension did not map to a known language. This is a scan coverage - // signal, not an indexed-file count, and is omitted by readers until a current index pass - // has stamped it. - // Issue #1585: 直近成功した全体 scan で、非空の拡張子が既知言語に対応しなかった - // ファイル数。index 済み件数ではなく scan coverage の信号であり、現行 index が stamp - // するまでは reader 側で省略する。 - public const string UnknownExtensionFileCountMetaKey = "unknown_extension_file_count"; - public const string UnknownExtensionFilePathsMetaKey = "unknown_extension_file_paths_json"; - public const string UnknownExtensionFilesTruncatedMetaKey = "unknown_extension_files_truncated"; - public const string UnknownExtensionFilePathLimitMetaKey = "unknown_extension_file_path_limit"; - public const string UnknownExtensionExtensionCountsMetaKey = "unknown_extension_extension_counts_json"; - public const string UnknownExtensionCategoryCountsMetaKey = "unknown_extension_category_counts_json"; - public const string UnknownExtensionGroupsMetaKey = "unknown_extension_groups_json"; - public const int UnknownExtensionFilePathSampleLimit = 50; - public const string BatchInProgressMetaKey = "batch_in_progress"; - // Issue #1546: case-sensitivity of the workspace filesystem the most recent successful - // index ran on, persisted as the string "true" / "false". Resolved via the probe in - // `PathCasing` (which honors `core.ignorecase` when the project is a git workspace and - // falls back to a per-volume probe otherwise) so case-sensitive APFS volumes on macOS, - // case-sensitive NTFS via WSL, and case-sensitive ReFS no longer collapse onto the OS - // family heuristic. Exposed back through `cdidx status` (`path_case_sensitive`) so - // operators can diagnose phantom path collapses / missing-file reports. - // #1546: 直近 index 時のワークスペース FS の大小区別を "true"/"false" で保存する。 - // OS 系列だけに依存していた既存ヒューリスティックでは case-sensitive APFS 等で - // ファイルが誤って同一視されるため、`PathCasing` の実 FS プローブで判定し、 - // `cdidx status` の `path_case_sensitive` で診断できるようにする。 - public const string WorkspacePathCaseSensitiveMetaKey = "workspace_path_case_sensitive"; - // Authoritative `symbols.is_metadata_target` flag readiness, per language. Stamped at the - // end of a successful index pass once extractor facts and the writer resolver have - // classified every class-like row for that language. Readers fall back to the legacy - // heuristic when the per-language stamp is absent or its version does not match. Issue #3524. - // 言語別 metadata-target 列の正式 readiness。index 終端で extractor fact と writer resolver が - // 当該言語の class-like 行を全部分類した後にだけ stamp する。stamp が無い・version 不一致の - // 言語については reader が legacy ヒューリスティックにフォールバックする。Issue #3524。 - // Version 2 (#435 iter 5) made the writer-side resolver import-aware: unqualified base - // identifiers now resolve through the deriving file's `using Namespace;` / `using Alias = - // FQN;` directives (plus `global using` aggregated across the repo) before falling back - // to the BCL `Attribute`-suffix convention. Iter 4 DBs that only resolved through the - // deriving class's own scope chain would miss `using A; class FooAttribute : BaseAttr` - // where `A.BaseAttr : Attribute` is indexed in a sibling file. Bumping the contract - // forces those DBs to degrade to the legacy `signature LIKE '%: %'` reader path until a - // reindex republishes `is_metadata_target`. - // Version 3 (#435 iter 6) normalizes C# verbatim-identifier `@` prefixes on the writer - // side so `using @Foo.@Bar;`, `using @AliasAttr = @Foo.@BaseAttr;`, and `class Foo : - // @BaseAttr` resolve identically to their non-verbatim counterparts. Iter-5 DBs stored - // the raw `@Foo.@Bar` token in the import map and never matched the qualified index, - // leaving `VerbatimImportAttribute : BaseAttr` as `is_metadata_target=0` and dropping - // the attribute-consumer edge from `deps` / `impact`. Bumping the contract degrades - // iter-5 DBs to the legacy reader path until reindexed. - // Version 4 (#435 iter 7) widens the C# namespace / class / struct / interface / enum - // declaration regexes to accept verbatim identifiers (`public class @BaseAttr : Attribute`, - // `namespace @Foo.@Bar`) and canonicalizes the persisted symbol name so the qualified - // index keys off `BaseAttr` / `Foo.Bar` regardless of source syntax. Iter-6 DBs never - // indexed verbatim class declarations at all (the extractor regex rejected them), so - // every derived `class X : @BaseAttr` stayed `is_metadata_target=0` and dropped the - // attribute edge even with iter-6's base-name stripping in place. Iter 7 also teaches - // `StripCSharpVerbatimPrefixes` about the `::` boundary so `global::@Foo.@Bar.BaseAttr` - // canonicalizes all the way to `global::Foo.Bar.BaseAttr` instead of leaving the first - // `@` after `::` intact. Bumping the contract forces iter-6 DBs to degrade to the - // legacy reader path until a reindex republishes `is_metadata_target`. - // バージョン 2 (#435 iter 5)で resolver が import を考慮するようになった。非修飾な基底は - // deriving ファイルの `using Namespace;` / `using Alias = FQN;`(および全ファイル集約の - // `global using`)を通して解決してから BCL の `Attribute` サフィックス規約にフォールバック - // する。iter 4 の DB は `using A; class FooAttribute : BaseAttr` のような一般的な C# パターンで - // 正しく解決できないため、契約バージョンを上げて reader を legacy ヒューリスティックに縮退 - // させ、再 index で republish されるまで metadata edge を誤って主張させない。 - // バージョン 3 (#435 iter 6) で書き込み側が C# verbatim 識別子の `@` 先頭を正規化するよう - // になった。`using @Foo.@Bar;` / `using @AliasAttr = @Foo.@BaseAttr;` / `class Foo : - // @BaseAttr` が非 verbatim 形と同じキーで解決される。iter-5 DB は import map に生の - // `@Foo.@Bar` を残していたため qualified 索引に当たらず、`VerbatimImportAttribute : - // BaseAttr` が `is_metadata_target=0` となり attribute consumer 側の edge が落ちていた。 - // 契約バージョンを上げて、再 index 前の iter-5 DB を reader の legacy パスに縮退させる。 - // バージョン 4 (#435 iter 7) で C# の namespace / class / struct / interface / enum 宣言 - // 正規表現が verbatim 識別子(`public class @BaseAttr : Attribute` / `namespace - // @Foo.@Bar`)を受理するようになり、永続化されるシンボル名も canonical 化される。qualified - // 索引は `BaseAttr` / `Foo.Bar` としてキー付けされ、ソース表記に依らない。iter-6 DB は - // verbatim class 宣言自体がインデックスされず(extractor の regex が弾いていた)、 - // `class X : @BaseAttr` のような派生は iter 6 の base 側 `@` 剥がしでも resolve できず - // `is_metadata_target=0` のまま attribute edge が落ちていた。iter 7 では - // `StripCSharpVerbatimPrefixes` も `::` 境界を処理するよう拡張し、`global::@Foo.@Bar.BaseAttr` - // を `global::Foo.Bar.BaseAttr` まで完全に canonical 化する(iter 6 は `::` 直後の `@` を - // 残していた)。契約バージョンを上げて iter-6 DB を reader の legacy パスに縮退させ、 - // 再 index で republish されるまで metadata edge を黙って誤るのを防ぐ。 - // Version 5 (#435 iter 8) teaches the resolver to expand alias-qualified bases - // such as `using Alias = A; class FooAttribute : Alias.MetaBase` into - // `A.MetaBase` before the qualified index lookup. Iter-5 only handled - // alias-unqualified bases (`class Foo : Alias` where the whole base name is the - // alias), and the qualified branch fell straight through to the BCL - // `Attribute`-suffix heuristic — which misses any `MetaBase` real attribute in - // the alias target namespace unless the derived class happens to be named - // `...Attribute`. Iter-7 DBs that indexed without this expansion therefore - // dropped every `[FooAttribute]` edge whose declaration used an alias-qualified - // base, so the contract is bumped to force a re-index. - // バージョン 5 (#435 iter 8) で resolver が alias 修飾された基底を展開するようになった。 - // `using Alias = A; class FooAttribute : Alias.MetaBase` の場合、qualified 索引を - // `A.MetaBase` で引けるようになり、従来は alias 展開が無いまま BCL の `Attribute` - // サフィックス規約までフォールバックしていたため、alias target 名前空間に居る本物の - // `MetaBase : Attribute` が同 repo にあっても、派生クラス名が `...Attribute` で終わる - // 偶然でしか metadata edge を張れなかった。iter-7 DB はこの展開なしで index された - // ため alias-qualified 基底の edge が黙って落ちていた。契約バージョンを上げて再 index - // を強制する。 - // Version 6 (#435 iter 9) extends alias-qualified expansion to the `::` - // separator. C# accepts both `Alias.X` (member access) and `Alias::X` - // (qualified-alias-member, §7.8) for using aliases that name a namespace, - // and production code uses the `::` form to disambiguate namespaces from - // type names. Iter-8 only split on `.` in the expansion helper, so - // `class FooAttribute : Alias::MetaBase` still fell through to the BCL - // suffix heuristic and dropped the `[FooAttribute]` edge. Iter-8 DBs that - // indexed without this expansion must degrade to the legacy reader path - // until a reindex republishes `is_metadata_target` with `::`-aware - // resolution. - // バージョン 6 (#435 iter 9) で alias 修飾展開が `::` 区切りにも対応した。C# では - // using alias が名前空間を指す場合、`Alias.X`(メンバ アクセス)と `Alias::X` - // (qualified-alias-member、§7.8)のどちらも許容され、現場コードは名前空間と型 - // 名を衝突させないために `::` を使うことがある。iter-8 の展開 helper は `.` のみで - // 区切っていたため `class FooAttribute : Alias::MetaBase` は BCL サフィックス規約 - // まで抜け落ち、`[FooAttribute]` の edge が落ちていた。iter-8 DB はこの展開なしで - // index されたため、再 index で `::` 対応の resolver が `is_metadata_target` を - // republish するまで reader を legacy 経路へ縮退させる。 - // Version 7 (#3524) persists metadata-target provenance in - // `symbols.metadata_target_source` so readers and diagnostics can tell direct extractor - // facts from writer-resolved transitive targets. Iter-6 DBs only stored the flattened - // `is_metadata_target` bit, so they must degrade until reindexed with source-aware - // storage. - // バージョン 7 (#3524) で `symbols.metadata_target_source` に provenance を保存する。 - // extractor が直接検出した fact と writer が推移的に解決した target を reader / diagnostics - // が区別できるようにするため、平坦な `is_metadata_target` だけを持つ iter-6 DB は - // source-aware storage で再 index されるまで縮退させる。 - public const int MetadataTargetVersion = 7; - public static string GetMetadataTargetVersionMetaKey(string lang) => $"metadata_target_version_{lang}"; - public const int TypeScriptAugmentationVersion = 1; - public const string TypeScriptAugmentationVersionMetaKey = "typescript_augmentation_version"; - // Audit trail: cdidx version string (e.g. "1.22.0") that produced the most recent - // successful end-of-index pass on this DB. Readers use it to surface "DB written by - // a newer cdidx" warnings when any persisted contract version exceeds this binary's - // compiled max so silent rollback / mixed-version-team degradation becomes visible. - // Issue #1515. - // 監査用: 成功 index の末尾に書き込んだ cdidx の version 文字列。reader はここと - // 各種 contract version の比較で「より新しい cdidx が書いた DB」を検知し、 - // 黙って縮退するのではなく status で警告するために利用する。Issue #1515。 - public const string CdidxWriterVersionMetaKey = "cdidx_writer_version"; - - public int GetUserVersion() - { - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - cmd.CommandText = "PRAGMA user_version"; - var result = cmd.ExecuteScalar(); - return result is long l ? (int)l : (result is int i ? i : 0); - } - - private void MarkHotspotReferenceAggregateReady() - { - var next = GetUserVersion() | HotspotReferenceAggregateFlags; - Execute($"PRAGMA user_version = {next}"); - } - - // Reset readiness bits. Called at the START of every index run so an interrupted run - // on an already-stamped DB demotes the trust signal to degraded until the end-of-run - // stamp is written on fully successful completion. - // index 開始時にビットをクリア。途中で落ちた場合は縮退状態のまま残す。 - public void ClearReadyFlags() - { - var aggregateContractBits = GetUserVersion() & HotspotReferenceAggregateFlags; - Execute($"PRAGMA user_version = {aggregateContractBits}"); - } - - /// - /// Read a string value from `codeindex_meta`. Returns null when absent or the table - /// hasn't been created (legacy DBs, read-only sandboxes where migration was skipped). - /// codeindex_meta からの読み取り。テーブル未作成や未登録キーは null を返す。 - /// - public string? GetMetaString(string key) - { - if (!TableExists("codeindex_meta")) return null; - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key"; - SqliteCommandPolicy.Add(cmd, "@key", key); - var raw = cmd.ExecuteScalar(); - return raw is string s ? s : null; - } - - public IReadOnlyDictionary GetMetaStrings(IReadOnlyList keys) - { - var values = new Dictionary(keys.Count, StringComparer.Ordinal); - foreach (var key in keys) - values[key] = null; - - if (keys.Count == 0 || !TableExists("codeindex_meta")) - return values; - - var parameterNames = new string[keys.Count]; - for (var i = 0; i < keys.Count; i++) - parameterNames[i] = "@key" + i.ToString(CultureInfo.InvariantCulture); - - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - cmd.CommandText = "SELECT key, value FROM codeindex_meta WHERE key IN (" + string.Join(", ", parameterNames) + ")"; - for (var i = 0; i < keys.Count; i++) - SqliteCommandPolicy.Add(cmd, parameterNames[i], keys[i]); - - using var reader = cmd.ExecuteReader(); - while (reader.Read()) - { - var key = reader.GetString(0); - values[key] = reader.IsDBNull(1) ? null : reader.GetString(1); - } - - return values; - } - - public bool TryValidateIsCodeIndexDb(out string? reason) - { - var requiredTables = new[] { "files", "symbols" }; - foreach (var table in requiredTables) - { - if (!TableExists(table)) - { - reason = $"missing required table `{table}`"; - return false; - } - } - - reason = null; - return true; - } - - private bool TableExists(string name) - { - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = @name"; - SqliteCommandPolicy.Add(cmd, "@name", name); - return cmd.ExecuteScalar() != null; - } - - public void InitializeSchema() - { - _rebuildTrigramFtsAfterSchemaMigration = - !TableExists(FtsChunksTrigramTableName) - || CountFtsChunksTrigramSyncTriggers() != 3; - var legacyAlterTable = ExecuteScalar("PRAGMA legacy_alter_table"); - try - { - RunWithForeignKeysDisabledForMigration( - "InitializeSchema", - () => InitializeSchemaInOwnedTransaction(legacyAlterTable)); - } - finally - { - _schemaCache?.Refresh(); - } - } - - private int CountFtsChunksTrigramSyncTriggers() - { - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - if (_activeMigrationTransaction != null) - cmd.Transaction = _activeMigrationTransaction; - cmd.CommandText = CountFtsChunksTrigramSyncTriggersSql; - return SqliteCommandPolicy.ReadInt32Scalar(cmd, "trigram FTS synchronization trigger count"); - } - - private void InitializeSchemaInOwnedTransaction(string legacyAlterTable) - { - SqliteTransaction? transaction = null; - try - { - Execute("PRAGMA legacy_alter_table=ON"); - transaction = _connection.BeginTransaction(deferred: false); - _activeMigrationTransaction = transaction; - _migrationTransactionOwnership = MigrationTransactionOwnership.Owned; - try - { - // Files table / ファイルテーブル - Execute(@" - CREATE TABLE IF NOT EXISTS files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path TEXT NOT NULL UNIQUE, - lang TEXT, - size INTEGER, - lines INTEGER, - checksum TEXT, - modified DATETIME, - generated INTEGER NOT NULL DEFAULT 0, - indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP - )"); - - // Chunks table / チャンクテーブル - Execute(@" - CREATE TABLE IF NOT EXISTS chunks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - chunk_index INTEGER NOT NULL, - start_line INTEGER, - end_line INTEGER, - content TEXT, - UNIQUE(file_id, chunk_index) - )"); - - // Shared reference-line context table / 参照行コンテキスト共有テーブル - Execute(@" - CREATE TABLE IF NOT EXISTS reference_lines ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - line INTEGER NOT NULL, - context TEXT NOT NULL, - UNIQUE(file_id, line, context) - )"); - - var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); - var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); - - // Symbols table / シンボルテーブル - Execute(@" - CREATE TABLE IF NOT EXISTS symbols ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - kind TEXT CHECK (kind IN (" + symbolKindCheck + @")), - sub_kind TEXT, - name TEXT, - line INTEGER, - start_line INTEGER, - start_column INTEGER, - end_line INTEGER, - body_start_line INTEGER, - body_end_line INTEGER, - signature TEXT, - container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN (" + symbolKindCheck + @")), - container_name TEXT, - container_qualified_name TEXT, - family_key TEXT, - visibility TEXT, - return_type TEXT, - is_metadata_target INTEGER, - metadata_target_source TEXT - )"); - - // Indexed references table / 参照インデックステーブル - Execute(@" - CREATE TABLE IF NOT EXISTS symbol_references ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - symbol_name TEXT, - reference_kind TEXT CHECK (reference_kind IN (" + referenceKindCheck + @")), - line INTEGER, - column_number INTEGER, - context TEXT, - reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, - container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN (" + symbolKindCheck + @")), - container_name TEXT, - source_symbol_id INTEGER, - target_symbol_id INTEGER, - target_symbol_key TEXT, - target_qualifier TEXT, - resolution_state TEXT, - resolution_candidate_count INTEGER NOT NULL DEFAULT 0 - )"); - - var backfillHotspotReferenceCounts = !TableExists(HotspotReferenceAggregateSql.TableName) - || (GetUserVersion() & HotspotReferenceAggregateReadyFlag) == 0; - Execute(HotspotReferenceAggregateSql.CreateTableSql); - - // File validation issues table / ファイル検証問題テーブル - Execute(@" - CREATE TABLE IF NOT EXISTS file_issues ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - kind TEXT NOT NULL, - line INTEGER NOT NULL DEFAULT 0, - message TEXT NOT NULL, - origin TEXT, - severity TEXT - )"); - - // Key-value metadata: fold algorithm version, future per-subsystem schema markers - // that don't fit in PRAGMA user_version's readiness/storage-contract bitmap. See - // NameFold.Version and DbReader fold-ready gate. - // メタデータ用 key-value: fold のアルゴリズム版数など、user_version bitmap に収まらない情報。 - Execute(@" - CREATE TABLE IF NOT EXISTS codeindex_meta ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT - )"); - NormalizeCodeIndexMetaKeys(); - - // Schema migrations for existing DBs / 既存DB向けスキーマ移行 - EnsureColumn("files", "lang", "TEXT"); - EnsureColumn("files", "checksum", "TEXT"); - EnsureColumn("files", "modified", "DATETIME"); - EnsureColumn("files", "generated", "INTEGER NOT NULL DEFAULT 0"); - EnsureColumn("files", "indexed_at", "DATETIME"); - EnsureColumn("symbols", "start_line", "INTEGER"); - EnsureColumn("symbols", "sub_kind", "TEXT"); - EnsureColumn("symbols", "start_column", "INTEGER"); - EnsureColumn("symbols", "end_line", "INTEGER"); - EnsureColumn("symbols", "body_start_line", "INTEGER"); - EnsureColumn("symbols", "body_end_line", "INTEGER"); - EnsureColumn("symbols", "signature", "TEXT"); - EnsureColumn("symbols", "container_kind", "TEXT"); - EnsureColumn("symbols", "container_name", "TEXT"); - EnsureColumn("symbols", "container_qualified_name", "TEXT"); - EnsureColumn("symbols", "family_key", "TEXT"); - EnsureColumn("symbols", "visibility", "TEXT"); - EnsureColumn("symbols", "return_type", "TEXT"); - EnsureColumn("file_issues", "origin", "TEXT"); - EnsureColumn("file_issues", "severity", "TEXT"); - EnsureColumn("symbols", "is_metadata_target", "INTEGER"); - EnsureColumn("symbols", "metadata_target_source", "TEXT"); - var rebuildsSymbolReferences = !ColumnIsNotNull("symbol_references", "file_id"); - EnsureColumn( - "symbol_references", - "reference_line_id", - rebuildsSymbolReferences ? "INTEGER" : "INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL"); - // #86: Unicode-aware folded name columns for `--exact` name matching across all - // `--exact` command variants. Populated by the writer via NameFold.Fold; NULL on - // legacy rows until a full reindex, in which case the reader falls back to the - // COLLATE NOCASE path (correct for ASCII, misses non-ASCII casing — #86 fix). - // #86: --exact 用の Unicode 折り畳み列。レガシー行は NULL のまま、再 index で埋まる。 - EnsureColumn("symbols", "name_folded", "TEXT"); - EnsureColumn("symbol_references", "symbol_name_folded", "TEXT"); - EnsureColumn("symbol_references", "container_name_folded", "TEXT"); - EnsureColumn("symbol_references", "is_self_reference", "INTEGER NOT NULL DEFAULT 0"); - EnsureColumn("symbol_references", "is_mutual_recursion", "INTEGER NOT NULL DEFAULT 0"); - EnsureColumn("symbol_references", "source_symbol_id", "INTEGER"); - EnsureColumn("symbol_references", "target_symbol_id", "INTEGER"); - EnsureColumn("symbol_references", "target_symbol_key", "TEXT"); - EnsureColumn("symbol_references", "target_qualifier", "TEXT"); - EnsureColumn("symbol_references", "resolution_state", "TEXT"); - EnsureColumn("symbol_references", "resolution_candidate_count", "INTEGER NOT NULL DEFAULT 0"); - foreach (var indexSql in HotspotReferenceAggregateSql.CreateIndexSql) - Execute(indexSql); - if (backfillHotspotReferenceCounts) - { - Execute(HotspotReferenceAggregateSql.BuildRefreshSql(singleFile: false)); - MarkHotspotReferenceAggregateReady(); - } - EnforceRequiredFileIdConstraints(); - EnforceReferenceLineSetNullConstraint(); - EnsureReferenceLinesContextKey(); - EnsureKindCheckConstraintsCurrent(); - Execute(@" - CREATE TABLE IF NOT EXISTS symbol_reference_candidates ( - reference_id INTEGER NOT NULL, - symbol_id INTEGER NOT NULL, - scope_rank INTEGER NOT NULL, - PRIMARY KEY(reference_id, symbol_id) - )"); - - // Indexes / インデックス - Execute("CREATE INDEX IF NOT EXISTS idx_files_lang ON files(lang)"); - Execute("CREATE INDEX IF NOT EXISTS idx_files_modified ON files(modified)"); - Execute("CREATE INDEX IF NOT EXISTS idx_files_generated ON files(generated)"); - Execute("CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)"); - Execute("CREATE INDEX IF NOT EXISTS idx_files_path_nocase ON files(path COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_file_issues_file_kind ON file_issues(file_id, kind)"); - // The UNIQUE path constraint supplies the BINARY exact index. The separate - // NOCASE index is only for bounded ASCII case-alias candidate lookups. - // path の UNIQUE 制約が BINARY exact index を作り、別の NOCASE index は - // bounded ASCII case-alias candidate lookup 専用に使う。 - Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_end_start_nonnull ON chunks(file_id, end_line, start_line, chunk_index) WHERE content IS NOT NULL"); - Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_start_chunk_nonnull ON chunks(file_id, start_line, chunk_index, end_line) WHERE content IS NOT NULL"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)"); - // Case-insensitive exact-match index for `symbols --exact` (and MCP `symbols` exact=true). - // Without this, `name = @q COLLATE NOCASE` falls back to a full symbols scan per query name, - // which on multi-name exact lookups becomes O(names × symbols). - // `symbols --exact` 用の大文字小文字無視 index。無いと multi-name exact でフルスキャンが N 回走る。 - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_nocase ON symbols(name COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_start ON symbols(start_line)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name ON symbol_references(symbol_name)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_file ON symbol_references(file_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container ON symbol_references(container_name)"); - // Compound indexes for common query patterns / よくあるクエリパターン用の複合インデックス - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_kind ON symbols(file_id, kind)"); - Execute("CREATE INDEX IF NOT EXISTS idx_files_lang_modified ON files(lang, modified)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_kind ON symbol_references(container_name, reference_kind)"); - // Indexes for new query patterns: --kind filter, visibility ranking, hotspot/unused analysis - // 新しいクエリパターン用: --kind フィルタ、可視性ランキング、ホットスポット/未使用分析 - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_visibility ON symbols(visibility)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_kind ON symbol_references(symbol_name, reference_kind)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_file ON symbol_references(symbol_name, file_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_mutual_folded ON symbol_references(container_name_folded, symbol_name_folded, reference_kind, is_self_reference)"); - Execute("CREATE INDEX IF NOT EXISTS idx_reference_lines_file_line ON reference_lines(file_id, line)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_reference_line ON symbol_references(reference_line_id)"); - // Case-insensitive exact-match indexes for `references --exact` / `callers --exact` / `callees --exact` (#83). - // Mirror idx_symbols_name_nocase so `= @q COLLATE NOCASE` stays O(log n) per name across graph commands. - // `references / callers / callees --exact` 用の NOCASE index。idx_symbols_name_nocase と対になる。 - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase ON symbol_references(symbol_name COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_nocase ON symbol_references(container_name COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase_kind ON symbol_references(symbol_name COLLATE NOCASE, reference_kind)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase_file ON symbol_references(symbol_name COLLATE NOCASE, file_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_nocase_kind ON symbol_references(container_name COLLATE NOCASE, reference_kind)"); - // #86: Indexes on the Unicode-folded columns. Used when FoldReadyFlag is set on the - // DB (= the write path filled every folded column). Legacy / partial DBs keep using - // the NOCASE indexes above. Both sets coexist so mixed-state DBs cannot regress. - // #86: 折り畳み列のインデックス。FoldReadyFlag が立っている DB でだけ使う。 - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded ON symbols(name_folded)"); - // Reference-source and ranked-candidate resolution repeatedly combines the folded - // symbol name with file or container scope. Keep those probes bounded for every - // indexed language, including the NOCASE fallback used by partially migrated DBs. - // 参照元・rank 候補解決は folded 名と file/container scope を繰り返し組み合わせる。 - // 全言語と部分 migration DB の NOCASE fallback を複合 index で bounded に保つ。 - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_folded ON symbols(file_id, name_folded)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_nocase ON symbols(file_id, name COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded_container_name_nocase ON symbols(name_folded, container_name COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded_container_qualified_name_nocase ON symbols(name_folded, container_qualified_name COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded ON symbol_references(symbol_name_folded)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_name_folded ON symbol_references(container_name_folded)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded_kind ON symbol_references(symbol_name_folded, reference_kind)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded_file ON symbol_references(symbol_name_folded, file_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_name_folded_kind ON symbol_references(container_name_folded, reference_kind)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_source_symbol ON symbol_references(source_symbol_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_target_symbol ON symbol_references(target_symbol_id)"); - // Mutual-recursion refresh probes the reverse of every resolved edge. Restrict the - // covering index to rows that can participate so unresolved references add no write - // or storage cost during ordinary extraction. - // 相互再帰 refresh は解決済み edge ごとに逆辺を探す。参加可能な行だけを covering - // index に含め、通常抽出中の未解決参照には書き込み・容量コストを加えない。 - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_resolved_source_target_kind ON symbol_references(source_symbol_id, target_symbol_id, reference_kind) WHERE source_symbol_id IS NOT NULL AND target_symbol_id IS NOT NULL"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_ref_candidates_symbol ON symbol_reference_candidates(symbol_id, reference_id)"); - - // Full-text search / 全文検索 - Execute(@" - CREATE VIRTUAL TABLE IF NOT EXISTS fts_chunks USING fts5( - content, - content='chunks', - content_rowid='id' - )"); - Execute($@" - CREATE VIRTUAL TABLE IF NOT EXISTS {FtsChunksTrigramTableName} USING fts5( - content, - content='chunks', - content_rowid='id', - tokenize='trigram' - )"); - if (_rebuildFtsAfterSchemaMigration) - { - Execute("INSERT INTO fts_chunks(fts_chunks) VALUES('rebuild')"); - _rebuildFtsAfterSchemaMigration = false; - } - if (_rebuildTrigramFtsAfterSchemaMigration) - { - Execute($"INSERT INTO {FtsChunksTrigramTableName}({FtsChunksTrigramTableName}) VALUES('rebuild')"); - _rebuildTrigramFtsAfterSchemaMigration = false; - } - - // FTS5 content-synced triggers — keep both FTS indexes in sync with chunks. - // Without these, CASCADE DELETEs on chunks leave orphan entries in fts_chunks. - // FTS5 content-synced トリガー — 両方の FTS index を chunks と同期する。 - // これがないと chunks の CASCADE DELETE で FTS に孤立エントリが残る。 - Execute(CreateAllFtsChunksSyncTriggersSql); - // Keep MCP resources/list cursors tied to the exact indexed-file snapshot. - // MCP resources/list カーソルをインデックス済みファイルのスナップショットに結び付ける。 - Execute(EnsureResourceListGenerationSql); - Execute(CreateResourceListGenerationInsertTriggerSql); - Execute(CreateResourceListGenerationDeleteTriggerSql); - Execute(CreateResourceListGenerationUpdateTriggerSql); - transaction.Commit(); - } - finally - { - _activeMigrationTransaction = null; - _migrationTransactionOwnership = MigrationTransactionOwnership.None; - } - } - finally - { - try - { - transaction?.Dispose(); - } - finally - { - Execute($"PRAGMA legacy_alter_table={legacyAlterTable}"); - } - } - } - - private void EnforceRequiredFileIdConstraints() - { - var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); - var legacyAlterTable = ExecuteScalar("PRAGMA legacy_alter_table"); - RunWithForeignKeysDisabledForMigration( - "EnforceRequiredFileIdConstraints", - () => EnforceRequiredFileIdConstraintsCore(symbolKindCheck, legacyAlterTable)); - } - - private void EnforceRequiredFileIdConstraintsCore(string symbolKindCheck, string legacyAlterTable) - { - try - { - Execute("PRAGMA legacy_alter_table=ON"); - RebuildTableWithRequiredFileId( - "chunks", - """ - CREATE TABLE chunks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - chunk_index INTEGER NOT NULL, - start_line INTEGER, - end_line INTEGER, - content TEXT, - UNIQUE(file_id, chunk_index) - ) - """, - "id, file_id, chunk_index, start_line, end_line, content"); - RebuildTableWithRequiredFileId( - "symbols", - $""" - CREATE TABLE symbols ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - kind TEXT CHECK (kind IN ({symbolKindCheck})), - sub_kind TEXT, - name TEXT, - line INTEGER, - start_line INTEGER, - start_column INTEGER, - end_line INTEGER, - body_start_line INTEGER, - body_end_line INTEGER, - signature TEXT, - container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), - container_name TEXT, - container_qualified_name TEXT, - family_key TEXT, - visibility TEXT, - return_type TEXT, - is_metadata_target INTEGER, - metadata_target_source TEXT, - name_folded TEXT - ) - """, - "id, file_id, kind, sub_kind, name, line, start_line, start_column, end_line, body_start_line, body_end_line, signature, container_kind, container_name, container_qualified_name, family_key, visibility, return_type, is_metadata_target, metadata_target_source, name_folded"); - RebuildReferenceLineTablesWithRequiredFileId(); - RebuildTableWithRequiredFileId( - "file_issues", - """ - CREATE TABLE file_issues ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - kind TEXT NOT NULL, - line INTEGER NOT NULL DEFAULT 0, - message TEXT NOT NULL, - origin TEXT, - severity TEXT - ) - """, - "id, file_id, kind, line, message, origin, severity"); - } - finally - { - Execute($"PRAGMA legacy_alter_table={legacyAlterTable}"); - } - } - - private void RebuildReferenceLineTablesWithRequiredFileId() - { - if (ColumnIsNotNull("reference_lines", "file_id") && - ColumnIsNotNull("symbol_references", "file_id")) - { - return; - } - - var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); - var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); - const string referenceLinesCreateSql = - """ - CREATE TABLE reference_lines ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - line INTEGER NOT NULL, - context TEXT NOT NULL, - UNIQUE(file_id, line, context) - ) - """; - const string referenceLinesColumns = "id, file_id, line, context"; - var symbolReferencesCreateSql = - $""" - CREATE TABLE symbol_references ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - symbol_name TEXT, - reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), - line INTEGER, - column_number INTEGER, - context TEXT, - reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, - container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), - container_name TEXT, - symbol_name_folded TEXT, - container_name_folded TEXT, - is_self_reference INTEGER NOT NULL DEFAULT 0, - is_mutual_recursion INTEGER NOT NULL DEFAULT 0, - source_symbol_id INTEGER, - target_symbol_id INTEGER, - target_symbol_key TEXT, - target_qualifier TEXT, - resolution_state TEXT, - resolution_candidate_count INTEGER NOT NULL DEFAULT 0 - ) - """; - const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; - - const string oldReferenceLines = "_reference_lines_nullable_file_id"; - const string oldSymbolReferences = "_symbol_references_nullable_file_id"; - var quotedOldReferenceLines = SqliteIdentifier.Quote(oldReferenceLines); - var quotedOldSymbolReferences = SqliteIdentifier.Quote(oldSymbolReferences); - Execute($"DROP TABLE IF EXISTS {quotedOldSymbolReferences}"); - Execute($"DROP TABLE IF EXISTS {quotedOldReferenceLines}"); - Execute("DELETE FROM symbol_references WHERE file_id IS NULL"); - Execute("DELETE FROM reference_lines WHERE file_id IS NULL"); - Execute($"ALTER TABLE symbol_references RENAME TO {quotedOldSymbolReferences}"); - Execute($"ALTER TABLE reference_lines RENAME TO {quotedOldReferenceLines}"); - Execute(referenceLinesCreateSql); - Execute($"INSERT INTO reference_lines ({referenceLinesColumns}) SELECT {referenceLinesColumns} FROM {quotedOldReferenceLines}"); - Execute(symbolReferencesCreateSql); - Execute($"INSERT INTO symbol_references ({symbolReferencesColumns}) SELECT {symbolReferencesColumns} FROM {quotedOldSymbolReferences}"); - Execute($"DROP TABLE {quotedOldSymbolReferences}"); - Execute($"DROP TABLE {quotedOldReferenceLines}"); - } - - private void EnforceReferenceLineSetNullConstraint() - { - if (SymbolReferencesReferenceLineDeletesSetNull()) - return; - - var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); - var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); - var symbolReferencesCreateSql = - $""" - CREATE TABLE symbol_references ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - symbol_name TEXT, - reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), - line INTEGER, - column_number INTEGER, - context TEXT, - reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, - container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), - container_name TEXT, - symbol_name_folded TEXT, - container_name_folded TEXT, - is_self_reference INTEGER NOT NULL DEFAULT 0, - is_mutual_recursion INTEGER NOT NULL DEFAULT 0, - source_symbol_id INTEGER, - target_symbol_id INTEGER, - target_symbol_key TEXT, - target_qualifier TEXT, - resolution_state TEXT, - resolution_candidate_count INTEGER NOT NULL DEFAULT 0 - ) - """; - const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; - const string oldSymbolReferences = "_symbol_references_reference_line_delete"; - var quotedOldSymbolReferences = SqliteIdentifier.Quote(oldSymbolReferences); - - Execute($"DROP TABLE IF EXISTS {quotedOldSymbolReferences}"); - Execute(@" - UPDATE symbol_references - SET reference_line_id = NULL - WHERE reference_line_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM reference_lines - WHERE reference_lines.id = symbol_references.reference_line_id - )"); - Execute($"ALTER TABLE symbol_references RENAME TO {quotedOldSymbolReferences}"); - Execute(symbolReferencesCreateSql); - Execute($"INSERT INTO symbol_references ({symbolReferencesColumns}) SELECT {symbolReferencesColumns} FROM {quotedOldSymbolReferences}"); - Execute($"DROP TABLE {quotedOldSymbolReferences}"); - } - - private bool SymbolReferencesReferenceLineDeletesSetNull() - { - using var cmd = _connection.CreateCommand(); - if (_activeMigrationTransaction != null) - cmd.Transaction = _activeMigrationTransaction; - cmd.CommandText = "PRAGMA foreign_key_list('symbol_references')"; - - using var reader = cmd.ExecuteTrackedReader(); - while (reader.TrackedRead()) - { - var table = reader.GetString(2); - var from = reader.GetString(3); - var onDelete = reader.GetString(6); - if (string.Equals(table, "reference_lines", StringComparison.OrdinalIgnoreCase) - && string.Equals(from, "reference_line_id", StringComparison.OrdinalIgnoreCase)) - { - return string.Equals(onDelete, "SET NULL", StringComparison.OrdinalIgnoreCase); - } - } - - return false; - } - - private void EnsureReferenceLinesContextKey() - { - if (ReferenceLinesHasContextUniqueKey()) - return; - - var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); - var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); - const string referenceLinesCreateSql = - """ - CREATE TABLE reference_lines ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - line INTEGER NOT NULL, - context TEXT NOT NULL, - UNIQUE(file_id, line, context) - ) - """; - const string referenceLinesColumns = "id, file_id, line, context"; - var symbolReferencesCreateSql = - $""" - CREATE TABLE symbol_references ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - symbol_name TEXT, - reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), - line INTEGER, - column_number INTEGER, - context TEXT, - reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, - container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), - container_name TEXT, - symbol_name_folded TEXT, - container_name_folded TEXT, - is_self_reference INTEGER NOT NULL DEFAULT 0, - is_mutual_recursion INTEGER NOT NULL DEFAULT 0, - source_symbol_id INTEGER, - target_symbol_id INTEGER, - target_symbol_key TEXT, - target_qualifier TEXT, - resolution_state TEXT, - resolution_candidate_count INTEGER NOT NULL DEFAULT 0 - ) - """; - const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; - - const string oldReferenceLines = "_reference_lines_file_line_key"; - const string oldSymbolReferences = "_symbol_references_file_line_key"; - var quotedOldReferenceLines = SqliteIdentifier.Quote(oldReferenceLines); - var quotedOldSymbolReferences = SqliteIdentifier.Quote(oldSymbolReferences); - RunWithForeignKeysDisabledForMigration("EnsureReferenceLinesContextKey", () => - { - Execute($"DROP TABLE IF EXISTS {quotedOldSymbolReferences}"); - Execute($"DROP TABLE IF EXISTS {quotedOldReferenceLines}"); - Execute($"ALTER TABLE symbol_references RENAME TO {quotedOldSymbolReferences}"); - Execute($"ALTER TABLE reference_lines RENAME TO {quotedOldReferenceLines}"); - Execute(referenceLinesCreateSql); - Execute($"INSERT INTO reference_lines ({referenceLinesColumns}) SELECT {referenceLinesColumns} FROM {quotedOldReferenceLines}"); - Execute(symbolReferencesCreateSql); - Execute($"INSERT INTO symbol_references ({symbolReferencesColumns}) SELECT {symbolReferencesColumns} FROM {quotedOldSymbolReferences}"); - Execute($"DROP TABLE {quotedOldSymbolReferences}"); - Execute($"DROP TABLE {quotedOldReferenceLines}"); - InvokeForeignKeyValidationBeforeCheckForTesting("reference_lines_context_key"); - }); - - ValidateForeignKeysAfterMigration("reference_lines_context_key"); - _schemaCache?.Refresh(); - } - - private bool ReferenceLinesHasContextUniqueKey() - { - using var listCmd = SqliteConnectionPolicy.CreateCommand(_connection); - listCmd.CommandText = "PRAGMA index_list('reference_lines')"; - using var indexReader = listCmd.ExecuteReader(); - var indexNames = new List(); - while (indexReader.Read()) - { - var isUnique = indexReader.GetInt32(2) == 1; - if (isUnique) - indexNames.Add(indexReader.GetString(1)); - } - - foreach (var indexName in indexNames) - { - using var infoCmd = SqliteConnectionPolicy.CreateCommand(_connection); - infoCmd.CommandText = $"PRAGMA index_info('{indexName.Replace("'", "''")}')"; - using var infoReader = infoCmd.ExecuteReader(); - var columns = new List(); - while (infoReader.Read()) - columns.Add(infoReader.GetString(2)); - - if (columns.SequenceEqual(["file_id", "line", "context"], StringComparer.Ordinal)) - return true; - } - - return false; - } - - private void EnsureKindCheckConstraintsCurrent() - { - var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); - var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); - var symbolsCreateSql = - $""" - CREATE TABLE symbols ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - kind TEXT CHECK (kind IN ({symbolKindCheck})), - sub_kind TEXT, - name TEXT, - line INTEGER, - start_line INTEGER, - start_column INTEGER, - end_line INTEGER, - body_start_line INTEGER, - body_end_line INTEGER, - signature TEXT, - container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), - container_name TEXT, - container_qualified_name TEXT, - family_key TEXT, - visibility TEXT, - return_type TEXT, - is_metadata_target INTEGER, - metadata_target_source TEXT, - name_folded TEXT - ) - """; - const string symbolsColumns = "id, file_id, kind, sub_kind, name, line, start_line, start_column, end_line, body_start_line, body_end_line, signature, container_kind, container_name, container_qualified_name, family_key, visibility, return_type, is_metadata_target, metadata_target_source, name_folded"; - var symbolReferencesCreateSql = - $""" - CREATE TABLE symbol_references ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - symbol_name TEXT, - reference_kind TEXT CHECK (reference_kind IN ({referenceKindCheck})), - line INTEGER, - column_number INTEGER, - context TEXT, - reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, - container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ({symbolKindCheck})), - container_name TEXT, - symbol_name_folded TEXT, - container_name_folded TEXT, - is_self_reference INTEGER NOT NULL DEFAULT 0, - is_mutual_recursion INTEGER NOT NULL DEFAULT 0, - source_symbol_id INTEGER, - target_symbol_id INTEGER, - target_symbol_key TEXT, - target_qualifier TEXT, - resolution_state TEXT, - resolution_candidate_count INTEGER NOT NULL DEFAULT 0 - ) - """; - const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion, source_symbol_id, target_symbol_id, target_symbol_key, target_qualifier, resolution_state, resolution_candidate_count"; - - var rebuilt = false; - RunWithForeignKeysDisabledForMigration("EnsureKindCheckConstraintsCurrent", () => - { - if (!TableCheckContainsAll("symbols", SymbolKindCatalog.SymbolKinds)) - { - RebuildTableWithCurrentKindChecks("symbols", "_symbols_kind_check", symbolsCreateSql, symbolsColumns); - rebuilt = true; - } - - if (!TableCheckContainsAll("symbol_references", SymbolKindCatalog.SymbolKinds.Concat(SymbolKindCatalog.ReferenceKinds))) - { - RebuildTableWithCurrentKindChecks("symbol_references", "_symbol_references_kind_check", symbolReferencesCreateSql, symbolReferencesColumns); - rebuilt = true; - } - - if (rebuilt) - InvokeForeignKeyValidationBeforeCheckForTesting("kind_check_constraints"); - }); - - if (rebuilt) - ValidateForeignKeysAfterMigration("kind_check_constraints"); - } - - private void RunWithForeignKeysDisabledForMigration(string operation, Action action) - { - if (IsSqliteTransactionActive()) - { - AssertForeignKeyMode(operation, expected: 0); - ForeignKeysDisabledForTesting?.Invoke(operation); - action(); - return; - } - - var foreignKeys = ReadPragmaLong("foreign_keys"); - ExceptionDispatchInfo? operationFailure = null; - try - { - SetForeignKeyModeAndVerify(operation, expected: 0); - ForeignKeysDisabledForTesting?.Invoke(operation); - action(); - } - catch (Exception ex) - { - operationFailure = ExceptionDispatchInfo.Capture(ex); - } - - try - { - ForeignKeysRestoringForTesting?.Invoke(operation, foreignKeys); - SetForeignKeyModeAndVerify(operation, foreignKeys); - } - catch (Exception ex) - { - throw new CodeIndexException( - code: CommandErrorCodes.DbError, - category: CodeIndexExceptionCategory.Database, - message: $"Failed to restore PRAGMA foreign_keys after {operation}.", - path: _connection.DataSource, - hint: "Close other database connections, restore write access if needed, and rerun the command before trusting further migration work.", - innerException: ex); - } - - operationFailure?.Throw(); - } - - private void SetForeignKeyModeAndVerify(string operation, long expected) - { - Execute($"PRAGMA foreign_keys={expected}"); - AssertForeignKeyMode(operation, expected); - } - - private void AssertForeignKeyMode(string operation, long expected) - { - var effective = ReadPragmaLong("foreign_keys"); - if (effective == expected) - return; - - throw new CodeIndexException( - code: CommandErrorCodes.DbError, - category: CodeIndexExceptionCategory.Database, - message: $"PRAGMA foreign_keys remained {effective} while schema migration operation '{operation}' required {expected}.", - path: _connection.DataSource, - hint: "Finish or roll back the external transaction, then rerun the migration on a writable database connection."); - } - - private bool IsSqliteTransactionActive() - => SQLitePCL.raw.sqlite3_get_autocommit(_connection.Handle) == 0; - - private void InvokeForeignKeyValidationBeforeCheckForTesting(string phase) - { - var boundedPhase = DiagnosticRedactor.BoundDiagnosticText(phase, MigrationDiagnosticTextLimit); - ForeignKeyValidationBeforeCheckForTesting?.Invoke(_connection, boundedPhase); - } - - private void ValidateForeignKeysAfterMigration(string phase) - { - var boundedPhase = DiagnosticRedactor.BoundDiagnosticText(phase, MigrationDiagnosticTextLimit); - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - if (_activeMigrationTransaction != null) - cmd.Transaction = _activeMigrationTransaction; - cmd.CommandText = "PRAGMA foreign_key_check"; - - var violations = new List(); - var violationCount = 0; - using var reader = cmd.ExecuteTrackedReader(); - while (reader.TrackedRead()) - { - violationCount++; - if (violations.Count < MigrationForeignKeyViolationSampleLimit) - violations.Add(FormatForeignKeyViolation(reader)); - } - - if (violationCount == 0) - return; - - var sample = string.Join("; ", violations); - var truncated = violationCount > violations.Count - ? $" (showing {violations.Count.ToString(CultureInfo.InvariantCulture)})" - : string.Empty; - throw new CodeIndexException( - code: CommandErrorCodes.DbIntegrityFailed, - category: CodeIndexExceptionCategory.Database, - message: $"Foreign key validation failed after schema migration phase '{boundedPhase}' with {violationCount.ToString(CultureInfo.InvariantCulture)} violation(s){truncated}: {sample}.", - hint: "Run `cdidx db --integrity-check --db ` and rebuild the index on writable storage if violations persist."); - } - - private static string FormatForeignKeyViolation(SqliteDataReader reader) - { - var table = FormatForeignKeyCheckValue(reader.IsDBNull(0) ? "" : reader.GetString(0)); - var rowId = reader.IsDBNull(1) - ? "" - : Convert.ToInt64(reader.GetValue(1), CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture); - var parent = FormatForeignKeyCheckValue(reader.IsDBNull(2) ? "" : reader.GetString(2)); - var fkId = reader.IsDBNull(3) - ? "" - : Convert.ToInt64(reader.GetValue(3), CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture); - return $"table={table}, rowid={rowId}, parent={parent}, fkid={fkId}"; - } - - private static string FormatForeignKeyCheckValue(string value) - => DiagnosticRedactor.BoundDiagnosticText( - DiagnosticRedactor.RedactSensitiveText(value, redactPaths: true), - MigrationDiagnosticTextLimit); - - private bool TableCheckContainsAll(string tableName, IEnumerable allowedValues) - { - var createSql = GetTableCreateSql(tableName); - if (createSql == null) - return true; - - if (!createSql.Contains("CHECK", StringComparison.OrdinalIgnoreCase)) - return true; - - return allowedValues.All(value => createSql.Contains($"'{value.Replace("'", "''")}'", StringComparison.Ordinal)); - } - - private string? GetTableCreateSql(string tableName) - { - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - if (_activeMigrationTransaction != null) - cmd.Transaction = _activeMigrationTransaction; - cmd.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = @table"; - SqliteCommandPolicy.AddText(cmd, "@table", tableName); - return cmd.ExecuteScalar() as string; - } - - private string BuildRebuildSelectProjection(string sourceTableName, string columns) - { - var existingColumns = LoadColumnNames(sourceTableName); - var projectedColumns = columns - .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) - .Select(column => existingColumns.Contains(column) ? column : $"NULL AS {column}"); - return string.Join(", ", projectedColumns); - } - - private HashSet LoadColumnNames(string tableName) - { - var columns = new HashSet(StringComparer.OrdinalIgnoreCase); - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - if (_activeMigrationTransaction != null) - cmd.Transaction = _activeMigrationTransaction; - cmd.CommandText = SqliteCommandPolicy.TableInfoPragmaSql(tableName); - - using var reader = cmd.ExecuteTrackedReader(); - while (reader.TrackedRead()) - columns.Add(reader.GetString(1)); - return columns; - } - - private void RebuildTableWithCurrentKindChecks(string tableName, string oldTableName, string createSql, string columns) - { - var quotedTableName = SqliteIdentifier.Quote(tableName); - var quotedOldTableName = SqliteIdentifier.Quote(oldTableName); - Execute($"DROP TABLE IF EXISTS {quotedOldTableName}"); - Execute($"ALTER TABLE {quotedTableName} RENAME TO {quotedOldTableName}"); - Execute(createSql); - var sourceColumns = BuildRebuildSelectProjection(oldTableName, columns); - Execute($"INSERT INTO {quotedTableName} ({columns}) SELECT {sourceColumns} FROM {quotedOldTableName}"); - Execute($"DROP TABLE {quotedOldTableName}"); - } - - private void RebuildTableWithRequiredFileId(string tableName, string createSql, string columns) - { - if (ColumnIsNotNull(tableName, "file_id")) - return; - - var oldTableName = $"_{tableName}_nullable_file_id"; - var quotedTableName = SqliteIdentifier.Quote(tableName); - var quotedOldTableName = SqliteIdentifier.Quote(oldTableName); - Execute($"DROP TABLE IF EXISTS {quotedOldTableName}"); - Execute(DropAllFtsChunksSyncTriggersSql); - if (string.Equals(tableName, "chunks", StringComparison.Ordinal)) - { - Execute("DROP TABLE IF EXISTS fts_chunks"); - Execute($"DROP TABLE IF EXISTS {FtsChunksTrigramTableName}"); - _rebuildFtsAfterSchemaMigration = true; - _rebuildTrigramFtsAfterSchemaMigration = true; - } - Execute($"DELETE FROM {quotedTableName} WHERE file_id IS NULL"); - Execute($"ALTER TABLE {quotedTableName} RENAME TO {quotedOldTableName}"); - Execute(createSql); - var sourceColumns = BuildRebuildSelectProjection(oldTableName, columns); - Execute($"INSERT INTO {quotedTableName} ({columns}) SELECT {sourceColumns} FROM {quotedOldTableName}"); - if (!string.Equals(tableName, "reference_lines", StringComparison.Ordinal)) - Execute($"DROP TABLE {quotedOldTableName}"); - } - - private bool ColumnIsNotNull(string tableName, string columnName) - { - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - if (_activeMigrationTransaction != null) - cmd.Transaction = _activeMigrationTransaction; - cmd.CommandText = SqliteCommandPolicy.TableInfoPragmaSql(tableName); - - using var reader = cmd.ExecuteTrackedReader(); - while (reader.TrackedRead()) - { - if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase)) - return reader.GetInt32(3) != 0; - } - return false; - } - - /// - /// Delete all data for a full rebuild. - /// 全データを削除して完全再構築する。 - /// - public void DropAll() - { - // A rebuild that produces zero files must still invalidate an outstanding resource cursor. - // 0 件になる rebuild でも既存の resource cursor を必ず無効化する。 - // A fresh database has no cursor to invalidate and creates this table after DropAll. - // fresh database には無効化対象がなく、この table は DropAll 後に作成される。 - if (TableExists("codeindex_meta")) - Execute(IncrementResourceListGenerationSql); - Execute(DropAllFtsChunksSyncTriggersSql); - Execute($"DROP TABLE IF EXISTS {FtsChunksTrigramTableName}"); - Execute("DROP TABLE IF EXISTS fts_chunks"); - Execute("DROP TABLE IF EXISTS file_issues"); - Execute("DROP TABLE IF EXISTS hotspot_reference_counts"); - Execute("DROP TABLE IF EXISTS symbol_references"); - Execute("DROP TABLE IF EXISTS reference_lines"); - Execute("DROP TABLE IF EXISTS symbols"); - Execute("DROP TABLE IF EXISTS chunks"); - Execute("DROP TABLE IF EXISTS files"); - _schemaCache?.Refresh(); - } - - private void Execute(string sql) - { - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - if (_activeMigrationTransaction != null) - cmd.Transaction = _activeMigrationTransaction; - cmd.CommandText = sql; - using var cancellationRegistration = _cancellation.CanBeCanceled - ? _cancellation.UnsafeRegister( - static state => SQLitePCL.raw.sqlite3_interrupt(((SqliteConnection)state!).Handle), - _connection) - : default; - _cancellation.ThrowIfCancellationRequested(); - try - { - cmd.ExecuteNonQuery(); - } - catch (SqliteException exception) when ( - _cancellation.IsCancellationRequested && exception.SqliteErrorCode == 9) - { - throw new OperationCanceledException( - "SQLite schema or aggregate maintenance was interrupted.", - exception, - _cancellation); - } - _cancellation.ThrowIfCancellationRequested(); - MarkWriteWork(walCheckpointable: false); - } - - private void EnsureForeignKeysEnabled() - { - Execute("PRAGMA foreign_keys=ON"); - var fkResult = ExecuteScalar("PRAGMA foreign_keys"); - if (fkResult != "1") - CommandErrorWriter.WriteStderr("Warning: foreign_keys pragma not enabled"); - } - - /// - /// Latest opportunistic-migration failure captured by . - /// Null when the most recent migration attempt completed every step (or was skipped on a - /// read-only connection). Callers can surface this to explain a later "no such column" - /// error coming out of a read path. - /// 直前の 実行で発生した部分マイグレーション失敗の情報。 - /// 全ステップ完了時、または読み取り専用接続でスキップされた場合は null。 - /// - public DbMigrationFailure? LastMigrationFailure { get; private set; } - - /// - /// Attempt opportunistic schema migration for read-only query paths. - /// Failures are captured on and a single - /// actionable warning is written to so a later - /// "no such column" error can be tied back to the failing migration step. - /// 読み取り専用クエリパス向けの機会的スキーマ移行を試みる。 - /// 失敗時は に記録し、stderr に 1 行の警告を出す。 - /// - public void TryMigrateForRead() - { - // Skip migration entirely on read-only connections. Even CREATE TABLE IF NOT EXISTS - // fails with SQLITE_CANTOPEN on sandboxes that cannot create -journal side files — - // previously only SQLITE_READONLY was caught, so the normal --db /path flow threw - // on restricted mounts even after the constructor had already degraded to read-only. - // read-only 接続ではマイグレーション DDL 自体を走らせない。CANTOPEN が漏れて落ちるため。 - if (_isReadOnly) return; - - LastMigrationFailure = null; - if (ReadMigrationSchemaIsCurrent()) - return; - - try - { - if (IsSqliteTransactionActive()) - { - RunReadMigrationSteps(MigrationTransactionOwnership.External); - return; - } - - EnsureForeignKeysEnabled(); - SqliteTransaction transaction; - try - { - transaction = ReadMigrationTransactionFactoryForTesting?.Invoke(_connection) - ?? _connection.BeginTransaction(deferred: false); - } - catch (SqliteException ex) when (IsReadOnlyOpenError(ex, _connection.DataSource)) - { - RecordMigrationFailure("BEGIN IMMEDIATE schema migration", ex); - return; - } - - using (transaction) - { - _activeMigrationTransaction = transaction; - try - { - if (!RunReadMigrationSteps(MigrationTransactionOwnership.Owned)) - return; - transaction.Commit(); - } - finally - { - _activeMigrationTransaction = null; - } - } - - EnsureForeignKeysEnabled(); - } - finally - { - _activeMigrationTransaction = null; - // Migration may have added columns or indexes the schema cache had already - // resolved as missing; drop the cache so the next DbReader sees the new shape. - // マイグレーションで列・index が追加された可能性があるためキャッシュを破棄する。 - _schemaCache?.Refresh(); - } - } - - private bool RunReadMigrationSteps(MigrationTransactionOwnership ownership) - { - if (ownership == MigrationTransactionOwnership.None) - throw new InvalidOperationException("Read migration transaction ownership must be explicit."); - - var previousOwnership = _migrationTransactionOwnership; - _migrationTransactionOwnership = ownership; - try - { - foreach (var (description, action) in BuildReadMigrationSteps()) - { - try - { - action(); - } - catch (SqliteException ex) - { - RecordMigrationFailure(description, ex); - - // Read-only DB / filesystem / sandbox — stop further steps and degrade. - // Catches SQLITE_READONLY (8) and compatible SQLITE_CANTOPEN (14): - // some restricted environments report CANTOPEN when SQLite tries to create - // -journal side files for the DDL. DbReader.LoadColumns() / table-detection - // will drive the degraded read path; later read queries that hit a still- - // missing column will now have a single clear preceding diagnostic to refer to. - // 読み取り専用 DB・FS・サンドボックスでの DDL 失敗は縮退扱いで打ち切る。 - if (IsReadOnlyOpenError(ex, _connection.DataSource)) return false; - - // Other SQLite errors (e.g. corruption, full disk) are not opportunistic- - // migration concerns — preserve the existing surface-the-exception behavior. - // それ以外の SQLite エラーは従来通り上位に伝播させる。 - throw; - } - } - - return true; - } - finally - { - _migrationTransactionOwnership = previousOwnership; - } - } - - private void RecordMigrationFailure(string description, SqliteException exception) - { - var failure = new DbMigrationFailure( - description, - exception.SqliteErrorCode, - FormatMigrationSqliteMessage(exception), - BuildMigrationSuggestedAction(exception.SqliteErrorCode)); - LastMigrationFailure = failure; - EmitMigrationFailureWarning(failure); - } - - private IEnumerable<(string Description, Action Action)> BuildReadMigrationSteps() - { - // The order here matches the legacy inline migration: tables before the columns and - // indexes that reference them, and fold columns before the folded indexes (#86). - // 並び順は legacy インラインマイグレーションと同じ。テーブル→列→index、fold 列→folded index。 - yield return ("CREATE INDEX bounded resource read chunk indexes", EnsureBoundedResourceReadChunkIndexes); - yield return ("CREATE TABLE reference_lines", () => Execute(@" - CREATE TABLE IF NOT EXISTS reference_lines ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - line INTEGER NOT NULL, - context TEXT NOT NULL, - UNIQUE(file_id, line, context) - )")); - yield return ("CREATE TABLE symbol_references", () => Execute(@" - CREATE TABLE IF NOT EXISTS symbol_references ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - symbol_name TEXT, - reference_kind TEXT, - line INTEGER, - column_number INTEGER, - context TEXT, - reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, - container_kind TEXT, - container_name TEXT, - is_self_reference INTEGER NOT NULL DEFAULT 0, - is_mutual_recursion INTEGER NOT NULL DEFAULT 0 - )")); - yield return ("EnsureColumn symbol_references.reference_line_id", - () => EnsureColumn("symbol_references", "reference_line_id", "INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL")); - yield return ("EnsureColumn symbol_references.is_self_reference", - () => EnsureColumn("symbol_references", "is_self_reference", "INTEGER NOT NULL DEFAULT 0")); - yield return ("EnsureColumn symbol_references.is_mutual_recursion", - () => EnsureColumn("symbol_references", "is_mutual_recursion", "INTEGER NOT NULL DEFAULT 0")); - yield return ("CREATE TABLE hotspot_reference_counts", - () => Execute(HotspotReferenceAggregateSql.CreateTableSql)); - foreach (var indexSql in HotspotReferenceAggregateSql.CreateIndexSql) - yield return ("CREATE INDEX hotspot_reference_counts", () => Execute(indexSql)); - yield return ("EnsureColumn symbol_references.source_symbol_id", - () => EnsureColumn("symbol_references", "source_symbol_id", "INTEGER")); - yield return ("EnsureColumn symbol_references.target_symbol_id", - () => EnsureColumn("symbol_references", "target_symbol_id", "INTEGER")); - yield return ("EnsureColumn symbol_references.target_symbol_key", - () => EnsureColumn("symbol_references", "target_symbol_key", "TEXT")); - yield return ("EnsureColumn symbol_references.target_qualifier", - () => EnsureColumn("symbol_references", "target_qualifier", "TEXT")); - yield return ("EnsureColumn symbol_references.resolution_state", - () => EnsureColumn("symbol_references", "resolution_state", "TEXT")); - yield return ("EnsureColumn symbol_references.resolution_candidate_count", - () => EnsureColumn("symbol_references", "resolution_candidate_count", "INTEGER NOT NULL DEFAULT 0")); - yield return ("CREATE TABLE symbol_reference_candidates", () => Execute(@" - CREATE TABLE IF NOT EXISTS symbol_reference_candidates ( - reference_id INTEGER NOT NULL, - symbol_id INTEGER NOT NULL, - scope_rank INTEGER NOT NULL, - PRIMARY KEY(reference_id, symbol_id) - )")); - yield return ("CREATE INDEX idx_symbol_refs_name", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name ON symbol_references(symbol_name)")); - yield return ("CREATE INDEX idx_symbol_refs_file", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_file ON symbol_references(file_id)")); - yield return ("CREATE INDEX idx_symbol_refs_container", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container ON symbol_references(container_name)")); - yield return ("CREATE INDEX idx_symbol_refs_container_kind", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_kind ON symbol_references(container_name, reference_kind)")); - yield return ("CREATE INDEX idx_symbol_refs_name_kind", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_kind ON symbol_references(symbol_name, reference_kind)")); - yield return ("CREATE INDEX idx_symbol_refs_name_file", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_file ON symbol_references(symbol_name, file_id)")); - yield return ("CREATE INDEX idx_reference_lines_file_line", - () => Execute("CREATE INDEX IF NOT EXISTS idx_reference_lines_file_line ON reference_lines(file_id, line)")); - yield return ("CREATE INDEX idx_symbol_refs_reference_line", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_reference_line ON symbol_references(reference_line_id)")); - yield return ("CREATE INDEX idx_symbol_refs_name_nocase", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase ON symbol_references(symbol_name COLLATE NOCASE)")); - yield return ("CREATE INDEX idx_symbol_refs_container_nocase", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_nocase ON symbol_references(container_name COLLATE NOCASE)")); - yield return ("CREATE INDEX idx_symbol_refs_name_nocase_kind", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase_kind ON symbol_references(symbol_name COLLATE NOCASE, reference_kind)")); - yield return ("CREATE INDEX idx_symbol_refs_name_nocase_file", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase_file ON symbol_references(symbol_name COLLATE NOCASE, file_id)")); - yield return ("CREATE INDEX idx_symbol_refs_container_nocase_kind", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_nocase_kind ON symbol_references(container_name COLLATE NOCASE, reference_kind)")); - yield return ("CREATE INDEX idx_symbol_refs_source_symbol", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_source_symbol ON symbol_references(source_symbol_id)")); - yield return ("CREATE INDEX idx_symbol_refs_target_symbol", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_target_symbol ON symbol_references(target_symbol_id)")); - yield return ("CREATE INDEX idx_symbol_refs_resolved_source_target_kind", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_resolved_source_target_kind ON symbol_references(source_symbol_id, target_symbol_id, reference_kind) WHERE source_symbol_id IS NOT NULL AND target_symbol_id IS NOT NULL")); - yield return ("CREATE INDEX idx_symbol_ref_candidates_symbol", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_ref_candidates_symbol ON symbol_reference_candidates(symbol_id, reference_id)")); - - yield return ("EnsureColumn files.lang", () => EnsureColumn("files", "lang", "TEXT")); - yield return ("EnsureColumn files.checksum", () => EnsureColumn("files", "checksum", "TEXT")); - yield return ("EnsureColumn files.modified", () => EnsureColumn("files", "modified", "DATETIME")); - yield return ("EnsureColumn files.indexed_at", () => EnsureColumn("files", "indexed_at", "DATETIME")); - yield return ("EnsureColumn symbols.start_line", () => EnsureColumn("symbols", "start_line", "INTEGER")); - yield return ("EnsureColumn symbols.end_line", () => EnsureColumn("symbols", "end_line", "INTEGER")); - yield return ("EnsureColumn symbols.body_start_line", () => EnsureColumn("symbols", "body_start_line", "INTEGER")); - yield return ("EnsureColumn symbols.body_end_line", () => EnsureColumn("symbols", "body_end_line", "INTEGER")); - yield return ("EnsureColumn symbols.signature", () => EnsureColumn("symbols", "signature", "TEXT")); - yield return ("EnsureColumn symbols.container_kind", () => EnsureColumn("symbols", "container_kind", "TEXT")); - yield return ("EnsureColumn symbols.container_name", () => EnsureColumn("symbols", "container_name", "TEXT")); - yield return ("EnsureColumn symbols.container_qualified_name", () => EnsureColumn("symbols", "container_qualified_name", "TEXT")); - yield return ("EnsureColumn symbols.family_key", () => EnsureColumn("symbols", "family_key", "TEXT")); - yield return ("EnsureColumn symbols.visibility", () => EnsureColumn("symbols", "visibility", "TEXT")); - yield return ("EnsureColumn symbols.return_type", () => EnsureColumn("symbols", "return_type", "TEXT")); - yield return ("EnsureColumn symbols.is_metadata_target", () => EnsureColumn("symbols", "is_metadata_target", "INTEGER")); - yield return ("EnsureColumn symbols.metadata_target_source", () => EnsureColumn("symbols", "metadata_target_source", "TEXT")); - yield return ("CREATE INDEX idx_symbols_name_nocase", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_nocase ON symbols(name COLLATE NOCASE)")); - - // #86: fold columns must be ensured BEFORE the folded indexes so CREATE INDEX does - // not fail on legacy DBs where the column did not exist yet. - // #86: folded 列を追加してから folded index を作らないと legacy DB でクラッシュする。 - yield return ("EnsureColumn symbols.name_folded", () => EnsureColumn("symbols", "name_folded", "TEXT")); - yield return ("EnsureColumn symbol_references.symbol_name_folded", () => EnsureColumn("symbol_references", "symbol_name_folded", "TEXT")); - yield return ("EnsureColumn symbol_references.container_name_folded", () => EnsureColumn("symbol_references", "container_name_folded", "TEXT")); - yield return ("CREATE INDEX idx_symbols_name_folded", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded ON symbols(name_folded)")); - yield return ("CREATE INDEX idx_symbols_file_name_folded", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_folded ON symbols(file_id, name_folded)")); - yield return ("CREATE INDEX idx_symbols_file_name_nocase", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_nocase ON symbols(file_id, name COLLATE NOCASE)")); - yield return ("CREATE INDEX idx_symbols_name_folded_container_name_nocase", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded_container_name_nocase ON symbols(name_folded, container_name COLLATE NOCASE)")); - yield return ("CREATE INDEX idx_symbols_name_folded_container_qualified_name_nocase", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded_container_qualified_name_nocase ON symbols(name_folded, container_qualified_name COLLATE NOCASE)")); - yield return ("CREATE INDEX idx_symbol_refs_symbol_name_folded", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded ON symbol_references(symbol_name_folded)")); - yield return ("CREATE INDEX idx_symbol_refs_container_name_folded", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_name_folded ON symbol_references(container_name_folded)")); - yield return ("CREATE INDEX idx_symbol_refs_symbol_name_folded_kind", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded_kind ON symbol_references(symbol_name_folded, reference_kind)")); - yield return ("CREATE INDEX idx_symbol_refs_symbol_name_folded_file", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded_file ON symbol_references(symbol_name_folded, file_id)")); - yield return ("CREATE INDEX idx_symbol_refs_container_name_folded_kind", - () => Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_name_folded_kind ON symbol_references(container_name_folded, reference_kind)")); - yield return ("Backfill hotspot_reference_counts", - () => Execute(HotspotReferenceAggregateSql.BuildRefreshSql(singleFile: false))); - yield return ("Stamp hotspot_reference_counts readiness", MarkHotspotReferenceAggregateReady); - - yield return ("CREATE TABLE file_issues", () => Execute(@" - CREATE TABLE IF NOT EXISTS file_issues ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - kind TEXT NOT NULL, - line INTEGER NOT NULL DEFAULT 0, - message TEXT NOT NULL - )")); - yield return ("CREATE TABLE codeindex_meta", () => Execute(@" - CREATE TABLE IF NOT EXISTS codeindex_meta ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT - )")); - yield return ("Initialize resources/list generation", () => Execute(EnsureResourceListGenerationSql)); - yield return ("CREATE TRIGGER files_resource_generation_ai", () => Execute(CreateResourceListGenerationInsertTriggerSql)); - yield return ("CREATE TRIGGER files_resource_generation_ad", () => Execute(CreateResourceListGenerationDeleteTriggerSql)); - yield return ("CREATE TRIGGER files_resource_generation_au", () => Execute(CreateResourceListGenerationUpdateTriggerSql)); - } - - private void EnsureBoundedResourceReadChunkIndexes() - { - if (!TableExists("chunks")) - return; - - Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_end_start_nonnull ON chunks(file_id, end_line, start_line, chunk_index) WHERE content IS NOT NULL"); - Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_start_chunk_nonnull ON chunks(file_id, start_line, chunk_index, end_line) WHERE content IS NOT NULL"); - } - - private bool ReadMigrationSchemaIsCurrent() - { - if ((GetUserVersion() & HotspotReferenceAggregateReadyFlag) == 0) - return false; - - foreach (var table in ReadMigrationRequiredTables) - { - if (!TableExists(table)) - return false; - } - - foreach (var (table, column) in ReadMigrationRequiredColumns) - { - if (!ColumnExists(table, column)) - return false; - } - - foreach (var index in ReadMigrationRequiredIndexes) - { - if (!IndexExists(index)) - return false; - } - - foreach (var trigger in ResourceListGenerationTriggerNames) - { - if (!TriggerExists(trigger)) - return false; - } - - return true; - } - - private string BuildMigrationSuggestedAction(int sqliteErrorCode) - { - // 8 = SQLITE_READONLY, 10 = SQLITE_IOERR, 14 = SQLITE_CANTOPEN: classic restricted- - // mount signatures (network share, sandbox, WORM). Point the user at the same fix - // we already document for the read-only fallback so the message is actionable. - // 8/10/14 は restricted mount 系の典型シグネチャ。書き込み可能な場所での再実行を案内する。 - if (sqliteErrorCode is 8 or 10 or 14) - { - return "Re-run cdidx on writable storage, or grant write access to (for example, chmod +w ), so the schema migration can complete."; - } - - // Unknown SQLite codes — surface the code itself and point at integrity check. - // それ以外の SQLite エラーは integrity_check と error code を案内する。 - return $"Inspect the database with 'sqlite3 \"PRAGMA integrity_check\"' (SQLite error code {sqliteErrorCode})."; - } - - private static string FormatMigrationSqliteMessage(SqliteException exception) - => DiagnosticRedactor.FormatExceptionMessage(exception, MigrationDiagnosticTextLimit); - - private static void EmitMigrationFailureWarning(DbMigrationFailure failure) - { - // Single line so the next read attempt only sees one clear "migration partial" record - // even if multiple commands share the same process / log stream. - // 1 行に集約し、後続 read エラーと混在しても拾いやすい形にする。 - CommandErrorWriter.WriteStderr( - $"Warning: cdidx schema migration step \"{failure.Step}\" failed " + - $"(SQLite error {failure.SqliteErrorCode}: {failure.SqliteMessage.TrimEnd('.')}). " + - "Subsequent read queries may fail with 'no such column' until the migration completes. " + - failure.SuggestedAction); - } - - private void EnsureColumn(string tableName, string columnName, string definition) - { - var quotedTableName = SqliteIdentifier.Quote(tableName); - var quotedColumnName = SqliteIdentifier.Quote(columnName); - if (_migrationTransactionOwnership != MigrationTransactionOwnership.None) - { - DbColumnEnsurer.EnsureColumn( - () => ColumnExists(tableName, columnName), - () => Execute($"ALTER TABLE {quotedTableName} ADD COLUMN {quotedColumnName} {definition}")); - return; - } - - DbColumnEnsurer.EnsureColumn( - () => ColumnExists(tableName, columnName), - beginImmediate: () => Execute("BEGIN IMMEDIATE"), - commit: () => Execute("COMMIT"), - rollback: () => Execute("ROLLBACK"), - () => Execute($"ALTER TABLE {quotedTableName} ADD COLUMN {quotedColumnName} {definition}")); - } - - private bool ColumnExists(string tableName, string columnName) - { - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - if (_activeMigrationTransaction != null) - cmd.Transaction = _activeMigrationTransaction; - cmd.CommandText = SqliteCommandPolicy.TableInfoPragmaSql(tableName); - - using var reader = cmd.ExecuteTrackedReader(); - while (reader.TrackedRead()) - { - if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase)) - return true; - } - return false; - } - - private bool IndexExists(string name) - { - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - if (_activeMigrationTransaction != null) - cmd.Transaction = _activeMigrationTransaction; - cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = @name"; - SqliteCommandPolicy.Add(cmd, "@name", name); - return cmd.ExecuteScalar() != null; - } - - private bool TriggerExists(string name) - { - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - if (_activeMigrationTransaction != null) - cmd.Transaction = _activeMigrationTransaction; - cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = @name"; - SqliteCommandPolicy.Add(cmd, "@name", name); - return cmd.ExecuteScalar() != null; - } - - private string ExecuteScalar(string sql) - { - using var cmd = SqliteConnectionPolicy.CreateCommand(_connection); - if (_activeMigrationTransaction != null) - cmd.Transaction = _activeMigrationTransaction; - cmd.CommandText = sql; - return cmd.ExecuteScalar()?.ToString() ?? ""; - } - - private void NormalizeCodeIndexMetaKeys() - { - if (!TableExists("codeindex_meta")) - return; - - using (var delete = SqliteConnectionPolicy.CreateCommand(_connection)) - { - if (_activeMigrationTransaction != null) - delete.Transaction = _activeMigrationTransaction; - - delete.CommandText = @" - DELETE FROM codeindex_meta - WHERE key IN ('hotspot_family_version', 'hotspot_family_marker_fingerprint') - AND value IS NULL"; - delete.ExecuteNonQuery(); - } - - using var stamp = SqliteConnectionPolicy.CreateCommand(_connection); - if (_activeMigrationTransaction != null) - stamp.Transaction = _activeMigrationTransaction; - stamp.CommandText = @" - INSERT INTO codeindex_meta (key, value) VALUES ('codeindex_meta_schema_version', @version) - ON CONFLICT(key) DO UPDATE SET value = excluded.value"; - SqliteCommandPolicy.Add(stamp, "@version", CodeIndexMetaSchemaVersion.ToString(CultureInfo.InvariantCulture)); - stamp.ExecuteNonQuery(); - } - - internal void MarkWriteWork(bool walCheckpointable = true) - { - if (!_isReadOnly && !_suppressWriteWorkTracking) - { - _hasWriteWork = true; - if (walCheckpointable) - _hasWalCheckpointableWriteWork = true; - } - } - - internal sealed record PlannerStatisticsMaintenanceFailure(string CommandText, SqliteException Exception); - - internal void SuppressPlannerStatisticsMaintenanceOnClose() - => Volatile.Write(ref _suppressPlannerStatisticsMaintenanceOnClose, true); - - internal PlannerStatisticsMaintenanceFailure? RunPlannerStatisticsMaintenance( - bool forceAnalyze, - CancellationToken cancellationToken = default) - { - if (_isReadOnly) - return null; - - using var cmd = _connection.CreateCommand(); - cmd.CommandText = forceAnalyze ? "ANALYZE" : "PRAGMA optimize"; - cancellationToken.ThrowIfCancellationRequested(); - using var cancellationRegistration = cancellationToken.UnsafeRegister( - static state => SQLitePCL.raw.sqlite3_interrupt(((SqliteConnection)state!).Handle), - _connection); - try - { - PlannerStatisticsCommandCreatedForTesting?.Invoke(cmd); - cmd.ExecuteNonQuery(); - cancellationToken.ThrowIfCancellationRequested(); - PlannerStatisticsCommandExecutedForTesting?.Invoke(_connection.DataSource, cmd.CommandText); - if (!forceAnalyze) - OptimizePragmaExecutedForTesting?.Invoke(_connection.DataSource); - _hasWriteWork = false; - return null; - } - catch (SqliteException ex) when (cancellationToken.IsCancellationRequested && ex.SqliteErrorCode == 9) - { - throw new OperationCanceledException("SQLite planner maintenance was interrupted.", ex, cancellationToken); - } - catch (SqliteException ex) - { - // Planner statistics are an index-performance aid. If SQLite rejects ANALYZE / - // optimize during cleanup (read-only handoff, transient filesystem state), keep - // the completed index usable instead of converting success into failure. - return new PlannerStatisticsMaintenanceFailure(cmd.CommandText, ex); - } - } - - private void RunOptimizeOnCloseIfNeeded() - { - if (!_hasWriteWork - || _isReadOnly - || _cancellation.IsCancellationRequested - || Volatile.Read(ref _suppressPlannerStatisticsMaintenanceOnClose)) - return; - - try - { - RunPlannerStatisticsMaintenance(forceAnalyze: false, _cancellation); - } - catch (OperationCanceledException) when (_cancellation.IsCancellationRequested) - { - // Dispose-time maintenance is best effort and must not outlive or fail the - // operation that owns this database context. - } - } - - public void Dispose() - { - DbSchemaCache? schemaCache; - lock (_schemaCacheLock) - { - if (_disposed) - return; - _disposed = true; - schemaCache = _schemaCache; - _schemaCache = null; - } - schemaCache?.Dispose(); - - // Dispose cached prepared statements before closing the connection so each - // SqliteCommand's finalizer does not race the connection teardown. - // connection を閉じる前にキャッシュ済み command を dispose し、finalizer と - // connection teardown の競合を防ぐ。 - _preparedCommands?.Dispose(); - _preparedCommands = null; - var hadWriteWork = _hasWriteWork; - var hadWalCheckpointableWriteWork = _hasWalCheckpointableWriteWork; - RunOptimizeOnCloseIfNeeded(); - if (hadWalCheckpointableWriteWork) - TryCheckpointWalTruncate(); - _connection.Dispose(); - } -} - -/// -/// Captured information about a single failed step inside -/// . Surfaced via -/// so a later "no such column" error coming -/// out of a read path can be traced back to the specific step that did not run. -/// で失敗したステップの情報。 -/// -public sealed record DbMigrationFailure( - string Step, - int SqliteErrorCode, - string SqliteMessage, - string SuggestedAction); - -internal static class DbColumnEnsurer -{ - internal static void EnsureColumn( - Func columnExists, - Action? beginImmediate, - Action? commit, - Action? rollback, - Action alterColumn) - { - if (columnExists()) - return; - - var hasTransactionHooks = beginImmediate != null && commit != null && rollback != null; - var transactionStarted = false; - try - { - if (hasTransactionHooks) - { - beginImmediate!(); - transactionStarted = true; - if (columnExists()) - { - commit!(); - transactionStarted = false; - return; - } - } - - alterColumn(); - if (transactionStarted) - { - commit!(); - transactionStarted = false; - } - } - catch (SqliteException ex) when (IsDuplicateColumnRace(ex, columnExists)) - { - // Another process or an earlier partial migration may have added the - // column between PRAGMA inspection and ALTER. Re-check PRAGMA-derived - // state and gate on SQLite's generic DDL error code so localized builds - // or future wording changes still recover (#1532, #1690). - // 列存在を PRAGMA 相当の状態で再確認し、SQLite の英語メッセージに依存せず - // 「移行済み」を判定する (#1532)。 - if (transactionStarted) - { - try { rollback!(); } catch (SqliteException) { } - transactionStarted = false; - } - } - catch - { - if (transactionStarted) - { - try { rollback!(); } catch (SqliteException) { } - } - throw; - } - } - - internal static void EnsureColumn(Func columnExists, Action alterColumn) - => EnsureColumn(columnExists, beginImmediate: null, commit: null, rollback: null, alterColumn); - - private static bool IsDuplicateColumnRace(SqliteException exception, Func columnExists) - { - if (!IsDuplicateColumnAddError(exception)) - return false; - - return columnExists(); - } - - private static bool IsDuplicateColumnAddError(SqliteException exception) - { - // SQLite reports duplicate-column ADD COLUMN as SQLITE_ERROR (1); callers - // confirm the column exists before treating it as a recovered race. - return exception.SqliteErrorCode == 1; - } } From 3222468e3bda5fadd3e85e031af4fd2649153c07 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:42:59 +0900 Subject: [PATCH 051/101] Decompose database schema initialization --- .../DbContext.SchemaInitialization.cs | 595 +++++++++--------- 1 file changed, 309 insertions(+), 286 deletions(-) diff --git a/src/CodeIndex/Database/DbContext.SchemaInitialization.cs b/src/CodeIndex/Database/DbContext.SchemaInitialization.cs index b3c40f993..a8bc7d198 100644 --- a/src/CodeIndex/Database/DbContext.SchemaInitialization.cs +++ b/src/CodeIndex/Database/DbContext.SchemaInitialization.cs @@ -48,292 +48,11 @@ private void InitializeSchemaInOwnedTransaction(string legacyAlterTable) _migrationTransactionOwnership = MigrationTransactionOwnership.Owned; try { - // Files table / ファイルテーブル - Execute(@" - CREATE TABLE IF NOT EXISTS files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path TEXT NOT NULL UNIQUE, - lang TEXT, - size INTEGER, - lines INTEGER, - checksum TEXT, - modified DATETIME, - generated INTEGER NOT NULL DEFAULT 0, - indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP - )"); - - // Chunks table / チャンクテーブル - Execute(@" - CREATE TABLE IF NOT EXISTS chunks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - chunk_index INTEGER NOT NULL, - start_line INTEGER, - end_line INTEGER, - content TEXT, - UNIQUE(file_id, chunk_index) - )"); - - // Shared reference-line context table / 参照行コンテキスト共有テーブル - Execute(@" - CREATE TABLE IF NOT EXISTS reference_lines ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - line INTEGER NOT NULL, - context TEXT NOT NULL, - UNIQUE(file_id, line, context) - )"); - - var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); - var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); - - // Symbols table / シンボルテーブル - Execute(@" - CREATE TABLE IF NOT EXISTS symbols ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - kind TEXT CHECK (kind IN (" + symbolKindCheck + @")), - sub_kind TEXT, - name TEXT, - line INTEGER, - start_line INTEGER, - start_column INTEGER, - end_line INTEGER, - body_start_line INTEGER, - body_end_line INTEGER, - signature TEXT, - container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN (" + symbolKindCheck + @")), - container_name TEXT, - container_qualified_name TEXT, - family_key TEXT, - visibility TEXT, - return_type TEXT, - is_metadata_target INTEGER, - metadata_target_source TEXT - )"); - - // Indexed references table / 参照インデックステーブル - Execute(@" - CREATE TABLE IF NOT EXISTS symbol_references ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - symbol_name TEXT, - reference_kind TEXT CHECK (reference_kind IN (" + referenceKindCheck + @")), - line INTEGER, - column_number INTEGER, - context TEXT, - reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, - container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN (" + symbolKindCheck + @")), - container_name TEXT, - source_symbol_id INTEGER, - target_symbol_id INTEGER, - target_symbol_key TEXT, - target_qualifier TEXT, - resolution_state TEXT, - resolution_candidate_count INTEGER NOT NULL DEFAULT 0 - )"); - - var backfillHotspotReferenceCounts = !TableExists(HotspotReferenceAggregateSql.TableName) - || (GetUserVersion() & HotspotReferenceAggregateReadyFlag) == 0; - Execute(HotspotReferenceAggregateSql.CreateTableSql); - - // File validation issues table / ファイル検証問題テーブル - Execute(@" - CREATE TABLE IF NOT EXISTS file_issues ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - kind TEXT NOT NULL, - line INTEGER NOT NULL DEFAULT 0, - message TEXT NOT NULL, - origin TEXT, - severity TEXT - )"); - - // Key-value metadata: fold algorithm version, future per-subsystem schema markers - // that don't fit in PRAGMA user_version's readiness/storage-contract bitmap. See - // NameFold.Version and DbReader fold-ready gate. - // メタデータ用 key-value: fold のアルゴリズム版数など、user_version bitmap に収まらない情報。 - Execute(@" - CREATE TABLE IF NOT EXISTS codeindex_meta ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT - )"); - NormalizeCodeIndexMetaKeys(); - - // Schema migrations for existing DBs / 既存DB向けスキーマ移行 - EnsureColumn("files", "lang", "TEXT"); - EnsureColumn("files", "checksum", "TEXT"); - EnsureColumn("files", "modified", "DATETIME"); - EnsureColumn("files", "generated", "INTEGER NOT NULL DEFAULT 0"); - EnsureColumn("files", "indexed_at", "DATETIME"); - EnsureColumn("symbols", "start_line", "INTEGER"); - EnsureColumn("symbols", "sub_kind", "TEXT"); - EnsureColumn("symbols", "start_column", "INTEGER"); - EnsureColumn("symbols", "end_line", "INTEGER"); - EnsureColumn("symbols", "body_start_line", "INTEGER"); - EnsureColumn("symbols", "body_end_line", "INTEGER"); - EnsureColumn("symbols", "signature", "TEXT"); - EnsureColumn("symbols", "container_kind", "TEXT"); - EnsureColumn("symbols", "container_name", "TEXT"); - EnsureColumn("symbols", "container_qualified_name", "TEXT"); - EnsureColumn("symbols", "family_key", "TEXT"); - EnsureColumn("symbols", "visibility", "TEXT"); - EnsureColumn("symbols", "return_type", "TEXT"); - EnsureColumn("file_issues", "origin", "TEXT"); - EnsureColumn("file_issues", "severity", "TEXT"); - EnsureColumn("symbols", "is_metadata_target", "INTEGER"); - EnsureColumn("symbols", "metadata_target_source", "TEXT"); - var rebuildsSymbolReferences = !ColumnIsNotNull("symbol_references", "file_id"); - EnsureColumn( - "symbol_references", - "reference_line_id", - rebuildsSymbolReferences ? "INTEGER" : "INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL"); - // #86: Unicode-aware folded name columns for `--exact` name matching across all - // `--exact` command variants. Populated by the writer via NameFold.Fold; NULL on - // legacy rows until a full reindex, in which case the reader falls back to the - // COLLATE NOCASE path (correct for ASCII, misses non-ASCII casing — #86 fix). - // #86: --exact 用の Unicode 折り畳み列。レガシー行は NULL のまま、再 index で埋まる。 - EnsureColumn("symbols", "name_folded", "TEXT"); - EnsureColumn("symbol_references", "symbol_name_folded", "TEXT"); - EnsureColumn("symbol_references", "container_name_folded", "TEXT"); - EnsureColumn("symbol_references", "is_self_reference", "INTEGER NOT NULL DEFAULT 0"); - EnsureColumn("symbol_references", "is_mutual_recursion", "INTEGER NOT NULL DEFAULT 0"); - EnsureColumn("symbol_references", "source_symbol_id", "INTEGER"); - EnsureColumn("symbol_references", "target_symbol_id", "INTEGER"); - EnsureColumn("symbol_references", "target_symbol_key", "TEXT"); - EnsureColumn("symbol_references", "target_qualifier", "TEXT"); - EnsureColumn("symbol_references", "resolution_state", "TEXT"); - EnsureColumn("symbol_references", "resolution_candidate_count", "INTEGER NOT NULL DEFAULT 0"); - foreach (var indexSql in HotspotReferenceAggregateSql.CreateIndexSql) - Execute(indexSql); - if (backfillHotspotReferenceCounts) - { - Execute(HotspotReferenceAggregateSql.BuildRefreshSql(singleFile: false)); - MarkHotspotReferenceAggregateReady(); - } - EnforceRequiredFileIdConstraints(); - EnforceReferenceLineSetNullConstraint(); - EnsureReferenceLinesContextKey(); - EnsureKindCheckConstraintsCurrent(); - Execute(@" - CREATE TABLE IF NOT EXISTS symbol_reference_candidates ( - reference_id INTEGER NOT NULL, - symbol_id INTEGER NOT NULL, - scope_rank INTEGER NOT NULL, - PRIMARY KEY(reference_id, symbol_id) - )"); - - // Indexes / インデックス - Execute("CREATE INDEX IF NOT EXISTS idx_files_lang ON files(lang)"); - Execute("CREATE INDEX IF NOT EXISTS idx_files_modified ON files(modified)"); - Execute("CREATE INDEX IF NOT EXISTS idx_files_generated ON files(generated)"); - Execute("CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)"); - Execute("CREATE INDEX IF NOT EXISTS idx_files_path_nocase ON files(path COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_file_issues_file_kind ON file_issues(file_id, kind)"); - // The UNIQUE path constraint supplies the BINARY exact index. The separate - // NOCASE index is only for bounded ASCII case-alias candidate lookups. - // path の UNIQUE 制約が BINARY exact index を作り、別の NOCASE index は - // bounded ASCII case-alias candidate lookup 専用に使う。 - Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_end_start_nonnull ON chunks(file_id, end_line, start_line, chunk_index) WHERE content IS NOT NULL"); - Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_start_chunk_nonnull ON chunks(file_id, start_line, chunk_index, end_line) WHERE content IS NOT NULL"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)"); - // Case-insensitive exact-match index for `symbols --exact` (and MCP `symbols` exact=true). - // Without this, `name = @q COLLATE NOCASE` falls back to a full symbols scan per query name, - // which on multi-name exact lookups becomes O(names × symbols). - // `symbols --exact` 用の大文字小文字無視 index。無いと multi-name exact でフルスキャンが N 回走る。 - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_nocase ON symbols(name COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_start ON symbols(start_line)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name ON symbol_references(symbol_name)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_file ON symbol_references(file_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container ON symbol_references(container_name)"); - // Compound indexes for common query patterns / よくあるクエリパターン用の複合インデックス - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_kind ON symbols(file_id, kind)"); - Execute("CREATE INDEX IF NOT EXISTS idx_files_lang_modified ON files(lang, modified)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_kind ON symbol_references(container_name, reference_kind)"); - // Indexes for new query patterns: --kind filter, visibility ranking, hotspot/unused analysis - // 新しいクエリパターン用: --kind フィルタ、可視性ランキング、ホットスポット/未使用分析 - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_visibility ON symbols(visibility)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_kind ON symbol_references(symbol_name, reference_kind)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_file ON symbol_references(symbol_name, file_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_mutual_folded ON symbol_references(container_name_folded, symbol_name_folded, reference_kind, is_self_reference)"); - Execute("CREATE INDEX IF NOT EXISTS idx_reference_lines_file_line ON reference_lines(file_id, line)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_reference_line ON symbol_references(reference_line_id)"); - // Case-insensitive exact-match indexes for `references --exact` / `callers --exact` / `callees --exact` (#83). - // Mirror idx_symbols_name_nocase so `= @q COLLATE NOCASE` stays O(log n) per name across graph commands. - // `references / callers / callees --exact` 用の NOCASE index。idx_symbols_name_nocase と対になる。 - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase ON symbol_references(symbol_name COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_nocase ON symbol_references(container_name COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase_kind ON symbol_references(symbol_name COLLATE NOCASE, reference_kind)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase_file ON symbol_references(symbol_name COLLATE NOCASE, file_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_nocase_kind ON symbol_references(container_name COLLATE NOCASE, reference_kind)"); - // #86: Indexes on the Unicode-folded columns. Used when FoldReadyFlag is set on the - // DB (= the write path filled every folded column). Legacy / partial DBs keep using - // the NOCASE indexes above. Both sets coexist so mixed-state DBs cannot regress. - // #86: 折り畳み列のインデックス。FoldReadyFlag が立っている DB でだけ使う。 - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded ON symbols(name_folded)"); - // Reference-source and ranked-candidate resolution repeatedly combines the folded - // symbol name with file or container scope. Keep those probes bounded for every - // indexed language, including the NOCASE fallback used by partially migrated DBs. - // 参照元・rank 候補解決は folded 名と file/container scope を繰り返し組み合わせる。 - // 全言語と部分 migration DB の NOCASE fallback を複合 index で bounded に保つ。 - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_folded ON symbols(file_id, name_folded)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_nocase ON symbols(file_id, name COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded_container_name_nocase ON symbols(name_folded, container_name COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded_container_qualified_name_nocase ON symbols(name_folded, container_qualified_name COLLATE NOCASE)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded ON symbol_references(symbol_name_folded)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_name_folded ON symbol_references(container_name_folded)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded_kind ON symbol_references(symbol_name_folded, reference_kind)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded_file ON symbol_references(symbol_name_folded, file_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_name_folded_kind ON symbol_references(container_name_folded, reference_kind)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_source_symbol ON symbol_references(source_symbol_id)"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_target_symbol ON symbol_references(target_symbol_id)"); - // Mutual-recursion refresh probes the reverse of every resolved edge. Restrict the - // covering index to rows that can participate so unresolved references add no write - // or storage cost during ordinary extraction. - // 相互再帰 refresh は解決済み edge ごとに逆辺を探す。参加可能な行だけを covering - // index に含め、通常抽出中の未解決参照には書き込み・容量コストを加えない。 - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_resolved_source_target_kind ON symbol_references(source_symbol_id, target_symbol_id, reference_kind) WHERE source_symbol_id IS NOT NULL AND target_symbol_id IS NOT NULL"); - Execute("CREATE INDEX IF NOT EXISTS idx_symbol_ref_candidates_symbol ON symbol_reference_candidates(symbol_id, reference_id)"); - - // Full-text search / 全文検索 - Execute(@" - CREATE VIRTUAL TABLE IF NOT EXISTS fts_chunks USING fts5( - content, - content='chunks', - content_rowid='id' - )"); - Execute($@" - CREATE VIRTUAL TABLE IF NOT EXISTS {FtsChunksTrigramTableName} USING fts5( - content, - content='chunks', - content_rowid='id', - tokenize='trigram' - )"); - if (_rebuildFtsAfterSchemaMigration) - { - Execute("INSERT INTO fts_chunks(fts_chunks) VALUES('rebuild')"); - _rebuildFtsAfterSchemaMigration = false; - } - if (_rebuildTrigramFtsAfterSchemaMigration) - { - Execute($"INSERT INTO {FtsChunksTrigramTableName}({FtsChunksTrigramTableName}) VALUES('rebuild')"); - _rebuildTrigramFtsAfterSchemaMigration = false; - } - - // FTS5 content-synced triggers — keep both FTS indexes in sync with chunks. - // Without these, CASCADE DELETEs on chunks leave orphan entries in fts_chunks. - // FTS5 content-synced トリガー — 両方の FTS index を chunks と同期する。 - // これがないと chunks の CASCADE DELETE で FTS に孤立エントリが残る。 - Execute(CreateAllFtsChunksSyncTriggersSql); - // Keep MCP resources/list cursors tied to the exact indexed-file snapshot. - // MCP resources/list カーソルをインデックス済みファイルのスナップショットに結び付ける。 - Execute(EnsureResourceListGenerationSql); - Execute(CreateResourceListGenerationInsertTriggerSql); - Execute(CreateResourceListGenerationDeleteTriggerSql); - Execute(CreateResourceListGenerationUpdateTriggerSql); + var backfillHotspotReferenceCounts = EnsureCoreSchemaTables(); + MigrateCoreTableColumns(); + InitializeReferenceGraphSchema(backfillHotspotReferenceCounts); + CreateCoreSchemaIndexes(); + InitializeFullTextSchema(); transaction.Commit(); } finally @@ -355,6 +74,310 @@ CREATE VIRTUAL TABLE IF NOT EXISTS fts_chunks USING fts5( } } + private bool EnsureCoreSchemaTables() + { + // Files table / ファイルテーブル + Execute(@" + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + lang TEXT, + size INTEGER, + lines INTEGER, + checksum TEXT, + modified DATETIME, + generated INTEGER NOT NULL DEFAULT 0, + indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP + )"); + + // Chunks table / チャンクテーブル + Execute(@" + CREATE TABLE IF NOT EXISTS chunks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + chunk_index INTEGER NOT NULL, + start_line INTEGER, + end_line INTEGER, + content TEXT, + UNIQUE(file_id, chunk_index) + )"); + + // Shared reference-line context table / 参照行コンテキスト共有テーブル + Execute(@" + CREATE TABLE IF NOT EXISTS reference_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + line INTEGER NOT NULL, + context TEXT NOT NULL, + UNIQUE(file_id, line, context) + )"); + + var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); + var referenceKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.ReferenceKinds); + + // Symbols table / シンボルテーブル + Execute(@" + CREATE TABLE IF NOT EXISTS symbols ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + kind TEXT CHECK (kind IN (" + symbolKindCheck + @")), + sub_kind TEXT, + name TEXT, + line INTEGER, + start_line INTEGER, + start_column INTEGER, + end_line INTEGER, + body_start_line INTEGER, + body_end_line INTEGER, + signature TEXT, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN (" + symbolKindCheck + @")), + container_name TEXT, + container_qualified_name TEXT, + family_key TEXT, + visibility TEXT, + return_type TEXT, + is_metadata_target INTEGER, + metadata_target_source TEXT + )"); + + // Indexed references table / 参照インデックステーブル + Execute(@" + CREATE TABLE IF NOT EXISTS symbol_references ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + symbol_name TEXT, + reference_kind TEXT CHECK (reference_kind IN (" + referenceKindCheck + @")), + line INTEGER, + column_number INTEGER, + context TEXT, + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN (" + symbolKindCheck + @")), + container_name TEXT, + source_symbol_id INTEGER, + target_symbol_id INTEGER, + target_symbol_key TEXT, + target_qualifier TEXT, + resolution_state TEXT, + resolution_candidate_count INTEGER NOT NULL DEFAULT 0 + )"); + + var backfillHotspotReferenceCounts = !TableExists(HotspotReferenceAggregateSql.TableName) + || (GetUserVersion() & HotspotReferenceAggregateReadyFlag) == 0; + Execute(HotspotReferenceAggregateSql.CreateTableSql); + + // File validation issues table / ファイル検証問題テーブル + Execute(@" + CREATE TABLE IF NOT EXISTS file_issues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + line INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL, + origin TEXT, + severity TEXT + )"); + + // Key-value metadata: fold algorithm version, future per-subsystem schema markers + // that don't fit in PRAGMA user_version's readiness/storage-contract bitmap. See + // NameFold.Version and DbReader fold-ready gate. + // メタデータ用 key-value: fold のアルゴリズム版数など、user_version bitmap に収まらない情報。 + Execute(@" + CREATE TABLE IF NOT EXISTS codeindex_meta ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT + )"); + NormalizeCodeIndexMetaKeys(); + return backfillHotspotReferenceCounts; + } + + private void MigrateCoreTableColumns() + { + // Schema migrations for existing DBs / 既存DB向けスキーマ移行 + EnsureColumn("files", "lang", "TEXT"); + EnsureColumn("files", "checksum", "TEXT"); + EnsureColumn("files", "modified", "DATETIME"); + EnsureColumn("files", "generated", "INTEGER NOT NULL DEFAULT 0"); + EnsureColumn("files", "indexed_at", "DATETIME"); + EnsureColumn("symbols", "start_line", "INTEGER"); + EnsureColumn("symbols", "sub_kind", "TEXT"); + EnsureColumn("symbols", "start_column", "INTEGER"); + EnsureColumn("symbols", "end_line", "INTEGER"); + EnsureColumn("symbols", "body_start_line", "INTEGER"); + EnsureColumn("symbols", "body_end_line", "INTEGER"); + EnsureColumn("symbols", "signature", "TEXT"); + EnsureColumn("symbols", "container_kind", "TEXT"); + EnsureColumn("symbols", "container_name", "TEXT"); + EnsureColumn("symbols", "container_qualified_name", "TEXT"); + EnsureColumn("symbols", "family_key", "TEXT"); + EnsureColumn("symbols", "visibility", "TEXT"); + EnsureColumn("symbols", "return_type", "TEXT"); + EnsureColumn("file_issues", "origin", "TEXT"); + EnsureColumn("file_issues", "severity", "TEXT"); + EnsureColumn("symbols", "is_metadata_target", "INTEGER"); + EnsureColumn("symbols", "metadata_target_source", "TEXT"); + var rebuildsSymbolReferences = !ColumnIsNotNull("symbol_references", "file_id"); + EnsureColumn( + "symbol_references", + "reference_line_id", + rebuildsSymbolReferences ? "INTEGER" : "INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL"); + // #86: Unicode-aware folded name columns for `--exact` name matching across all + // `--exact` command variants. Populated by the writer via NameFold.Fold; NULL on + // legacy rows until a full reindex, in which case the reader falls back to the + // COLLATE NOCASE path (correct for ASCII, misses non-ASCII casing — #86 fix). + // #86: --exact 用の Unicode 折り畳み列。レガシー行は NULL のまま、再 index で埋まる。 + EnsureColumn("symbols", "name_folded", "TEXT"); + EnsureColumn("symbol_references", "symbol_name_folded", "TEXT"); + EnsureColumn("symbol_references", "container_name_folded", "TEXT"); + EnsureColumn("symbol_references", "is_self_reference", "INTEGER NOT NULL DEFAULT 0"); + EnsureColumn("symbol_references", "is_mutual_recursion", "INTEGER NOT NULL DEFAULT 0"); + EnsureColumn("symbol_references", "source_symbol_id", "INTEGER"); + EnsureColumn("symbol_references", "target_symbol_id", "INTEGER"); + EnsureColumn("symbol_references", "target_symbol_key", "TEXT"); + EnsureColumn("symbol_references", "target_qualifier", "TEXT"); + EnsureColumn("symbol_references", "resolution_state", "TEXT"); + EnsureColumn("symbol_references", "resolution_candidate_count", "INTEGER NOT NULL DEFAULT 0"); + } + + private void InitializeReferenceGraphSchema(bool backfillHotspotReferenceCounts) + { + foreach (var indexSql in HotspotReferenceAggregateSql.CreateIndexSql) + Execute(indexSql); + if (backfillHotspotReferenceCounts) + { + Execute(HotspotReferenceAggregateSql.BuildRefreshSql(singleFile: false)); + MarkHotspotReferenceAggregateReady(); + } + EnforceRequiredFileIdConstraints(); + EnforceReferenceLineSetNullConstraint(); + EnsureReferenceLinesContextKey(); + EnsureKindCheckConstraintsCurrent(); + Execute(@" + CREATE TABLE IF NOT EXISTS symbol_reference_candidates ( + reference_id INTEGER NOT NULL, + symbol_id INTEGER NOT NULL, + scope_rank INTEGER NOT NULL, + PRIMARY KEY(reference_id, symbol_id) + )"); + } + + private void CreateCoreSchemaIndexes() + { + // Indexes / インデックス + Execute("CREATE INDEX IF NOT EXISTS idx_files_lang ON files(lang)"); + Execute("CREATE INDEX IF NOT EXISTS idx_files_modified ON files(modified)"); + Execute("CREATE INDEX IF NOT EXISTS idx_files_generated ON files(generated)"); + Execute("CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)"); + Execute("CREATE INDEX IF NOT EXISTS idx_files_path_nocase ON files(path COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_file_issues_file_kind ON file_issues(file_id, kind)"); + // The UNIQUE path constraint supplies the BINARY exact index. The separate + // NOCASE index is only for bounded ASCII case-alias candidate lookups. + // path の UNIQUE 制約が BINARY exact index を作り、別の NOCASE index は + // bounded ASCII case-alias candidate lookup 専用に使う。 + Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_end_start_nonnull ON chunks(file_id, end_line, start_line, chunk_index) WHERE content IS NOT NULL"); + Execute("CREATE INDEX IF NOT EXISTS idx_chunks_file_start_chunk_nonnull ON chunks(file_id, start_line, chunk_index, end_line) WHERE content IS NOT NULL"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)"); + // Case-insensitive exact-match index for `symbols --exact` (and MCP `symbols` exact=true). + // Without this, `name = @q COLLATE NOCASE` falls back to a full symbols scan per query name, + // which on multi-name exact lookups becomes O(names × symbols). + // `symbols --exact` 用の大文字小文字無視 index。無いと multi-name exact でフルスキャンが N 回走る。 + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_nocase ON symbols(name COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_start ON symbols(start_line)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name ON symbol_references(symbol_name)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_file ON symbol_references(file_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container ON symbol_references(container_name)"); + // Compound indexes for common query patterns / よくあるクエリパターン用の複合インデックス + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_kind ON symbols(file_id, kind)"); + Execute("CREATE INDEX IF NOT EXISTS idx_files_lang_modified ON files(lang, modified)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_kind ON symbol_references(container_name, reference_kind)"); + // Indexes for new query patterns: --kind filter, visibility ranking, hotspot/unused analysis + // 新しいクエリパターン用: --kind フィルタ、可視性ランキング、ホットスポット/未使用分析 + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_kind ON symbols(kind)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_visibility ON symbols(visibility)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_kind ON symbol_references(symbol_name, reference_kind)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_file ON symbol_references(symbol_name, file_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_mutual_folded ON symbol_references(container_name_folded, symbol_name_folded, reference_kind, is_self_reference)"); + Execute("CREATE INDEX IF NOT EXISTS idx_reference_lines_file_line ON reference_lines(file_id, line)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_reference_line ON symbol_references(reference_line_id)"); + // Case-insensitive exact-match indexes for `references --exact` / `callers --exact` / `callees --exact` (#83). + // Mirror idx_symbols_name_nocase so `= @q COLLATE NOCASE` stays O(log n) per name across graph commands. + // `references / callers / callees --exact` 用の NOCASE index。idx_symbols_name_nocase と対になる。 + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase ON symbol_references(symbol_name COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_nocase ON symbol_references(container_name COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase_kind ON symbol_references(symbol_name COLLATE NOCASE, reference_kind)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_name_nocase_file ON symbol_references(symbol_name COLLATE NOCASE, file_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_nocase_kind ON symbol_references(container_name COLLATE NOCASE, reference_kind)"); + // #86: Indexes on the Unicode-folded columns. Used when FoldReadyFlag is set on the + // DB (= the write path filled every folded column). Legacy / partial DBs keep using + // the NOCASE indexes above. Both sets coexist so mixed-state DBs cannot regress. + // #86: 折り畳み列のインデックス。FoldReadyFlag が立っている DB でだけ使う。 + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded ON symbols(name_folded)"); + // Reference-source and ranked-candidate resolution repeatedly combines the folded + // symbol name with file or container scope. Keep those probes bounded for every + // indexed language, including the NOCASE fallback used by partially migrated DBs. + // 参照元・rank 候補解決は folded 名と file/container scope を繰り返し組み合わせる。 + // 全言語と部分 migration DB の NOCASE fallback を複合 index で bounded に保つ。 + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_folded ON symbols(file_id, name_folded)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_file_name_nocase ON symbols(file_id, name COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded_container_name_nocase ON symbols(name_folded, container_name COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbols_name_folded_container_qualified_name_nocase ON symbols(name_folded, container_qualified_name COLLATE NOCASE)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded ON symbol_references(symbol_name_folded)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_name_folded ON symbol_references(container_name_folded)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded_kind ON symbol_references(symbol_name_folded, reference_kind)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_symbol_name_folded_file ON symbol_references(symbol_name_folded, file_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_container_name_folded_kind ON symbol_references(container_name_folded, reference_kind)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_source_symbol ON symbol_references(source_symbol_id)"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_target_symbol ON symbol_references(target_symbol_id)"); + // Mutual-recursion refresh probes the reverse of every resolved edge. Restrict the + // covering index to rows that can participate so unresolved references add no write + // or storage cost during ordinary extraction. + // 相互再帰 refresh は解決済み edge ごとに逆辺を探す。参加可能な行だけを covering + // index に含め、通常抽出中の未解決参照には書き込み・容量コストを加えない。 + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_refs_resolved_source_target_kind ON symbol_references(source_symbol_id, target_symbol_id, reference_kind) WHERE source_symbol_id IS NOT NULL AND target_symbol_id IS NOT NULL"); + Execute("CREATE INDEX IF NOT EXISTS idx_symbol_ref_candidates_symbol ON symbol_reference_candidates(symbol_id, reference_id)"); + } + + private void InitializeFullTextSchema() + { + // Full-text search / 全文検索 + Execute(@" + CREATE VIRTUAL TABLE IF NOT EXISTS fts_chunks USING fts5( + content, + content='chunks', + content_rowid='id' + )"); + Execute($@" + CREATE VIRTUAL TABLE IF NOT EXISTS {FtsChunksTrigramTableName} USING fts5( + content, + content='chunks', + content_rowid='id', + tokenize='trigram' + )"); + if (_rebuildFtsAfterSchemaMigration) + { + Execute("INSERT INTO fts_chunks(fts_chunks) VALUES('rebuild')"); + _rebuildFtsAfterSchemaMigration = false; + } + if (_rebuildTrigramFtsAfterSchemaMigration) + { + Execute($"INSERT INTO {FtsChunksTrigramTableName}({FtsChunksTrigramTableName}) VALUES('rebuild')"); + _rebuildTrigramFtsAfterSchemaMigration = false; + } + + // FTS5 content-synced triggers — keep both FTS indexes in sync with chunks. + // Without these, CASCADE DELETEs on chunks leave orphan entries in fts_chunks. + // FTS5 content-synced トリガー — 両方の FTS index を chunks と同期する。 + // これがないと chunks の CASCADE DELETE で FTS に孤立エントリが残る。 + Execute(CreateAllFtsChunksSyncTriggersSql); + // Keep MCP resources/list cursors tied to the exact indexed-file snapshot. + // MCP resources/list カーソルをインデックス済みファイルのスナップショットに結び付ける。 + Execute(EnsureResourceListGenerationSql); + Execute(CreateResourceListGenerationInsertTriggerSql); + Execute(CreateResourceListGenerationDeleteTriggerSql); + Execute(CreateResourceListGenerationUpdateTriggerSql); + } + private void EnforceRequiredFileIdConstraints() { var symbolKindCheck = SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds); From 26ea8981435331ff362e69b87a72750928daa28e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:48:21 +0900 Subject: [PATCH 052/101] Split console UI responsibilities --- src/CodeIndex/Cli/ConsoleUi.Branding.cs | 228 +++ src/CodeIndex/Cli/ConsoleUi.Colors.cs | 370 +++++ src/CodeIndex/Cli/ConsoleUi.Help.cs | 696 +++++++++ src/CodeIndex/Cli/ConsoleUi.Progress.cs | 319 +++++ src/CodeIndex/Cli/ConsoleUi.Terminal.cs | 165 +++ src/CodeIndex/Cli/ConsoleUi.cs | 1710 +---------------------- 6 files changed, 1779 insertions(+), 1709 deletions(-) create mode 100644 src/CodeIndex/Cli/ConsoleUi.Branding.cs create mode 100644 src/CodeIndex/Cli/ConsoleUi.Colors.cs create mode 100644 src/CodeIndex/Cli/ConsoleUi.Help.cs create mode 100644 src/CodeIndex/Cli/ConsoleUi.Progress.cs create mode 100644 src/CodeIndex/Cli/ConsoleUi.Terminal.cs diff --git a/src/CodeIndex/Cli/ConsoleUi.Branding.cs b/src/CodeIndex/Cli/ConsoleUi.Branding.cs new file mode 100644 index 000000000..01311e5ab --- /dev/null +++ b/src/CodeIndex/Cli/ConsoleUi.Branding.cs @@ -0,0 +1,228 @@ +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace CodeIndex.Cli; + +public static partial class ConsoleUi +{ + public static void PrintBanner() + { + const string banner = """ + + ██████╗ ██████╗ ██████╗ ███████╗██╗███╗ ██╗██████╗ ███████╗██╗ ██╗ + ██╔════╝██╔═══██╗██╔══██╗██╔════╝██║████╗ ██║██╔══██╗██╔════╝╚██╗██╔╝ + ██║ ██║ ██║██║ ██║█████╗ ██║██╔██╗ ██║██║ ██║█████╗ ╚███╔╝ + ██║ ██║ ██║██║ ██║██╔══╝ ██║██║╚██╗██║██║ ██║██╔══╝ ██╔██╗ + ╚██████╗╚██████╔╝██████╔╝███████╗██║██║ ╚████║██████╔╝███████╗██╔╝ ██╗ + ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝╚═╝ ╚═══╝╚═════╝ ╚══════╝╚═╝ ╚═╝ + """; + Console.WriteLine(banner); + } + + public static void PrintIndexCompleteSummary( + string projectRoot, + string resolvedDbPath, + bool incremental, + int filesScanned, + IReadOnlyDictionary languageCounts) + { + Console.WriteLine(incremental ? "Next steps (incremental):" : "Next steps:"); + Console.WriteLine(" - Search code: cdidx search \"authenticate\" --path src/"); + Console.WriteLine(" - Find a definition: cdidx definition SymbolName"); + Console.WriteLine($" - Start MCP: cdidx mcp --db {QuoteForDisplay(resolvedDbPath)}"); + Console.WriteLine($" - Database: {resolvedDbPath}"); + Console.WriteLine(" - Exclude paths with .gitignore or .cdidxignore, then rerun cdidx index ."); + Console.WriteLine($" - Scanned {Counted(filesScanned, "file", format: "N0")} under {projectRoot}"); + if (languageCounts.Count > 0) + { + var summary = string.Join( + ", ", + languageCounts + .OrderByDescending(static pair => pair.Value) + .ThenBy(static pair => pair.Key, StringComparer.Ordinal) + .Take(6) + .Select(static pair => $"{pair.Key} {pair.Value.ToString("N0", CultureInfo.InvariantCulture)}")); + Console.WriteLine($" - Languages: {summary}"); + } + Console.WriteLine(); + } + + public static void EmitCompletionNotification(CompletionNotificationMode mode, string message) + { + var resolved = mode == CompletionNotificationMode.Auto + ? ShouldUseInteractiveConsole() ? CompletionNotificationMode.Bell : CompletionNotificationMode.None + : mode; + if (resolved == CompletionNotificationMode.None) + return; + + var safeMessage = message.Replace('\r', ' ').Replace('\n', ' '); + if (resolved == CompletionNotificationMode.Osc9) + Console.Error.Write($"\u001b]9;{safeMessage}\a"); + else + Console.Error.Write('\a'); + Console.Error.Flush(); + } + + private static string QuoteForDisplay(string value) + => value.IndexOfAny([' ', '\t', '"']) < 0 + ? value + : $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\""; + + // --- Easter eggs / イースターエッグ --- + + /// + /// Print easter egg message (standalone mode). Renders the catalog entry for + /// in the language chosen by + /// (CDIDX_LANG env > + /// > English fallback). Unknown flags print two blank lines for legacy compatibility. + /// Pass to bypass env/culture resolution (used by + /// tests so they do not mutate the live process environment). + /// イースターエッグメッセージを表示(単体実行時)。 + /// が選んだ言語(CDIDX_LANG 環境変数 > カルチャ > 英語)でカタログ + /// エントリを描画する。未知フラグは従来互換で空行を2つ出力。 + /// を指定すると環境変数/カルチャ判定をスキップする + /// (テストがプロセス環境を書き換えずに済むようにするためのフック)。 + /// + public static void PrintEasterEggMessage(string flag, UiLanguage? languageOverride = null) + { + var pair = flag switch + { + "--sushi" => UiMessages.EasterEggSushi, + "--coffee" => UiMessages.EasterEggCoffee, + "--ramen" => UiMessages.EasterEggRamen, + "--wine" => UiMessages.EasterEggWine, + "--beer" => UiMessages.EasterEggBeer, + "--matcha" => UiMessages.EasterEggMatcha, + "--whisky" => UiMessages.EasterEggWhisky, + _ => null, + }; + if (pair is null) + { + Console.WriteLine(); + Console.WriteLine(); + return; + } + + var lang = languageOverride ?? UiLanguageResolver.Resolve(); + foreach (var line in UiMessages.Render(pair, lang)) + Console.WriteLine(line); + } + + // --- Version loading / バージョン読み込み --- + + /// + /// Load version from version.json. + /// version.jsonからバージョンを読み込み。 + /// + public static string LoadVersion() + { + var exeDir = AppContext.BaseDirectory; + var path = Path.Combine(exeDir, "version.json"); + if (!File.Exists(LongPath.EnsureWindowsPrefix(path))) + { + // Fallback: look relative to current directory / カレントディレクトリからの相対パスでフォールバック + path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "version.json"); + } + var ioPath = LongPath.EnsureWindowsPrefix(path); + if (File.Exists(ioPath)) + return LoadVersionFromFile(ioPath); + + return FallbackVersion; + } + + internal static string LoadVersionFromFile(string ioPath) + { + try + { + var json = DataDirectorySecurity.ReadTextWithinLimit(ioPath, MaxVersionJsonBytes); + if (json is null) + return FallbackVersion; + + using var doc = BoundedJson.ParseDocument(json, MaxVersionJsonBytes, MaxVersionJsonDepth); + if (doc.RootElement.TryGetProperty("version", out var ver)) + return ver.GetString() ?? FallbackVersion; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or InvalidDataException + or InvalidOperationException) + { + return FallbackVersion; + } + + return FallbackVersion; + } + + /// + /// Format byte counts for human-facing CLI output using binary units. + /// 人間向けCLI出力用にバイト数を2進単位で整形する。 + /// + public static string FormatBytes(long bytes) + { + if (bytes < 0) + return string.Create(CultureInfo.InvariantCulture, $"{bytes:N0} bytes"); + if (bytes < 1024) + return string.Create(CultureInfo.InvariantCulture, $"{bytes:N0} bytes"); + + var value = (double)bytes; + var unitIndex = 0; + while (value >= 1024 && unitIndex < ByteUnits.Length - 1) + { + value /= 1024; + unitIndex++; + } + + return string.Create(CultureInfo.InvariantCulture, $"{value:N1} {ByteUnits[unitIndex]}"); + } + + /// + /// Build metadata stamped into the assembly at compile time, used by + /// `--version` so dev builds and tagged releases are distinguishable in + /// bug reports (#1550). Any field can be "unknown" when the build host + /// lacks git (e.g. a tarball-only checkout). + /// `--version` がバグ報告で dev ビルドとタグ済みリリースを区別できる + /// よう、ビルド時にアセンブリへ刻んだメタデータ (#1550)。git の無い + /// ビルドホストでは各フィールドが "unknown" になりうる。 + /// + public sealed record BuildMetadata(string Version, string Commit, string BuildDate, string Dirty); + + /// + /// Load the full build metadata: semver from version.json plus commit/build + /// date/dirty flag stamped into the assembly via AssemblyMetadataAttribute. + /// version.json の semver と、AssemblyMetadataAttribute で刻まれた + /// commit / build date / dirty フラグを合わせて読み込む。 + /// + public static BuildMetadata LoadBuildMetadata() + { + var assembly = typeof(ConsoleUi).Assembly; + return new BuildMetadata( + Version: LoadVersion(), + Commit: ReadAssemblyMetadata(assembly, "CdidxCommit"), + BuildDate: ReadAssemblyMetadata(assembly, "CdidxBuildDate"), + Dirty: ReadAssemblyMetadata(assembly, "CdidxBuildDirty")); + } + + private static string ReadAssemblyMetadata(Assembly assembly, string key) + { + foreach (var attr in assembly.GetCustomAttributes()) + { + if (string.Equals(attr.Key, key, StringComparison.Ordinal)) + return string.IsNullOrWhiteSpace(attr.Value) ? "unknown" : attr.Value!; + } + return "unknown"; + } + + // --- Usage / 使い方 --- + + /// + /// Print usage information. + /// 使い方を表示する。 + /// +} diff --git a/src/CodeIndex/Cli/ConsoleUi.Colors.cs b/src/CodeIndex/Cli/ConsoleUi.Colors.cs new file mode 100644 index 000000000..b7e7097ff --- /dev/null +++ b/src/CodeIndex/Cli/ConsoleUi.Colors.cs @@ -0,0 +1,370 @@ +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace CodeIndex.Cli; + +public static partial class ConsoleUi +{ + internal static ColorMode GetColorModeForDiagnostics() + => _colorMode; + + internal static ColorMode GetColorMode() => _colorMode; + + /// + /// Override the active ANSI palette. null restores auto-detection + /// via COLORTERM / TERM / CDIDX_COLOR_PALETTE. + /// + public static void SetColorPalette(ColorPalette? palette) => _explicitPalette = palette; + + internal static ColorPalette? GetExplicitColorPalette() => _explicitPalette; + + /// + /// Parse a user-supplied `--palette` value. Accepts `basic`, `256`, + /// `color256`, `truecolor`, and `24bit` (case-insensitive). Returns false + /// on any other value. + /// `--palette` 値を解析する。`basic` / `256` / `truecolor` などを許可する。 + /// + public static bool TryParseColorPalette(string? value, out ColorPalette palette) + { + switch (value?.Trim().ToLowerInvariant()) + { + case "basic": + case "8": + case "16": + case "ansi": + palette = ColorPalette.Basic; + return true; + case "256": + case "color256": + case "8bit": + palette = ColorPalette.Color256; + return true; + case "truecolor": + case "24bit": + case "rgb": + palette = ColorPalette.Truecolor; + return true; + default: + palette = ColorPalette.Basic; + return false; + } + } + + /// + /// Resolve the palette to use. Honors the explicit override set via + /// first, then falls back to the + /// CDIDX_COLOR_PALETTE environment variable, then to capability + /// detection from COLORTERM / TERM. + /// + public static ColorPalette ResolveColorPalette() + { + if (_explicitPalette is { } explicitPalette) + return explicitPalette; + + var envPalette = CdidxEnvironment.GetEnvironmentVariable("CDIDX_COLOR_PALETTE"); + if (!string.IsNullOrWhiteSpace(envPalette) && TryParseColorPalette(envPalette, out var parsed)) + return parsed; + + return DetectColorPalette(); + } + + /// + /// Detect the terminal palette from the COLORTERM and TERM + /// environment variables. COLORTERM=truecolor / COLORTERM=24bit + /// → . TERM containing + /// 256color (e.g. xterm-256color, screen-256color) → + /// . Otherwise . + /// + internal static ColorPalette DetectColorPalette() + { + var colorTerm = CdidxEnvironment.GetEnvironmentVariable("COLORTERM"); + if (!string.IsNullOrEmpty(colorTerm)) + { + var ct = colorTerm.Trim().ToLowerInvariant(); + if (ct == "truecolor" || ct == "24bit") + return ColorPalette.Truecolor; + } + + var term = CdidxEnvironment.GetEnvironmentVariable("TERM"); + if (!string.IsNullOrEmpty(term)) + { + var t = term.ToLowerInvariant(); + if (t.Contains("256color", StringComparison.Ordinal)) + return ColorPalette.Color256; + if (t.Contains("truecolor", StringComparison.Ordinal) || t.Contains("direct", StringComparison.Ordinal)) + return ColorPalette.Truecolor; + } + + return ColorPalette.Basic; + } + + /// + /// Parse a user-supplied `--color` value. Accepts `auto`, `always`, and + /// `never` (case-insensitive). Returns false on any other value. + /// `--color` 値を解析する。`auto` / `always` / `never` のみ許可。 + /// + public static bool TryParseColorMode(string? value, out ColorMode mode) + { + switch (value?.Trim().ToLowerInvariant()) + { + case "auto": + mode = ColorMode.Auto; + return true; + case "always": + mode = ColorMode.Always; + return true; + case "never": + mode = ColorMode.Never; + return true; + default: + mode = ColorMode.Auto; + return false; + } + } + + /// + /// Colorize a symbol kind name with ANSI escape codes for terminal output. + /// Honors the active ; in + /// falls back to 's env + TTY policy. + /// シンボル種別名を ANSI エスケープコードで色付けする。 を尊重し、 + /// auto では環境変数と TTY 自動判定にフォールバックする。 + /// + public static string ColorizeKind(string kind, int padWidth = 0) + { + var padded = padWidth > 0 ? kind.PadRight(padWidth) : kind; + if (JsonOutputDepth.Value <= 0 && ShouldUseColor()) + { + var color = GetKindColorCode(kind, ResolveColorPalette()); + if (color.Length > 0) + return $"{color}{padded}\x1b[0m"; + } + return padded; + } + + // Per-palette SGR introducer for a given symbol kind. Basic stays within + // the 8 standard ANSI colors (30–37) and intentionally avoids + // `\x1b[90m` (bright-black / dim), which is unreadable on many minimal + // SSH / CI terminals; namespace / import fall back to plain white (37). + // 各パレットでのシンボル種別ごとの SGR コード。Basic は標準8色のみで + // dim (`\x1b[90m`) を避け、SSH/CI 端末でも可読性を確保する。 + internal static string GetKindColorCode(string kind, ColorPalette palette) => palette switch + { + ColorPalette.Truecolor => kind switch + { + "class" => "\x1b[38;2;102;217;239m", // bright cyan + "struct" => "\x1b[38;2;102;217;239m", + "interface" => "\x1b[38;2;102;160;255m", // bright blue + "enum" => "\x1b[38;2;215;110;215m", // bright magenta + "function" => "\x1b[38;2;255;215;75m", // gold yellow + "property" => "\x1b[38;2;160;230;100m", // bright green + "event" => "\x1b[38;2;255;100;100m", // bright red + "delegate" => "\x1b[38;2;215;110;215m", + "namespace" => "\x1b[38;2;180;180;180m", // light gray (readable on dark + light bg) + "import" => "\x1b[38;2;180;180;180m", + _ => "", + }, + ColorPalette.Color256 => kind switch + { + "class" => "\x1b[38;5;81m", // cyan + "struct" => "\x1b[38;5;81m", + "interface" => "\x1b[38;5;75m", // blue + "enum" => "\x1b[38;5;213m", // magenta + "function" => "\x1b[38;5;221m", // gold + "property" => "\x1b[38;5;120m", // green + "event" => "\x1b[38;5;203m", // salmon red + "delegate" => "\x1b[38;5;213m", + "namespace" => "\x1b[38;5;245m", // medium gray (not as dim as 90m) + "import" => "\x1b[38;5;245m", + _ => "", + }, + _ => kind switch + { + "class" => "\x1b[36m", // cyan / シアン + "struct" => "\x1b[36m", // cyan / シアン + "interface" => "\x1b[34m", // blue / 青 + "enum" => "\x1b[35m", // magenta / マゼンタ + "function" => "\x1b[33m", // yellow / 黄 + "property" => "\x1b[32m", // green / 緑 + "event" => "\x1b[31m", // red / 赤 + "delegate" => "\x1b[35m", // magenta / マゼンタ + "namespace" => "\x1b[37m", // white (instead of dim 90m) / 白(dim 回避) + "import" => "\x1b[37m", // white (instead of dim 90m) / 白(dim 回避) + _ => "", + }, + }; + + internal static bool ShouldUseInteractiveConsole() + => ShouldUseInteractiveConsole( + Console.IsOutputRedirected, + Console.Out.Encoding, + Console.Out is StringWriter, + HasTerminalEnvironmentHint(), + IsTerminalEnvironmentDisabled(), + OperatingSystem.IsWindows()); + + internal static bool ShouldUseInteractiveConsole( + bool isOutputRedirected, + Encoding outputEncoding, + bool isTextWriterCapture, + bool hasTerminalEnvironmentHint, + bool isTerminalEnvironmentDisabled, + bool isWindows) + { + if (isOutputRedirected) + return false; + + if (isTerminalEnvironmentDisabled) + return false; + + // StringWriter-based test capture leaves the process console attached, so + // Console.IsOutputRedirected stays false even though interactive terminal + // behavior would be unsafe. Detect it directly instead of inferring from + // encoding, because real terminals may expose UTF-8 or UTF-16 independently + // of ConPTY/ANSI support. + if (isTextWriterCapture) + return false; + + return isWindows || hasTerminalEnvironmentHint; + } + + internal static bool ShouldUseAnsiOutput() + => ShouldUseAnsiOutput( + Console.IsOutputRedirected, + Console.Out.Encoding, + Console.Out is StringWriter, + HasTerminalEnvironmentHint(), + IsTerminalEnvironmentDisabled(), + OperatingSystem.IsWindows(), + GetWindowsVirtualTerminalProcessingEnabled()); + + internal static bool ShouldUseAnsiOutput( + bool isOutputRedirected, + Encoding outputEncoding, + bool isTextWriterCapture, + bool hasTerminalEnvironmentHint, + bool isTerminalEnvironmentDisabled, + bool isWindows, + bool windowsVirtualTerminalProcessingEnabled) + { + if (!ShouldUseInteractiveConsole(isOutputRedirected, outputEncoding, isTextWriterCapture, hasTerminalEnvironmentHint, isTerminalEnvironmentDisabled, isWindows)) + return false; + + if (!isWindows) + return true; + + return windowsVirtualTerminalProcessingEnabled || hasTerminalEnvironmentHint; + } + + /// + /// Decide whether ANSI color escapes should be emitted. Precedence (highest first): + /// 1. Explicit from `--color` flag (Always/Never short-circuit). + /// 2. CLICOLOR_FORCE (any non-empty value other than "0") — force color on. + /// 3. NO_COLOR (any non-empty value) — color off. + /// 4. CLICOLOR=0 — color off. + /// 5. Otherwise fall back to . + /// ANSI 色エスケープを出力するかを判定する。`--color` フラグ > 環境変数 > TTY 判定。 + /// + public static bool ShouldUseColor() + { + if (_colorMode == ColorMode.Always) + return true; + if (_colorMode == ColorMode.Never) + return false; + if (IsForceColorRequested()) + return true; + if (IsNoColorRequested()) + return false; + return ShouldUseAnsiOutput(); + } + + private static bool HasTerminalEnvironmentHint() + { + if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("WT_SESSION"))) + return true; + if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("WT_PROFILE_ID"))) + return true; + if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("TERM_PROGRAM"))) + return true; + + var term = CdidxEnvironment.GetEnvironmentVariable("TERM"); + return !string.IsNullOrWhiteSpace(term) + && !term.Equals("dumb", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTerminalEnvironmentDisabled() + => IsDumbTerminal() || IsCiEnvironment(); + + private static bool IsCiEnvironment() + { + var ci = CdidxEnvironment.GetEnvironmentVariable("CI"); + return !string.IsNullOrEmpty(ci) + && !ci.Equals("0", StringComparison.OrdinalIgnoreCase) + && !ci.Equals("false", StringComparison.OrdinalIgnoreCase) + && !ci.Equals("no", StringComparison.OrdinalIgnoreCase) + && !ci.Equals("off", StringComparison.OrdinalIgnoreCase); + } + + private static bool GetWindowsVirtualTerminalProcessingEnabled() + { + if (!OperatingSystem.IsWindows()) + return false; + + if (_windowsVirtualTerminalProcessingEnabled is { } cached) + return cached; + + var detected = (_windowsVirtualTerminalProcessingDetectorForTests ?? DetectWindowsVirtualTerminalProcessing)(); + _windowsVirtualTerminalProcessingEnabled = detected; + return detected; + } + + private static bool DetectWindowsVirtualTerminalProcessing() + { + var handle = GetStdHandle(StdOutputHandle); + if (handle == IntPtr.Zero || handle == new IntPtr(-1)) + return false; + + return GetConsoleMode(handle, out var mode) + && (mode & EnableVirtualTerminalProcessing) != 0; + } + + internal static void SetWindowsVirtualTerminalProcessingDetectorForTests(Func? detector) + { + _windowsVirtualTerminalProcessingDetectorForTests = detector; + _windowsVirtualTerminalProcessingEnabled = null; + } + + internal static void ResetTerminalCapabilityCacheForTests() + { + _windowsVirtualTerminalProcessingEnabled = null; + _windowsVirtualTerminalProcessingDetectorForTests = null; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int nStdHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); + + private static bool IsForceColorRequested() + { + var force = CdidxEnvironment.GetEnvironmentVariable("CLICOLOR_FORCE"); + return !string.IsNullOrEmpty(force) && force != "0"; + } + + private static bool IsNoColorRequested() + { + var noColor = CdidxEnvironment.GetEnvironmentVariable("NO_COLOR"); + if (!string.IsNullOrEmpty(noColor)) + return true; + + var cliColor = CdidxEnvironment.GetEnvironmentVariable("CLICOLOR"); + return cliColor == "0"; + } + +} diff --git a/src/CodeIndex/Cli/ConsoleUi.Help.cs b/src/CodeIndex/Cli/ConsoleUi.Help.cs new file mode 100644 index 000000000..89cfa92ea --- /dev/null +++ b/src/CodeIndex/Cli/ConsoleUi.Help.cs @@ -0,0 +1,696 @@ +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace CodeIndex.Cli; + +public static partial class ConsoleUi +{ + public static void PrintUsage(bool showBanner = true) + => PrintUsageBrief(showBanner); + + public static void PrintUsageBrief(bool showBanner = true) + { + if (showBanner) + { + PrintBanner(); + } + + Console.WriteLine("Usage:"); + Console.WriteLine(" cdidx "); + Console.WriteLine(" cdidx [options]"); + Console.WriteLine(" cdidx --help-all"); + Console.WriteLine(" cdidx --help-flags"); + Console.WriteLine(); + PrintCommandSummary(); + Console.WriteLine(); + Console.WriteLine("Run `cdidx --help-all` for every command and option, `cdidx --help-flags` for shared flags, or `cdidx --help` for one command."); + Console.WriteLine(); + Console.WriteLine("Examples:"); + Console.WriteLine(" cdidx ./myproject"); + Console.WriteLine(" cdidx search \"authenticate\""); + Console.WriteLine(" cdidx inspect Run --body --exclude-tests"); + } + + public static void PrintUsageFull(bool showBanner = true) + { + if (showBanner) + { + PrintBanner(); + } + + var helpWidth = ShouldUseInteractiveConsole() ? Math.Min(GetWindowWidth(), 120) : 0; + void WriteHelpLine(string line = "") + { + if (helpWidth <= 0) + { + Console.WriteLine(line); + return; + } + + foreach (var wrapped in WrapHelpLine(line, helpWidth)) + Console.WriteLine(wrapped); + } + + Console.WriteLine("Usage:"); + Console.WriteLine(" cdidx "); + foreach (var (name, usage) in CommandUsageLines) + { + if (HiddenCommandUsageNames.Contains(name)) + continue; + + WriteHelpLine($" {usage}"); + } + Console.WriteLine(); + PrintCommandSummary(); + Console.WriteLine(); + PrintFlagReference(WriteHelpLine); + Console.WriteLine(); + PrintExamples(); + } + + public static void PrintFlagUsage(bool showBanner = true) + { + if (showBanner) + { + PrintBanner(); + } + + var helpWidth = ShouldUseInteractiveConsole() ? Math.Min(GetWindowWidth(), 120) : 0; + void WriteHelpLine(string line = "") + { + if (helpWidth <= 0) + { + Console.WriteLine(line); + return; + } + + foreach (var wrapped in WrapHelpLine(line, helpWidth)) + Console.WriteLine(wrapped); + } + + Console.WriteLine("Usage:"); + Console.WriteLine(" cdidx --help-flags"); + Console.WriteLine(); + PrintFlagReference(WriteHelpLine); + Console.WriteLine(); + Console.WriteLine("Run `cdidx --help-all` to show commands and examples."); + } + + private static void PrintCommandSummary() + { + Console.WriteLine("Commands:"); + Console.WriteLine(" help [subcommand] Show help without running the command"); + Console.WriteLine(" index Build or update the index for a project"); + Console.WriteLine(" hooks Install, uninstall, or inspect git hook integration"); + Console.WriteLine(" backfill-fold Upgrade folded-name columns in an existing index DB"); + Console.WriteLine(" optimize Optimize FTS5 segments in an existing index DB"); + Console.WriteLine(" vacuum Reclaim free SQLite pages from an existing index DB"); + Console.WriteLine(" search Full-text search across indexed chunks"); + Console.WriteLine(" recipes List built-in search audit recipes"); + Console.WriteLine(" audit Run a built-in search audit recipe"); + Console.WriteLine(" definition Resolve symbol definitions with extracted ranges"); + Console.WriteLine(" goto Return one best LSP Location for a definition"); + Console.WriteLine(" references Find indexed references for a symbol (--kind uses reference kind)"); + Console.WriteLine(" callers Find callers of a symbol (--kind uses reference kind)"); + Console.WriteLine(" callees Find callees used by a caller (--kind uses reference kind)"); + Console.WriteLine(" symbols [query] Search symbols (functions, classes, imports)"); + Console.WriteLine(" files [query|glob] List indexed files (* and ? positionals use path-glob semantics)"); + Console.WriteLine(" find Find literal substring matches inside known indexed files"); + Console.WriteLine(" excerpt Reconstruct a line-range excerpt from indexed chunks"); + Console.WriteLine(" map Show a repo-level overview for AI orientation"); + Console.WriteLine(" inspect Bundle definition, graph, and nearby symbol context"); + Console.WriteLine(" outline Show a file outline ordered by line, start column, kind, and name"); + Console.WriteLine(" status Show database statistics; add --check for freshness, --config for effective config, --explain for field details, or --log-path for logs"); + Console.WriteLine(" workspace List manifest members and manage the active workspace"); + Console.WriteLine(" config show Show resolved workspace config and precedence"); + Console.WriteLine(" upgrade Check for and install the latest release via install.sh"); + Console.WriteLine(" validate-config Validate .cdidx/config.json or .cdidxrc.json"); + Console.WriteLine(" doctor Print a redacted environment summary or env-var inventory for bug reports"); + Console.WriteLine(" db --integrity-check Run SQLite `PRAGMA integrity_check` and report findings"); + Console.WriteLine(" db schema Dump SQLite schema entries and PRAGMA user_version"); + Console.WriteLine(" db prune --dry-run|--apply Count or delete orphaned DB rows"); + Console.WriteLine(" diff Compare two index databases; exit 0 identical, 1 drift, 2 schema mismatch, 3 unreadable"); + Console.WriteLine(" report --output Build a redacted crash-repro tarball (.tgz/.tar.gz); --json reports stdout metadata"); + Console.WriteLine(" validate Report encoding issues (U+FFFD origin/severity, BOM, null bytes, mixed line endings, UTF-16 BOM, likely non-UTF8)"); + Console.WriteLine(" impact Show transitive callers; type queries may return heuristic file-level dependency hints"); + Console.WriteLine(" deps Show file-level dependency edges from the reference graph"); + Console.WriteLine(" unused Find symbols defined but never referenced (dead code)"); + Console.WriteLine(" hotspots Find high-impact symbols; duplicate-name families may fall back conservatively"); + Console.WriteLine(" suggestions Add, list, inspect, and export local suggestion history"); + Console.WriteLine(" export Export ctags or a portable CodeIndex archive"); + Console.WriteLine(" import Import a portable CodeIndex archive"); + Console.WriteLine(" languages List supported languages and their capabilities"); + Console.WriteLine(" batch Run newline-delimited JSON query commands with one DB connection"); + Console.WriteLine(" mcp Start MCP server (for AI tools: Claude, Cursor, etc.)"); + Console.WriteLine(" lsp Start LSP server over stdio (for LSP-native editors)"); + Console.WriteLine(" completions Generate shell completions for bash, zsh, fish, or PowerShell"); + Console.WriteLine(" license Show licensing, trademark, and commercial-use summary"); + } + + private static void PrintFlagReference(Action WriteHelpLine) + { + Console.WriteLine(); + Console.WriteLine("Index and update options:"); + Console.WriteLine(" --db Database file path (default for index: /.cdidx/codeindex.db)"); + WriteHelpLine(" .cdidxignore Optional project-local ignore file; loaded after .gitignore in each directory"); + Console.WriteLine(" --rebuild Delete existing DB and rebuild from scratch"); + Console.WriteLine(" --verbose Show per-file status ([OK ]/[SKIP]/[DEL ]/[ERR ])"); + Console.WriteLine(" --dry-run Scan files without writing to the database"); + WriteHelpLine($" --dry-run-path-limit Dry run only: process at most candidate paths before returning truncated lower-bound estimates (default: {IndexCommandRunner.DefaultDryRunPathLimit}, max: {IndexCommandRunner.MaxDryRunPathLimit})"); + Console.WriteLine(" --force Bypass the per-database index lock; only use when no other cdidx index is active"); + WriteHelpLine(" --symbols-only Build chunks and symbols but skip reference extraction; graph queries stay degraded until a normal index run"); + Console.WriteLine(" --json Output results as JSON (for AI/machine use)"); + Console.WriteLine(" --memory-trace Include phase memory samples in index JSON output"); + Console.WriteLine(" --quiet, -q, --silent Suppress informational stderr output; errors still print (also honors CDIDX_QUIET=1)"); + Console.WriteLine(" --duration-format Index elapsed time format: `auto` (default), `seconds`, or `hms`; JSON keeps raw elapsed_ms"); + WriteHelpLine(" --notify Long index completion signal: auto, bell, osc9, desktop, or none (also honors CDIDX_NOTIFY; quiet/json suppress it)"); + WriteHelpLine(" --max-file-bytes Index only files up to this size (default: 4MiB; also honors CDIDX_MAX_FILE_BYTES; accepts K/M/G suffixes)"); + WriteHelpLine(" --max-symbols-per-file Skip file content, symbols, and references when one file emits too many symbols (default: 5000; max: 50000)"); + WriteHelpLine(" --max-references-per-file Skip references when one file emits too many references (default: 100000; max: 1000000)"); + WriteHelpLine(" --parallelism Full-scan extraction workers (default: CPU count capped at 8; explicit max: 16; also honors CDIDX_INDEX_PARALLELISM)"); + WriteHelpLine(" --follow-symlinks Symlink policy for directories and files: none (default), internal, or all"); + WriteHelpLine(" --include-symbol-kind [,] Keep only matching symbol kinds during indexing"); + WriteHelpLine(" --exclude-symbol-kind [,] Drop matching symbol kinds during indexing"); + Console.WriteLine(" --commits [commit-ref ...]"); + Console.WriteLine($" Update only files changed in the specified git commits (preferred after commits; max {IndexCommandRunner.MaxCommitRefCount} refs, {IndexCommandRunner.MaxCommitRefLength} chars each)"); + Console.WriteLine(" --changed-between "); + Console.WriteLine(" Update only files changed between two git refs (useful after branch switches)"); + Console.WriteLine(" --files [path ...] Update only the specified files; old rename/delete paths are not purged unless also listed"); + WriteHelpLine(" --watch After the initial scan, stay running and reindex on file changes (FileSystemWatcher / inotify / FSEvents); rejects --commits / --changed-between / --files / --dry-run"); + Console.WriteLine($" --debounce Watch only: coalesce bursts of file events into one update after of quiet (default: {IndexWatchRunner.DefaultDebounceMs}, max {IndexWatchRunner.MaxDebounceMs})"); + WriteHelpLine($" --watch-pending-path-limit Watch only: pending changed-path queue limit before falling back to a full rescan (default: {IndexWatchRunner.DefaultWatchPendingPathLimit}, max: {IndexWatchRunner.MaxWatchPendingPathLimit}; also honors {IndexCommandRunner.WatchPendingPathLimitEnvironmentVariable})"); + Console.WriteLine(" --optimize index only: optimize the existing FTS5 table for this project's DB without scanning files"); + WriteHelpLine(" --color Color output: `auto` (default), `always`, or `never`; flag wins over `CLICOLOR_FORCE` / `NO_COLOR` / `CLICOLOR` env vars, which win over TTY auto-detect"); + WriteHelpLine(" --palette ANSI palette: `basic` (8-color, default fallback), `256`, or `truecolor`; flag wins over `CDIDX_COLOR_PALETTE` env var, which wins over `COLORTERM` / `TERM` auto-detect"); + WriteHelpLine(" --ascii Use ASCII spinner/progress glyphs instead of Unicode glyphs (also honors CDIDX_ASCII=1, NO_UNICODE, TERM=dumb, accessibility env hints, and non-UTF-8 locales)"); + WriteHelpLine(" --no-progress Disable animated progress/spinner output (also honors CDIDX_DISABLE_PROGRESS=1 and PREFERS_REDUCED_MOTION)"); + Console.WriteLine(" --metrics Append one JSONL record per CLI command / MCP tool call to (also honors CDIDX_METRICS=)"); + Console.WriteLine(" --log-format Persistent stderr log format (also honors CDIDX_LOG_FORMAT)"); + Console.WriteLine(" --log-retain-count Persistent stderr log file retention count (also honors CDIDX_LOG_RETAIN)"); + Console.WriteLine(" --log-max-size-mb Persistent stderr log rotation size cap in MiB (also honors CDIDX_LOG_MAX_SIZE_MB)"); + WriteHelpLine(" --debug-unsafe Allow raw debug dumps only when CDIDX_DEBUG=unsafe is also set; local troubleshooting only"); + WriteHelpLine(" --strict-version Treat workspace version pin mismatches as exit code 64 instead of warnings"); + Console.WriteLine(" --help, -h Show this help message"); + Console.WriteLine(" --version, -V Show version information"); + Console.WriteLine(" --license Show licensing, trademark, and commercial-use summary"); + Console.WriteLine(" --completions Generate shell completions (bash, zsh, fish, powershell)"); + Console.WriteLine(); + Console.WriteLine("Update workflows:"); + Console.WriteLine(" Use --commits with a project path after normal commits; git diff sees rename/delete paths too."); + Console.WriteLine(" Use --changed-between after switching branches to refresh only changed files."); + Console.WriteLine(" Use --files only for known in-place edits or new files; old rename/delete paths stay indexed unless also listed."); + Console.WriteLine(" Incremental writes optimize FTS5 opportunistically after a small maintenance threshold; run `cdidx optimize` for manual maintenance."); + Console.WriteLine(); + Console.WriteLine("Query options:"); + Console.WriteLine(" --db Database file path (default: .cdidx/codeindex.db in current directory)"); + WriteHelpLine(" --json Output as JSON (search/symbols/files stream ndjson by default; search/symbols/files/validate accept --json=array for one array)"); + WriteHelpLine(" --verbose Query commands: emit debug diagnostics to stderr; with --json, append an _debug JSON object"); + WriteHelpLine(" --quiet, -q, --silent Query commands: suppress informational stderr output, including zero-result hints and summaries; errors still print. Overrides --verbose stderr text."); + WriteHelpLine(" --profile Read commands: append SQL timing, row-count, and EXPLAIN QUERY PLAN JSON after the normal result"); + WriteHelpLine(" --slow-query-ms Read commands: log profiled SQL statements that take at least ms (use 0 to log every statement)"); + Console.WriteLine(" --limit , --top , --max-results "); + Console.WriteLine(" Max results to return (default: 20)"); + Console.WriteLine(" --lang Filter by language (aliases: bat, cmd, cshtml, razor, ts, tsx, cts, mts)"); + Console.WriteLine(" --path Restrict matches to glob-style path patterns (* and ?)"); + WriteHelpLine($" --query Pass a query literal, useful when the query starts with '-' (`search`/`find` max {QueryLimits.MaxQueryLength} chars)"); + WriteHelpLine(" --named-query = search only: add a named ad hoc batch query; repeat to run related searches with grouped compact results"); + Console.WriteLine(" --exclude-path Exclude glob-style path patterns (* and ?) (repeatable)"); + Console.WriteLine(" --exclude-tests Exclude likely test files"); + WriteHelpLine(" --audit-scope search/unused: source uses production-code cleanup defaults; all disables source-scope defaults"); + Console.WriteLine(" --source-only search only: shorthand for --audit-scope source on ad hoc and named searches"); + Console.WriteLine(" --exclude-comments search only: suppress comment-only matches"); + Console.WriteLine(" --exclude-strings search only: suppress string, regex, and help-text matches"); + Console.WriteLine(" --exclude-fixtures search only: suppress fixture-only matches in tests"); + WriteHelpLine(" --origin/--match-origin search only: keep only matches from selected origins (code, comment, string_literal, regex_literal, help_text, unknown; repeatable or comma-separated)"); + WriteHelpLine(" --exclude-origin search only: drop matches from selected origins while keeping other origins in the same result"); + Console.WriteLine(" --include-generated Include generated files in query results"); + Console.WriteLine(" --snippet-lines search/find snippet length (1-20, default: search 8; find 1)"); + Console.WriteLine(" --snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)"); + WriteHelpLine($" --max-line-width search/references/callers/callees/find/excerpt/impact/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: {LineWidthFormatter.DefaultMaxLineWidth})"); + WriteHelpLine(" --focus-line find/excerpt: focus a line; excerpt keeps the leading window when no column is supplied"); + Console.WriteLine(" --focus-column find/excerpt: focus a specific 1-based column"); + Console.WriteLine(" --focus-length excerpt: width of the focused span (default: 1, requires --focus-column)"); + Console.WriteLine(" --no-semantic-tokens excerpt JSON: omit semantic_tokens for compact line/window payloads"); + WriteHelpLine($" --fts Use raw FTS5 query syntax for search (content:term, NEAR(a b, 5), OR, NOT, groups, prefix*, \"phrase\"; search query max {QueryLimits.MaxQueryLength} chars; raw FTS parser max {DbReader.MaxRawFtsQueryLength} chars, {DbReader.MaxRawFtsBooleanOperators} boolean ops, {DbReader.MaxRawFtsNearOperators} NEAR ops; trailing * is a prefix shorthand in literal-safe mode)"); + Console.WriteLine(" --exact Backward-compatible shorthand."); + Console.WriteLine(" Prefer --exact-substring for search,"); + Console.WriteLine(" --exact for find,"); + Console.WriteLine(" and --exact-name for symbol/graph lookups."); + Console.WriteLine(" Combining exact-match flags is rejected."); + Console.WriteLine(" --exact-substring Search only: case-sensitive exact substring"); + Console.WriteLine(" (no FTS5)"); + Console.WriteLine(" --token-boundary Search only: exact substring plus code-token"); + Console.WriteLine(" boundaries; excludes longer identifiers."); + Console.WriteLine(" --exact-name Exact name match for symbols, definition,"); + Console.WriteLine(" references, callers, callees, and inspect."); + Console.WriteLine(" Uses NFKC + Unicode CaseFold when ready."); + Console.WriteLine(" Legacy/stale-fold DBs fall back to ASCII NOCASE;"); + Console.WriteLine(" run `cdidx backfill-fold` or check fold_ready."); + WriteHelpLine(" --kind definition/symbols/outline/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation/type_tag/bcl_regex_without_timeout); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind"); + WriteHelpLine(" --sort Symbols/outline: order audit output by a ranking signal; outline also accepts source, kind, references, size, complexity, path, and name"); + Console.WriteLine(" --severity validate only: filter issues by severity: info, warning, error"); + Console.WriteLine(" --visibility Filter symbols/definitions/unused/hotspots by visibility: public, protected, internal, private"); + WriteHelpLine(" --exclude-visibility Exclude symbols/definitions/unused/hotspots by visibility"); + WriteHelpLine(" --count Count only; result limits are ignored by count modes, but scan caps can still mark approximate counts as degraded"); + WriteHelpLine(" --group-partials definition/symbols/inspect symbol mode: collapse partial type declarations into logical families while preserving physical counts and definition_sites"); + WriteHelpLine(" --bucket unused only: filter one unused confidence bucket"); + WriteHelpLine(" --min-confidence unused only: filter medium or low confidence candidates; --confidence is an alias"); + WriteHelpLine(" --all unused only: include low-confidence contract-domain candidates suppressed by default"); + WriteHelpLine(" --actionable unused only: preset for private medium-confidence cleanup candidates"); + Console.WriteLine(" --since Filter to files modified since this timestamp (ISO 8601)"); + Console.WriteLine(" --no-dedup search only: return every raw overlapping chunk hit (debug/density)"); + WriteHelpLine($" --require-before/--require-after search only: keep primary matches only when the guard query appears within --guard-window lines before/after the match (default {DbReader.DefaultSearchGuardWindow}, max {DbReader.MaxSearchGuardWindow})"); + WriteHelpLine(" --reject-before/--reject-after search only: drop primary matches when the guard query appears within the same before/after window; useful for finding API calls missing nearby checks"); + WriteHelpLine(" --guard-scope search only: evaluate guards in the line window (default) or only on the same line before/after the primary match"); + WriteHelpLine(" --bytes files: sort by size and show raw byte counts in human output; map: show raw byte counts; JSON always keeps raw integer bytes"); + Console.WriteLine(" --min-entrypoint-confidence map only: omit entrypoint candidates below this 0.0..1.0 confidence"); + WriteHelpLine(" --max-hops Max BFS hops for impact analysis, inclusive (default: 5; --max-hops 2 returns callers at hop 1 and 2; --max-hops 0 resolves the symbol without traversing callers)"); + Console.WriteLine(" --depth Deprecated alias for --max-hops"); + Console.WriteLine(" --reverse Reverse direction for deps (show dependents)"); + WriteHelpLine(" --group-by search: with --count, group rows by file, symbol, origin, return-type, or subsystem; hotspots: group by symbol or file, or by statement only with --lang sql"); + WriteHelpLine(" --group-by-name hotspots: collapse rows sharing (name, kind) across files; JSON keeps capped paths plus full definition_site_details"); + WriteHelpLine(" --with-paths impact: also emit `paths` per caller — the shortest call chains [root, ..., caller] (diamond graphs surface every converging route, capped per row)"); + WriteHelpLine(" unused reflection note C# nameof/typeof and direct reflection member-name literals such as GetMethod(\"Foo\") are indexed; dynamically constructed reflection names may need manual review"); + WriteHelpLine(" Note: if a query itself starts with '-', pass it with --query or -- ; for option values that start with '--', use --opt=."); + } + + private static void PrintExamples() + { + Console.WriteLine("Examples:"); + Console.WriteLine(" cdidx ./myproject Index a project"); + Console.WriteLine(" cdidx backfill-fold Upgrade folded-name columns in an existing DB"); + Console.WriteLine(" cdidx optimize --dry-run --json Preview FTS5 optimization work without writing"); + Console.WriteLine(" cdidx optimize Optimize FTS5 segments in an existing DB"); + Console.WriteLine(" cdidx vacuum --dry-run --json Estimate DB free pages and maintenance guidance"); + Console.WriteLine(" cdidx index ./myproject --commits abc123 Update DB from one commit"); + Console.WriteLine(" cdidx index ./myproject --commits abc123 def456"); + Console.WriteLine(" Update DB from multiple commits"); + Console.WriteLine(" cdidx index ./myproject --changed-between main feature"); + Console.WriteLine(" Update DB from files changed between two refs"); + Console.WriteLine(" cdidx index ./myproject --files src/app.cs Update specific files"); + Console.WriteLine(" cdidx index ./myproject --watch Run an initial scan, then keep the index live as files change (Ctrl+C to stop)"); + Console.WriteLine(" cdidx export ctags --output tags Export editor tags for Vim, Emacs, and Sublime"); + Console.WriteLine(" cdidx export codeindex.cdidx.zip Export a portable CodeIndex archive"); + Console.WriteLine(" cdidx import codeindex.cdidx.zip Import a portable CodeIndex archive"); + Console.WriteLine(" cdidx import codeindex.cdidx.zip --dry-run Validate an archive without replacing the DB"); + Console.WriteLine(" cdidx search \"authenticate\" Full-text search"); + Console.WriteLine(" cdidx search \"auth*\" Prefix shorthand in literal-safe mode"); + Console.WriteLine(" cdidx search --query --path --path README.md Search for a literal option token"); + Console.WriteLine(" cdidx search --named-query pack=\"dotnet pack\" --named-query push=\"nuget push\" --format compact"); + Console.WriteLine(" Run named ad hoc searches with compact snippets"); + Console.WriteLine(" cdidx search \"Run();\" --exact-substring Case-sensitive exact substring search"); + Console.WriteLine(" cdidx search \"File.ReadAllText\" --exact-substring --reject-before \"Length\" --guard-window 8"); + Console.WriteLine(" Find calls without a nearby preceding size guard"); + Console.WriteLine(" cdidx search authenticate --json=array Emit search results as one JSON array"); + Console.WriteLine(" cdidx search authenticate --profile Append SQL profile JSON for slow-query debugging"); + Console.WriteLine(" cdidx search authenticate --verbose Emit query debug diagnostics on stderr"); + Console.WriteLine(" cdidx definition ResolveGitCommonDir --body Show a symbol definition and body"); + Console.WriteLine(" cdidx references ResolveGitCommonDir Find indexed references"); + Console.WriteLine(" cdidx references DbContext --kind instantiate Filter constructor sites by reference kind"); + Console.WriteLine(" cdidx references e --path dist/app.js --max-line-width 120"); + Console.WriteLine(" Clamp a minified single-line context window"); + Console.WriteLine(" cdidx excerpt src/app.js --start 120 --focus-column 88 --max-line-width 120"); + Console.WriteLine(" Keep the requested token visible inside a long line"); + Console.WriteLine(" cdidx callers ResolveGitCommonDir Find callers"); + Console.WriteLine(" cdidx callees AddToGitExclude Find callees used by a caller"); + Console.WriteLine(" cdidx symbols Run --exact-name Exact symbol-name match"); + Console.WriteLine(" cdidx symbols UserService --kind class Find class definitions"); + Console.WriteLine(" cdidx find guard --path src/Auth.cs --after 2 Find literal matches inside a known file"); + Console.WriteLine(" cdidx find --path README.md -- --path Search a literal that starts with '-'"); + Console.WriteLine(" cdidx excerpt src/app.cs --start 10 --end 20 Reconstruct a file excerpt"); + Console.WriteLine(" cdidx map --path src/ --exclude-tests Show a repo map for source code"); + Console.WriteLine(" cdidx inspect Run --body --exclude-tests Inspect one symbol with bundled context"); + Console.WriteLine(" cdidx outline src/app.cs --json Symbol outline of a single file"); + Console.WriteLine(" cdidx deps --path src/ --exclude-tests Show file-level dependency edges"); + Console.WriteLine(" cdidx deps --reverse --path src/app.cs Show what depends on a file"); + Console.WriteLine(" cdidx unused --lang csharp --actionable Find private cleanup candidates"); + Console.WriteLine(" cdidx hotspots --lang csharp --exclude-tests Find high-impact symbols with conservative duplicate fallback"); + Console.WriteLine(" cdidx hotspots --group-by=file --json Compare hotspot volume by target file"); + Console.WriteLine(" cdidx hotspots --group-by-name --exclude-tests Collapse same-name hotspots across files"); + Console.WriteLine(" cdidx impact Run --max-hops 0 --exclude-tests Resolve a symbol without traversing callers"); + Console.WriteLine(" cdidx impact FolderDiffService --json Type query may return heuristic file-level dependency hints"); + Console.WriteLine(" cdidx files --lang python List Python files"); + Console.WriteLine(" cdidx files --since 2024-01-01 Files modified since a date"); + Console.WriteLine(" cdidx status --json DB stats as JSON"); + Console.WriteLine(" cdidx status --config Effective configuration as JSON"); + Console.WriteLine(" cdidx validate-config Validate checked-in config"); + Console.WriteLine(" cdidx languages Show supported languages"); + Console.WriteLine(" cdidx --completions zsh > ~/.zfunc/_cdidx Generate a zsh completion script"); + Console.WriteLine(" cdidx license Show licensing and commercial-use terms"); + } + + internal static IReadOnlyList WrapHelpLine(string line, int maxWidth) + { + if (maxWidth <= 0 || line.Length <= maxWidth) + return [line]; + + var continuationIndent = GetHelpContinuationIndent(line); + return WrapLineByWords(line, maxWidth, continuationIndent); + } + + private static string GetHelpContinuationIndent(string line) + { + var leading = 0; + while (leading < line.Length && line[leading] == ' ') + leading++; + + for (var i = leading + 1; i < line.Length - 1; i++) + { + if (line[i] == ' ' && line[i + 1] == ' ') + { + while (i < line.Length && line[i] == ' ') + i++; + if (i < line.Length) + return new string(' ', i); + break; + } + } + + return new string(' ', Math.Min(leading + 2, 8)); + } + + private static IReadOnlyList WrapLineByWords(string line, int maxWidth, string continuationIndent) + { + maxWidth = Math.Max(1, maxWidth); + if (continuationIndent.Length >= maxWidth) + continuationIndent = new string(' ', Math.Max(0, Math.Min(2, maxWidth - 1))); + + var lines = new List(); + var current = line; + while (current.Length > maxWidth) + { + var breakAt = current.LastIndexOf(' ', Math.Min(maxWidth, current.Length - 1)); + if (breakAt <= 0 || current[..breakAt].Trim().Length == 0) + breakAt = maxWidth; + + lines.Add(current[..breakAt].TrimEnd()); + var nextStart = breakAt < current.Length && current[breakAt] == ' ' ? breakAt + 1 : breakAt; + current = continuationIndent + current[nextStart..].TrimStart(); + } + + lines.Add(current); + return lines; + } + + public static void PrintLicenseSummary() + { + Console.WriteLine("cdidx / CodeIndex license"); + Console.WriteLine(); + Console.WriteLine("License: Functional Source License, Version 1.1, ALv2 Future License (FSL-1.1-ALv2)"); + Console.WriteLine("Copyright: Copyright 2026 Widthdom."); + Console.WriteLine("Summary: use, modification, and distribution are allowed for non-competing purposes, including internal, commercial, AI, IDE, MCP, CI, and scripting integrations."); + Console.WriteLine("Competing commercial products or services require a separate written agreement with Widthdom."); + Console.WriteLine("Names and trademarks: CodeIndex and cdidx are not licensed for derivative product, package, service, or endorsement branding."); + Console.WriteLine(); + Console.WriteLine("See LICENSE, LICENSES/FSL-1.1-ALv2.txt, LICENSES/Apache-2.0.txt, COMMERCIAL_LICENSE.md, INTEGRATION_POLICY.md, and TRADEMARKS.md for the controlling terms."); + } + + internal static LicenseJsonResult BuildLicenseJsonResult() => + new( + JsonOutputContract.ApiVersion, + new LicenseTermsJsonResult( + "FSL-1.1-ALv2", + "Functional Source License, Version 1.1, ALv2 Future License", + "Apache-2.0", + "LICENSE"), + "Copyright 2026 Widthdom.", + new LicenseCommercialUseJsonResult( + NonCompetingUseAllowed: true, + CompetingProductsOrServicesRequireSeparateAgreement: true, + "Use, modification, and distribution are allowed for non-competing purposes, including internal, commercial, AI, IDE, MCP, CI, and scripting integrations."), + new LicenseTrademarkJsonResult( + ["CodeIndex", "cdidx"], + DerivativeBrandingAllowed: false, + EndorsementBrandingAllowed: false, + "CodeIndex and cdidx are not licensed for derivative product, package, service, or endorsement branding."), + [ + "LICENSE", + "LICENSES/FSL-1.1-ALv2.txt", + "LICENSES/Apache-2.0.txt", + "COMMERCIAL_LICENSE.md", + "INTEGRATION_POLICY.md", + "TRADEMARKS.md", + ]); + + public static string? GetUsageLine(string command) + { + command = NormalizeCommandUsageName(command); + foreach (var (name, usage) in CommandUsageLines) + { + if (string.Equals(name, command, StringComparison.Ordinal)) + return usage; + } + + return null; + } + + public static bool PrintCommandUsage(string command) + { + command = NormalizeCommandUsageName(command); + var usages = GetCommandUsageLines(command); + if (usages.Count == 0) + return false; + + Console.WriteLine("Usage:"); + foreach (var usage in usages) + Console.WriteLine($" {usage}"); + var schemaCommand = GetFlagSchemaCommandName(command); + var helpFlags = string.Equals(command, schemaCommand, StringComparison.Ordinal) + && CliFlagSchema.HasAuthoritativeHelpOptions(schemaCommand) + ? CliFlagSchema.GetCompletionFlagsForCommand(schemaCommand) + : []; + if (helpFlags.Count > 0) + { + Console.WriteLine(); + Console.WriteLine("Options:"); + foreach (var flag in helpFlags) + { + var names = flag.ShortName is null ? flag.Name : $"{flag.Name}, {flag.ShortName}"; + var token = flag.ValuePlaceholder is null ? names : $"{names} {flag.ValuePlaceholder}"; + Console.WriteLine($" {token}"); + Console.WriteLine($" {flag.Description}"); + } + } + var notes = GetCommandUsageNotes(command); + if (notes.Count > 0) + { + Console.WriteLine(); + Console.WriteLine("Notes:"); + foreach (var note in notes) + Console.WriteLine($" {note}"); + } + Console.WriteLine(); + Console.WriteLine("Run `cdidx --help` to show all commands and shared options."); + return true; + } + + private static IReadOnlyList GetCommandUsageLines(string command) + { + command = NormalizeCommandUsageName(command); + var usages = new List(); + foreach (var (name, usage) in CommandUsageLines) + { + if (string.Equals(name, command, StringComparison.Ordinal) + || string.Equals(command, "index", StringComparison.Ordinal) && name.StartsWith("index-", StringComparison.Ordinal)) + { + usages.Add(usage); + } + } + + return usages; + } + + private static IReadOnlyList GetCommandUsageNotes(string command) + { + command = NormalizeCommandUsageName(command); + var notes = new List(); + foreach (var (name, note) in CommandUsageNotes) + { + if (string.Equals(name, command, StringComparison.Ordinal)) + notes.Add(note); + } + + return notes; + } + + private static string GetFlagSchemaCommandName(string command) + { + if (command.StartsWith("db-", StringComparison.Ordinal)) + return "db"; + if (command.StartsWith("hooks-", StringComparison.Ordinal)) + return "hooks"; + return command == "--completions" ? "completions" : command; + } + + private static string NormalizeCommandUsageName(string command) => + CliCommandCatalog.NormalizePublicCommandName(command); + + // --- Did-you-mean / もしかして --- + + /// + /// Find the closest matching command name using Damerau-Levenshtein distance. + /// Short commands use a stricter threshold to avoid unrelated suggestions. + /// Damerau-Levenshtein距離で最も近いコマンド名を返す。短いコマンドは無関係な推薦を避けるため閾値を厳しくする。 + /// + public static string? FindClosestCommand(string input) => + FindClosestMatch(input, CliCommandCatalog.PublicCommandNames); + + /// + /// Find the closest match for from + /// using Damerau-Levenshtein distance with the same length-aware threshold the + /// command suggester uses (#1582). Comparison is case-insensitive. Returns the original + /// (cased) candidate string, or null when no candidate is within the threshold. + /// 任意の候補集合に対して Damerau-Levenshtein 距離で最も近い候補を返す (#1582)。 + /// 短い入力には厳しめの距離閾値を適用し、無関係な推薦を避ける。比較は case-insensitive。 + /// + public static string? FindClosestMatch(string? input, IEnumerable candidates) + { + var normalized = NormalizeSuggestionInput(input); + if (normalized == null) + return null; + + string? best = null; + var bestDist = int.MaxValue; + foreach (var candidate in candidates) + { + if (string.IsNullOrEmpty(candidate)) + continue; + if (candidate.Length > MaxSuggestionInputCharLength) + continue; + var candidateNormalized = candidate.ToLowerInvariant(); + if (string.Equals(normalized, candidateNormalized, StringComparison.Ordinal)) + return candidate; + var dist = DamerauLevenshteinDistance(normalized, candidateNormalized); + if (dist > GetSuggestionDistanceThreshold(normalized.Length, candidateNormalized.Length)) + continue; + if (dist < bestDist) + { + bestDist = dist; + best = candidate; + } + } + return best; + } + + /// + /// Return up to closest candidates for , + /// ordered by Damerau-Levenshtein distance. Useful for structured suggestions in MCP + /// error payloads (#1582). Returns an empty list when no candidate is within the threshold. + /// Damerau-Levenshtein 距離で近い候補を最大 件まで返す。 + /// MCP の structured error payload で `similar_values` を返す用途を想定する (#1582)。 + /// + public static IReadOnlyList FindClosestMatches(string? input, IEnumerable candidates, int maxResults = 3) + { + var normalized = NormalizeSuggestionInput(input); + if (normalized == null || maxResults <= 0) + return Array.Empty(); + + var matches = new List<(string Candidate, int Distance)>(); + foreach (var candidate in candidates) + { + if (string.IsNullOrEmpty(candidate)) + continue; + if (candidate.Length > MaxSuggestionInputCharLength) + continue; + var candidateNormalized = candidate.ToLowerInvariant(); + if (string.Equals(normalized, candidateNormalized, StringComparison.Ordinal)) + continue; + var dist = DamerauLevenshteinDistance(normalized, candidateNormalized); + if (dist > GetSuggestionDistanceThreshold(normalized.Length, candidateNormalized.Length)) + continue; + matches.Add((candidate, dist)); + } + return matches + .OrderBy(m => m.Distance) + .ThenBy(m => m.Candidate, StringComparer.Ordinal) + .Select(m => m.Candidate) + .Take(maxResults) + .ToList(); + } + + private static string? NormalizeSuggestionInput(string? input) + { + if (input == null || input.Length > MaxSuggestionInputCharLength || string.IsNullOrWhiteSpace(input)) + return null; + + return input.ToLowerInvariant(); + } + + private static int GetSuggestionDistanceThreshold(int inputLength, int commandLength) + { + var shorter = Math.Min(inputLength, commandLength); + return shorter switch + { + <= 4 => 1, + <= 10 => 2, + _ => 3, + }; + } + + private static int DamerauLevenshteinDistance(string s, string t) + { + var n = s.Length; + var m = t.Length; + var d = new int[n + 1, m + 1]; + for (var i = 0; i <= n; i++) d[i, 0] = i; + for (var j = 0; j <= m; j++) d[0, j] = j; + for (var i = 1; i <= n; i++) + { + for (var j = 1; j <= m; j++) + { + var cost = s[i - 1] == t[j - 1] ? 0 : 1; + d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost); + if (i > 1 && j > 1 && s[i - 1] == t[j - 2] && s[i - 2] == t[j - 1]) + d[i, j] = Math.Min(d[i, j], d[i - 2, j - 2] + 1); + } + } + return d[n, m]; + } + + // --- Shell Completions / シェル補完 --- + + /// + /// Print shell completion script. Returns false for unknown shells. + /// シェル補完スクリプトを出力。不明なシェルの場合はfalseを返す。 + /// + public static bool PrintCompletions(string shell) + { + try + { + Console.WriteLine(GetCompletionScript(shell)); + return true; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + internal static string GetCompletionScript(string shell) => + ConsoleCompletionRenderer.GetCompletionScript(shell); + + // --- Helpers / ヘルパー --- + + private static ColorMode _colorMode = ColorMode.Auto; + private static ColorPalette? _explicitPalette; + private static bool? _windowsVirtualTerminalProcessingEnabled; + private static Func? _windowsVirtualTerminalProcessingDetectorForTests; + private const int StdOutputHandle = -11; + private const uint EnableVirtualTerminalProcessing = 0x0004; + + /// + /// Set the active color-output mode. and + /// short-circuit env / TTY checks in + /// ; defers to + /// the existing CLICOLOR_FORCE / NO_COLOR / CLICOLOR / TTY chain. + /// 色出力モードを設定する。Always / Never は環境変数と TTY 判定を上書きする。 + /// + public static void SetColorMode(ColorMode mode) => _colorMode = mode; + +} diff --git a/src/CodeIndex/Cli/ConsoleUi.Progress.cs b/src/CodeIndex/Cli/ConsoleUi.Progress.cs new file mode 100644 index 000000000..4d62d7ee3 --- /dev/null +++ b/src/CodeIndex/Cli/ConsoleUi.Progress.cs @@ -0,0 +1,319 @@ +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace CodeIndex.Cli; + +public static partial class ConsoleUi +{ + public static CancellationTokenSource? StartSpinner(string message, string[] frames) + { + EnsureConsoleWritersSynchronized(); + + // Braille frames are single-char; themed frames are longer strings containing the display text + // ブレイルフレームは1文字、テーマフレームは表示テキストを含む長い文字列 + bool isThemed = frames.Length > 0 && frames[0].Length > 2; + + if (!ShouldUseInteractiveConsole() || !ShouldUseProgressAnimation()) + { + Console.WriteLine(message); + return null; + } + + var cts = new SpinnerCancellationTokenSource(); + var ct = cts.Token; + var spinnerTask = BackgroundTaskObserver.Run(async token => + { + int i = 0; + while (!token.IsCancellationRequested) + { + var frame = frames[i % frames.Length]; + var line = isThemed ? $"\r{frame}" : $"\r{frame} {message}"; + lock (TerminalLock) + { + Console.Write(line); + Console.Out.Flush(); + } + i++; + try { await Task.Delay(SpinnerFrameDelayMs, token).ConfigureAwait(false); } catch (OperationCanceledException) { break; } + } + }, "cdidx", "console spinner", ct); + cts.SetSpinnerTask(spinnerTask); + return cts; + } + + /// + /// Stop spinner and clear the line. + /// スピナーを停止して行をクリア。 + /// + public static void StopSpinner(CancellationTokenSource? cts) + { + if (cts == null) return; + cts.Cancel(); + if (cts is SpinnerCancellationTokenSource spinnerCts) + { + try + { + spinnerCts.SpinnerTask.GetAwaiter().GetResult(); + } + catch + { + // Spinner shutdown is best-effort; BackgroundTaskObserver reports faults. + // spinner shutdown は best-effort。fault は BackgroundTaskObserver が報告する。 + } + } + if (ShouldUseInteractiveConsole()) + { + lock (TerminalLock) + { + Console.Write($"\r{new string(' ', GetWindowWidth() - ConsoleLineMargin)}\r"); + Console.Out.Flush(); + } + } + cts.Dispose(); + } + + private sealed class SpinnerCancellationTokenSource : CancellationTokenSource + { + private Task _spinnerTask = Task.CompletedTask; + + public Task SpinnerTask => _spinnerTask; + + public void SetSpinnerTask(Task spinnerTask) + => _spinnerTask = spinnerTask; + } + + internal static void SetProgressAnimationEnabled(bool? enabled) + => _progressAnimationEnabledOverride = enabled; + + internal static bool? GetProgressAnimationOverrideForDiagnostics() + => _progressAnimationEnabledOverride; + + internal static bool ShouldUseProgressAnimation() + { + if (_progressAnimationEnabledOverride.HasValue) + return _progressAnimationEnabledOverride.Value; + + if (IsTruthyEnvironmentVariable(DisableProgressEnvironmentVariable)) + return false; + + var reducedMotion = CdidxEnvironment.GetEnvironmentVariable(PrefersReducedMotionEnvironmentVariable); + return string.IsNullOrWhiteSpace(reducedMotion) || !IsTruthyEnvironmentValue(reducedMotion); + } + + /// + /// Get spinner frames based on easter egg flag. + /// イースターエッグフラグに基づくスピナーフレームを取得。 + /// + public static string[] GetSpinnerFrames(string? easterEgg) + { + if (!ShouldUseUnicodeGlyphs()) + return AsciiSpinnerFrames; + + return easterEgg switch + { + "--sushi" => + [ + "\U0001f363 Slicing ", "\U0001f363 Slicing. ", "\U0001f363 Slicing.. ", "\U0001f363 Slicing... ", + "\U0001f363 Shaping ", "\U0001f363 Shaping. ", "\U0001f363 Shaping.. ", "\U0001f363 Shaping... ", + "\U0001f363 Pressing ", "\U0001f363 Pressing. ", "\U0001f363 Pressing.. ", "\U0001f363 Pressing... ", + "\U0001f363 Itadakimasu! ", + ], + "--coffee" => + [ + "\u2615 Grinding ", "\u2615 Grinding. ", "\u2615 Grinding.. ", "\u2615 Grinding... ", + "\u2615 Heating ", "\u2615 Heating. ", "\u2615 Heating.. ", "\u2615 Heating... ", + "\u2615 Brewing ", "\u2615 Brewing. ", "\u2615 Brewing.. ", "\u2615 Brewing... ", + ], + "--ramen" => + [ + "\U0001f35c Boiling ", "\U0001f35c Boiling. ", "\U0001f35c Boiling.. ", "\U0001f35c Boiling... ", + "\U0001f35c Steaming ", "\U0001f35c Steaming. ", "\U0001f35c Steaming.. ", "\U0001f35c Steaming... ", + "\U0001f35c Slurping ", "\U0001f35c Slurping. ", "\U0001f35c Slurping.. ", "\U0001f35c Slurping... ", + "\U0001f35c Itadakimasu! ", + ], + "--wine" => + [ + "\U0001f377 Crushing ", "\U0001f377 Crushing. ", "\U0001f377 Crushing.. ", "\U0001f377 Crushing... ", + "\U0001f377 Aging ", "\U0001f377 Aging. ", "\U0001f377 Aging.. ", "\U0001f377 Aging... ", + "\U0001f377 Pouring ", "\U0001f377 Pouring. ", "\U0001f377 Pouring.. ", "\U0001f377 Pouring... ", + "\U0001f377 Sant\u00e9! ", + ], + "--beer" => + [ + "\U0001f37a Tapping ", "\U0001f37a Tapping. ", "\U0001f37a Tapping.. ", "\U0001f37a Tapping... ", + "\U0001f37a Pouring ", "\U0001f37a Pouring. ", "\U0001f37a Pouring.. ", "\U0001f37a Pouring... ", + "\U0001f37a Foaming ", "\U0001f37a Foaming. ", "\U0001f37a Foaming.. ", "\U0001f37a Foaming... ", + "\U0001f37a Cheers! ", + ], + "--matcha" => + [ + "\U0001f375 Sifting ", "\U0001f375 Sifting. ", "\U0001f375 Sifting.. ", "\U0001f375 Sifting... ", + "\U0001f375 Pouring ", "\U0001f375 Pouring. ", "\U0001f375 Pouring.. ", "\U0001f375 Pouring... ", + "\U0001f375 Whisking ", "\U0001f375 Whisking. ", "\U0001f375 Whisking.. ", "\U0001f375 Whisking... ", + "\U0001f375 Douzo! ", + ], + "--whisky" => + [ + "\U0001f943 Mashing ", "\U0001f943 Mashing. ", "\U0001f943 Mashing.. ", "\U0001f943 Mashing... ", + "\U0001f943 Distilling ", "\U0001f943 Distilling. ", "\U0001f943 Distilling.. ", "\U0001f943 Distilling... ", + "\U0001f943 Aging ", "\U0001f943 Aging. ", "\U0001f943 Aging.. ", "\U0001f943 Aging... ", + "\U0001f943 Slainte! ", + ], + // Default: Braille spinner / デフォルト: ブレイルスピナー + _ => DefaultBrailleSpinnerFrames, + }; + } + + // --- Progress bar / プログレスバー --- + + // Active spinner frames for progress bar (themed or default braille) + // プログレスバー用アクティブスピナーフレーム(テーマ付きまたはデフォルトブレイル) + private static string[] _progressSpinnerFrames = DefaultBrailleSpinnerFrames; + // Track last progress line length for clearing / クリア用に最後のプログレス行の長さを記録 + private static int _lastProgressLineLength; + private static bool _asciiOutputForced; + private static bool? _progressAnimationEnabledOverride; + private static bool _widthDetectionFailed; + private static bool _widthDetectionTraceWritten; + private static bool _traceWidthDetectionFailures; + + /// + /// Set progress bar spinner theme (reuses GetSpinnerFrames). + /// プログレスバーのスピナーテーマを設定(GetSpinnerFramesを再利用)。 + /// + public static void SetProgressTheme(string? easterEgg) + { + _progressSpinnerFrames = GetSpinnerFrames(easterEgg); + } + + /// + /// Print inline progress bar with spinner. + /// スピナー付きインライン進捗バーを表示。 + /// + public static void PrintProgress(int current, int total) + { + if (total <= 0) + return; + + var output = Console.Out; + var redirected = !ShouldUseInteractiveConsole(); + + // Update every 50 files or at completion / 50ファイルごと、または完了時に更新 + if (current % 50 != 0 && current != total) + return; + + var line = FormatProgressLine( + current, + total, + redirected ? 80 : GetWindowWidth(), + ShouldUseUnicodeGlyphs(), + ShouldUseProgressAnimation()); + + if (!redirected) + { + lock (TerminalLock) + { + output.Write($"\r{line}"); + output.Flush(); + _lastProgressLineLength = line.Length; + if (current == total) + { + output.WriteLine(); + _lastProgressLineLength = 0; + } + } + } + else + { + // Fallback for redirected output / リダイレクト時はフォールバック + output.WriteLine(line.TrimStart()); + } + } + + internal static string FormatProgressLine( + int current, + int total, + int windowWidth, + bool useUnicodeGlyphs, + bool useProgressAnimation = true) + { + const int barWidth = 32; + var pct = (double)current / total; + var percentAndCounts = string.Create( + CultureInfo.InvariantCulture, + $"{pct * 100,5:F1}% [{current:N0}/{total:N0}]"); + + if (useUnicodeGlyphs && windowWidth < 40) + return percentAndCounts; + + int filled = (int)Math.Round(pct * barWidth); + if (filled > barWidth) filled = barWidth; + if (filled < 0) filled = 0; + + var spinner = useProgressAnimation ? ResolveProgressSpinner(current, total, useUnicodeGlyphs) : " "; + var bar = useUnicodeGlyphs + ? new string('\u2588', filled) + new string('\u2591', barWidth - filled) + : $"[{new string('#', filled)}{new string('-', barWidth - filled)}]"; + return $"{spinner} {bar} {percentAndCounts}"; + } + + private static string ResolveProgressSpinner(int current, int total, bool useUnicodeGlyphs) + { + if (current == total) + return " "; + + return useUnicodeGlyphs + ? _progressSpinnerFrames[(current / 50) % _progressSpinnerFrames.Length] + : "-"; + } + + /// + /// Clear the current progress bar line so other output can be printed cleanly. + /// 他の出力を正しく表示するために現在のプログレスバー行をクリア。 + /// + public static void ClearProgressLine() + { + lock (TerminalLock) + { + ClearProgressLineCore(); + } + } + + private static void ClearProgressLineCore() + { + if (ShouldUseInteractiveConsole() && _lastProgressLineLength > 0) + { + Console.Write($"\r{new string(' ', _lastProgressLineLength)}\r"); + Console.Out.Flush(); + _lastProgressLineLength = 0; + } + } + + /// + /// Print a warning message, clearing the progress bar line first if needed. + /// 必要に応じてプログレスバー行をクリアしてから警告メッセージを表示。 + /// + public static void PrintWarning(string message) + { + lock (TerminalLock) + { + ClearProgressLineCore(); + CommandErrorWriter.WriteStderr($" [WARN] {message}"); + Console.Error.Flush(); + Console.Out.Flush(); + } + } + + // --- Banner / バナー --- + + /// + /// Print ASCII-art banner. + /// ASCIIアートバナーを表示。 + /// +} diff --git a/src/CodeIndex/Cli/ConsoleUi.Terminal.cs b/src/CodeIndex/Cli/ConsoleUi.Terminal.cs new file mode 100644 index 000000000..c52e4b398 --- /dev/null +++ b/src/CodeIndex/Cli/ConsoleUi.Terminal.cs @@ -0,0 +1,165 @@ +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace CodeIndex.Cli; + +public static partial class ConsoleUi +{ + internal static bool ShouldUseUnicodeGlyphs() + { + if (IsAsciiOutputRequested()) + return false; + + if (IsDumbTerminal()) + return false; + + var locale = FirstNonEmptyEnvironmentVariable("LC_ALL", "LC_CTYPE", "LANG"); + if (locale != null && !IsUnicodeLocale(locale)) + return false; + + return Console.OutputEncoding.CodePage == Encoding.UTF8.CodePage + || Console.OutputEncoding.CodePage == Encoding.Unicode.CodePage; + } + + private static bool IsAsciiOutputRequested() + { + if (_asciiOutputForced) + return true; + + var ascii = CdidxEnvironment.GetEnvironmentVariable("CDIDX_ASCII"); + if (!string.IsNullOrEmpty(ascii) && ascii != "0") + return true; + + var noUnicode = CdidxEnvironment.GetEnvironmentVariable("NO_UNICODE"); + if (!string.IsNullOrEmpty(noUnicode) && noUnicode != "0") + return true; + + var atBridgeType = CdidxEnvironment.GetEnvironmentVariable("AT_BRIDGE_TYPE"); + if (!string.IsNullOrEmpty(atBridgeType)) + return true; + + var accessibilityEnabled = CdidxEnvironment.GetEnvironmentVariable("ACCESSIBILITY_ENABLED"); + if (!string.IsNullOrEmpty(accessibilityEnabled) && accessibilityEnabled != "0") + return true; + + return IsPosixLocale(CdidxEnvironment.GetEnvironmentVariable("LC_ALL")) + || IsPosixLocale(CdidxEnvironment.GetEnvironmentVariable("LC_CTYPE")) + || IsPosixLocale(CdidxEnvironment.GetEnvironmentVariable("LANG")); + } + + private static bool IsTruthyEnvironmentVariable(string name) + => IsTruthyEnvironmentValue(CdidxEnvironment.GetEnvironmentVariable(name)); + + private static bool IsTruthyEnvironmentValue(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + return value.Trim() is not ("0" or "false" or "False" or "FALSE" or "no" or "No" or "NO"); + } + + private static bool IsDumbTerminal() + => string.Equals(CdidxEnvironment.GetEnvironmentVariable("TERM"), "dumb", StringComparison.OrdinalIgnoreCase); + + private static bool IsPosixLocale(string? locale) + => locale != null + && (locale.Equals("C", StringComparison.OrdinalIgnoreCase) + || locale.Equals("POSIX", StringComparison.OrdinalIgnoreCase)); + + private static bool IsUnicodeLocale(string locale) + => locale.Contains(".UTF-8", StringComparison.OrdinalIgnoreCase) + || locale.Contains(".UTF8", StringComparison.OrdinalIgnoreCase); + + private static string? FirstNonEmptyEnvironmentVariable(params string[] names) + { + foreach (var name in names) + { + var value = CdidxEnvironment.GetEnvironmentVariable(name); + if (!string.IsNullOrEmpty(value)) + return value; + } + + return null; + } + + internal static void SetAsciiOutput(bool enabled) => _asciiOutputForced = enabled; + + internal static bool IsAsciiOutputForced() => _asciiOutputForced; + + internal static bool WidthDetectionFailed => _widthDetectionFailed; + + internal static void SetWidthDetectionTracing(bool enabled) => _traceWidthDetectionFailures = enabled; + + /// + /// Get console window width safely (some environments throw IOException). + /// コンソール幅を安全に取得する(一部環境ではIOExceptionが発生する)。 + /// + internal static int GetWindowWidth() + { + if (TryGetColumnsEnvironmentWidth(out var columnsWidth)) + return columnsWidth; + + try + { + var w = Console.WindowWidth; + if (w > 0) + return w; + } + catch (IOException ex) + { + return GetFallbackWindowWidth(ex); + } + catch (NotSupportedException ex) + { + return GetFallbackWindowWidth(ex); + } + + return GetFallbackWindowWidth(null); + } + + private static int GetFallbackWindowWidth(Exception? exception) + { + _widthDetectionFailed = true; + if (_traceWidthDetectionFailures && !_widthDetectionTraceWritten) + { + var suffix = exception == null ? string.Empty : $" ({CommandErrorWriter.FormatSanitizedExceptionDetail(exception)})"; + CommandErrorWriter.WriteStderr($"cdidx: console width detection failed; using COLUMNS or 80 columns{suffix}"); + _widthDetectionTraceWritten = true; + } + + return TryGetColumnsEnvironmentWidth(out var columnsWidth) ? columnsWidth : 80; + } + + private static bool TryGetColumnsEnvironmentWidth(out int width) + { + var columns = CdidxEnvironment.GetEnvironmentVariable("COLUMNS"); + if (int.TryParse(columns, NumberStyles.Integer, CultureInfo.InvariantCulture, out width) && width > 0) + return true; + + width = 0; + return false; + } + + private sealed class JsonOutputScope : IDisposable + { + public void Dispose() + { + if (JsonOutputDepth.Value > 0) + JsonOutputDepth.Value--; + } + } + + private sealed class NoopDisposable : IDisposable + { + public static readonly NoopDisposable Instance = new(); + public void Dispose() + { + } + } +} diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 51d59afc2..b4a281708 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -64,7 +64,7 @@ public enum CompletionNotificationMode /// Console UI helpers: spinner, progress bar, banner, and easter egg messages. /// コンソールUIヘルパー: スピナー、プログレスバー、バナー、イースターエッグメッセージ。 /// -public static class ConsoleUi +public static partial class ConsoleUi { public const string DisableProgressEnvironmentVariable = "CDIDX_DISABLE_PROGRESS"; public const string PrefersReducedMotionEnvironmentVariable = "PREFERS_REDUCED_MOTION"; @@ -473,1712 +473,4 @@ private static string FormatDurationAsHms(TimeSpan duration) /// Start spinner on a background thread, returns CancellationTokenSource to stop it. /// バックグラウンドスレッドでスピナーを開始。停止用のCancellationTokenSourceを返す。 /// - public static CancellationTokenSource? StartSpinner(string message, string[] frames) - { - EnsureConsoleWritersSynchronized(); - - // Braille frames are single-char; themed frames are longer strings containing the display text - // ブレイルフレームは1文字、テーマフレームは表示テキストを含む長い文字列 - bool isThemed = frames.Length > 0 && frames[0].Length > 2; - - if (!ShouldUseInteractiveConsole() || !ShouldUseProgressAnimation()) - { - Console.WriteLine(message); - return null; - } - - var cts = new SpinnerCancellationTokenSource(); - var ct = cts.Token; - var spinnerTask = BackgroundTaskObserver.Run(async token => - { - int i = 0; - while (!token.IsCancellationRequested) - { - var frame = frames[i % frames.Length]; - var line = isThemed ? $"\r{frame}" : $"\r{frame} {message}"; - lock (TerminalLock) - { - Console.Write(line); - Console.Out.Flush(); - } - i++; - try { await Task.Delay(SpinnerFrameDelayMs, token).ConfigureAwait(false); } catch (OperationCanceledException) { break; } - } - }, "cdidx", "console spinner", ct); - cts.SetSpinnerTask(spinnerTask); - return cts; - } - - /// - /// Stop spinner and clear the line. - /// スピナーを停止して行をクリア。 - /// - public static void StopSpinner(CancellationTokenSource? cts) - { - if (cts == null) return; - cts.Cancel(); - if (cts is SpinnerCancellationTokenSource spinnerCts) - { - try - { - spinnerCts.SpinnerTask.GetAwaiter().GetResult(); - } - catch - { - // Spinner shutdown is best-effort; BackgroundTaskObserver reports faults. - // spinner shutdown は best-effort。fault は BackgroundTaskObserver が報告する。 - } - } - if (ShouldUseInteractiveConsole()) - { - lock (TerminalLock) - { - Console.Write($"\r{new string(' ', GetWindowWidth() - ConsoleLineMargin)}\r"); - Console.Out.Flush(); - } - } - cts.Dispose(); - } - - private sealed class SpinnerCancellationTokenSource : CancellationTokenSource - { - private Task _spinnerTask = Task.CompletedTask; - - public Task SpinnerTask => _spinnerTask; - - public void SetSpinnerTask(Task spinnerTask) - => _spinnerTask = spinnerTask; - } - - internal static void SetProgressAnimationEnabled(bool? enabled) - => _progressAnimationEnabledOverride = enabled; - - internal static bool? GetProgressAnimationOverrideForDiagnostics() - => _progressAnimationEnabledOverride; - - internal static bool ShouldUseProgressAnimation() - { - if (_progressAnimationEnabledOverride.HasValue) - return _progressAnimationEnabledOverride.Value; - - if (IsTruthyEnvironmentVariable(DisableProgressEnvironmentVariable)) - return false; - - var reducedMotion = CdidxEnvironment.GetEnvironmentVariable(PrefersReducedMotionEnvironmentVariable); - return string.IsNullOrWhiteSpace(reducedMotion) || !IsTruthyEnvironmentValue(reducedMotion); - } - - /// - /// Get spinner frames based on easter egg flag. - /// イースターエッグフラグに基づくスピナーフレームを取得。 - /// - public static string[] GetSpinnerFrames(string? easterEgg) - { - if (!ShouldUseUnicodeGlyphs()) - return AsciiSpinnerFrames; - - return easterEgg switch - { - "--sushi" => - [ - "\U0001f363 Slicing ", "\U0001f363 Slicing. ", "\U0001f363 Slicing.. ", "\U0001f363 Slicing... ", - "\U0001f363 Shaping ", "\U0001f363 Shaping. ", "\U0001f363 Shaping.. ", "\U0001f363 Shaping... ", - "\U0001f363 Pressing ", "\U0001f363 Pressing. ", "\U0001f363 Pressing.. ", "\U0001f363 Pressing... ", - "\U0001f363 Itadakimasu! ", - ], - "--coffee" => - [ - "\u2615 Grinding ", "\u2615 Grinding. ", "\u2615 Grinding.. ", "\u2615 Grinding... ", - "\u2615 Heating ", "\u2615 Heating. ", "\u2615 Heating.. ", "\u2615 Heating... ", - "\u2615 Brewing ", "\u2615 Brewing. ", "\u2615 Brewing.. ", "\u2615 Brewing... ", - ], - "--ramen" => - [ - "\U0001f35c Boiling ", "\U0001f35c Boiling. ", "\U0001f35c Boiling.. ", "\U0001f35c Boiling... ", - "\U0001f35c Steaming ", "\U0001f35c Steaming. ", "\U0001f35c Steaming.. ", "\U0001f35c Steaming... ", - "\U0001f35c Slurping ", "\U0001f35c Slurping. ", "\U0001f35c Slurping.. ", "\U0001f35c Slurping... ", - "\U0001f35c Itadakimasu! ", - ], - "--wine" => - [ - "\U0001f377 Crushing ", "\U0001f377 Crushing. ", "\U0001f377 Crushing.. ", "\U0001f377 Crushing... ", - "\U0001f377 Aging ", "\U0001f377 Aging. ", "\U0001f377 Aging.. ", "\U0001f377 Aging... ", - "\U0001f377 Pouring ", "\U0001f377 Pouring. ", "\U0001f377 Pouring.. ", "\U0001f377 Pouring... ", - "\U0001f377 Sant\u00e9! ", - ], - "--beer" => - [ - "\U0001f37a Tapping ", "\U0001f37a Tapping. ", "\U0001f37a Tapping.. ", "\U0001f37a Tapping... ", - "\U0001f37a Pouring ", "\U0001f37a Pouring. ", "\U0001f37a Pouring.. ", "\U0001f37a Pouring... ", - "\U0001f37a Foaming ", "\U0001f37a Foaming. ", "\U0001f37a Foaming.. ", "\U0001f37a Foaming... ", - "\U0001f37a Cheers! ", - ], - "--matcha" => - [ - "\U0001f375 Sifting ", "\U0001f375 Sifting. ", "\U0001f375 Sifting.. ", "\U0001f375 Sifting... ", - "\U0001f375 Pouring ", "\U0001f375 Pouring. ", "\U0001f375 Pouring.. ", "\U0001f375 Pouring... ", - "\U0001f375 Whisking ", "\U0001f375 Whisking. ", "\U0001f375 Whisking.. ", "\U0001f375 Whisking... ", - "\U0001f375 Douzo! ", - ], - "--whisky" => - [ - "\U0001f943 Mashing ", "\U0001f943 Mashing. ", "\U0001f943 Mashing.. ", "\U0001f943 Mashing... ", - "\U0001f943 Distilling ", "\U0001f943 Distilling. ", "\U0001f943 Distilling.. ", "\U0001f943 Distilling... ", - "\U0001f943 Aging ", "\U0001f943 Aging. ", "\U0001f943 Aging.. ", "\U0001f943 Aging... ", - "\U0001f943 Slainte! ", - ], - // Default: Braille spinner / デフォルト: ブレイルスピナー - _ => DefaultBrailleSpinnerFrames, - }; - } - - // --- Progress bar / プログレスバー --- - - // Active spinner frames for progress bar (themed or default braille) - // プログレスバー用アクティブスピナーフレーム(テーマ付きまたはデフォルトブレイル) - private static string[] _progressSpinnerFrames = DefaultBrailleSpinnerFrames; - // Track last progress line length for clearing / クリア用に最後のプログレス行の長さを記録 - private static int _lastProgressLineLength; - private static bool _asciiOutputForced; - private static bool? _progressAnimationEnabledOverride; - private static bool _widthDetectionFailed; - private static bool _widthDetectionTraceWritten; - private static bool _traceWidthDetectionFailures; - - /// - /// Set progress bar spinner theme (reuses GetSpinnerFrames). - /// プログレスバーのスピナーテーマを設定(GetSpinnerFramesを再利用)。 - /// - public static void SetProgressTheme(string? easterEgg) - { - _progressSpinnerFrames = GetSpinnerFrames(easterEgg); - } - - /// - /// Print inline progress bar with spinner. - /// スピナー付きインライン進捗バーを表示。 - /// - public static void PrintProgress(int current, int total) - { - if (total <= 0) - return; - - var output = Console.Out; - var redirected = !ShouldUseInteractiveConsole(); - - // Update every 50 files or at completion / 50ファイルごと、または完了時に更新 - if (current % 50 != 0 && current != total) - return; - - var line = FormatProgressLine( - current, - total, - redirected ? 80 : GetWindowWidth(), - ShouldUseUnicodeGlyphs(), - ShouldUseProgressAnimation()); - - if (!redirected) - { - lock (TerminalLock) - { - output.Write($"\r{line}"); - output.Flush(); - _lastProgressLineLength = line.Length; - if (current == total) - { - output.WriteLine(); - _lastProgressLineLength = 0; - } - } - } - else - { - // Fallback for redirected output / リダイレクト時はフォールバック - output.WriteLine(line.TrimStart()); - } - } - - internal static string FormatProgressLine( - int current, - int total, - int windowWidth, - bool useUnicodeGlyphs, - bool useProgressAnimation = true) - { - const int barWidth = 32; - var pct = (double)current / total; - var percentAndCounts = string.Create( - CultureInfo.InvariantCulture, - $"{pct * 100,5:F1}% [{current:N0}/{total:N0}]"); - - if (useUnicodeGlyphs && windowWidth < 40) - return percentAndCounts; - - int filled = (int)Math.Round(pct * barWidth); - if (filled > barWidth) filled = barWidth; - if (filled < 0) filled = 0; - - var spinner = useProgressAnimation ? ResolveProgressSpinner(current, total, useUnicodeGlyphs) : " "; - var bar = useUnicodeGlyphs - ? new string('\u2588', filled) + new string('\u2591', barWidth - filled) - : $"[{new string('#', filled)}{new string('-', barWidth - filled)}]"; - return $"{spinner} {bar} {percentAndCounts}"; - } - - private static string ResolveProgressSpinner(int current, int total, bool useUnicodeGlyphs) - { - if (current == total) - return " "; - - return useUnicodeGlyphs - ? _progressSpinnerFrames[(current / 50) % _progressSpinnerFrames.Length] - : "-"; - } - - /// - /// Clear the current progress bar line so other output can be printed cleanly. - /// 他の出力を正しく表示するために現在のプログレスバー行をクリア。 - /// - public static void ClearProgressLine() - { - lock (TerminalLock) - { - ClearProgressLineCore(); - } - } - - private static void ClearProgressLineCore() - { - if (ShouldUseInteractiveConsole() && _lastProgressLineLength > 0) - { - Console.Write($"\r{new string(' ', _lastProgressLineLength)}\r"); - Console.Out.Flush(); - _lastProgressLineLength = 0; - } - } - - /// - /// Print a warning message, clearing the progress bar line first if needed. - /// 必要に応じてプログレスバー行をクリアしてから警告メッセージを表示。 - /// - public static void PrintWarning(string message) - { - lock (TerminalLock) - { - ClearProgressLineCore(); - CommandErrorWriter.WriteStderr($" [WARN] {message}"); - Console.Error.Flush(); - Console.Out.Flush(); - } - } - - // --- Banner / バナー --- - - /// - /// Print ASCII-art banner. - /// ASCIIアートバナーを表示。 - /// - public static void PrintBanner() - { - const string banner = """ - - ██████╗ ██████╗ ██████╗ ███████╗██╗███╗ ██╗██████╗ ███████╗██╗ ██╗ - ██╔════╝██╔═══██╗██╔══██╗██╔════╝██║████╗ ██║██╔══██╗██╔════╝╚██╗██╔╝ - ██║ ██║ ██║██║ ██║█████╗ ██║██╔██╗ ██║██║ ██║█████╗ ╚███╔╝ - ██║ ██║ ██║██║ ██║██╔══╝ ██║██║╚██╗██║██║ ██║██╔══╝ ██╔██╗ - ╚██████╗╚██████╔╝██████╔╝███████╗██║██║ ╚████║██████╔╝███████╗██╔╝ ██╗ - ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝╚═╝ ╚═══╝╚═════╝ ╚══════╝╚═╝ ╚═╝ - """; - Console.WriteLine(banner); - } - - public static void PrintIndexCompleteSummary( - string projectRoot, - string resolvedDbPath, - bool incremental, - int filesScanned, - IReadOnlyDictionary languageCounts) - { - Console.WriteLine(incremental ? "Next steps (incremental):" : "Next steps:"); - Console.WriteLine(" - Search code: cdidx search \"authenticate\" --path src/"); - Console.WriteLine(" - Find a definition: cdidx definition SymbolName"); - Console.WriteLine($" - Start MCP: cdidx mcp --db {QuoteForDisplay(resolvedDbPath)}"); - Console.WriteLine($" - Database: {resolvedDbPath}"); - Console.WriteLine(" - Exclude paths with .gitignore or .cdidxignore, then rerun cdidx index ."); - Console.WriteLine($" - Scanned {Counted(filesScanned, "file", format: "N0")} under {projectRoot}"); - if (languageCounts.Count > 0) - { - var summary = string.Join( - ", ", - languageCounts - .OrderByDescending(static pair => pair.Value) - .ThenBy(static pair => pair.Key, StringComparer.Ordinal) - .Take(6) - .Select(static pair => $"{pair.Key} {pair.Value.ToString("N0", CultureInfo.InvariantCulture)}")); - Console.WriteLine($" - Languages: {summary}"); - } - Console.WriteLine(); - } - - public static void EmitCompletionNotification(CompletionNotificationMode mode, string message) - { - var resolved = mode == CompletionNotificationMode.Auto - ? ShouldUseInteractiveConsole() ? CompletionNotificationMode.Bell : CompletionNotificationMode.None - : mode; - if (resolved == CompletionNotificationMode.None) - return; - - var safeMessage = message.Replace('\r', ' ').Replace('\n', ' '); - if (resolved == CompletionNotificationMode.Osc9) - Console.Error.Write($"\u001b]9;{safeMessage}\a"); - else - Console.Error.Write('\a'); - Console.Error.Flush(); - } - - private static string QuoteForDisplay(string value) - => value.IndexOfAny([' ', '\t', '"']) < 0 - ? value - : $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\""; - - // --- Easter eggs / イースターエッグ --- - - /// - /// Print easter egg message (standalone mode). Renders the catalog entry for - /// in the language chosen by - /// (CDIDX_LANG env > - /// > English fallback). Unknown flags print two blank lines for legacy compatibility. - /// Pass to bypass env/culture resolution (used by - /// tests so they do not mutate the live process environment). - /// イースターエッグメッセージを表示(単体実行時)。 - /// が選んだ言語(CDIDX_LANG 環境変数 > カルチャ > 英語)でカタログ - /// エントリを描画する。未知フラグは従来互換で空行を2つ出力。 - /// を指定すると環境変数/カルチャ判定をスキップする - /// (テストがプロセス環境を書き換えずに済むようにするためのフック)。 - /// - public static void PrintEasterEggMessage(string flag, UiLanguage? languageOverride = null) - { - var pair = flag switch - { - "--sushi" => UiMessages.EasterEggSushi, - "--coffee" => UiMessages.EasterEggCoffee, - "--ramen" => UiMessages.EasterEggRamen, - "--wine" => UiMessages.EasterEggWine, - "--beer" => UiMessages.EasterEggBeer, - "--matcha" => UiMessages.EasterEggMatcha, - "--whisky" => UiMessages.EasterEggWhisky, - _ => null, - }; - if (pair is null) - { - Console.WriteLine(); - Console.WriteLine(); - return; - } - - var lang = languageOverride ?? UiLanguageResolver.Resolve(); - foreach (var line in UiMessages.Render(pair, lang)) - Console.WriteLine(line); - } - - // --- Version loading / バージョン読み込み --- - - /// - /// Load version from version.json. - /// version.jsonからバージョンを読み込み。 - /// - public static string LoadVersion() - { - var exeDir = AppContext.BaseDirectory; - var path = Path.Combine(exeDir, "version.json"); - if (!File.Exists(LongPath.EnsureWindowsPrefix(path))) - { - // Fallback: look relative to current directory / カレントディレクトリからの相対パスでフォールバック - path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "version.json"); - } - var ioPath = LongPath.EnsureWindowsPrefix(path); - if (File.Exists(ioPath)) - return LoadVersionFromFile(ioPath); - - return FallbackVersion; - } - - internal static string LoadVersionFromFile(string ioPath) - { - try - { - var json = DataDirectorySecurity.ReadTextWithinLimit(ioPath, MaxVersionJsonBytes); - if (json is null) - return FallbackVersion; - - using var doc = BoundedJson.ParseDocument(json, MaxVersionJsonBytes, MaxVersionJsonDepth); - if (doc.RootElement.TryGetProperty("version", out var ver)) - return ver.GetString() ?? FallbackVersion; - } - catch (Exception ex) when (ex is IOException - or UnauthorizedAccessException - or JsonException - or InvalidDataException - or InvalidOperationException) - { - return FallbackVersion; - } - - return FallbackVersion; - } - - /// - /// Format byte counts for human-facing CLI output using binary units. - /// 人間向けCLI出力用にバイト数を2進単位で整形する。 - /// - public static string FormatBytes(long bytes) - { - if (bytes < 0) - return string.Create(CultureInfo.InvariantCulture, $"{bytes:N0} bytes"); - if (bytes < 1024) - return string.Create(CultureInfo.InvariantCulture, $"{bytes:N0} bytes"); - - var value = (double)bytes; - var unitIndex = 0; - while (value >= 1024 && unitIndex < ByteUnits.Length - 1) - { - value /= 1024; - unitIndex++; - } - - return string.Create(CultureInfo.InvariantCulture, $"{value:N1} {ByteUnits[unitIndex]}"); - } - - /// - /// Build metadata stamped into the assembly at compile time, used by - /// `--version` so dev builds and tagged releases are distinguishable in - /// bug reports (#1550). Any field can be "unknown" when the build host - /// lacks git (e.g. a tarball-only checkout). - /// `--version` がバグ報告で dev ビルドとタグ済みリリースを区別できる - /// よう、ビルド時にアセンブリへ刻んだメタデータ (#1550)。git の無い - /// ビルドホストでは各フィールドが "unknown" になりうる。 - /// - public sealed record BuildMetadata(string Version, string Commit, string BuildDate, string Dirty); - - /// - /// Load the full build metadata: semver from version.json plus commit/build - /// date/dirty flag stamped into the assembly via AssemblyMetadataAttribute. - /// version.json の semver と、AssemblyMetadataAttribute で刻まれた - /// commit / build date / dirty フラグを合わせて読み込む。 - /// - public static BuildMetadata LoadBuildMetadata() - { - var assembly = typeof(ConsoleUi).Assembly; - return new BuildMetadata( - Version: LoadVersion(), - Commit: ReadAssemblyMetadata(assembly, "CdidxCommit"), - BuildDate: ReadAssemblyMetadata(assembly, "CdidxBuildDate"), - Dirty: ReadAssemblyMetadata(assembly, "CdidxBuildDirty")); - } - - private static string ReadAssemblyMetadata(Assembly assembly, string key) - { - foreach (var attr in assembly.GetCustomAttributes()) - { - if (string.Equals(attr.Key, key, StringComparison.Ordinal)) - return string.IsNullOrWhiteSpace(attr.Value) ? "unknown" : attr.Value!; - } - return "unknown"; - } - - // --- Usage / 使い方 --- - - /// - /// Print usage information. - /// 使い方を表示する。 - /// - public static void PrintUsage(bool showBanner = true) - => PrintUsageBrief(showBanner); - - public static void PrintUsageBrief(bool showBanner = true) - { - if (showBanner) - { - PrintBanner(); - } - - Console.WriteLine("Usage:"); - Console.WriteLine(" cdidx "); - Console.WriteLine(" cdidx [options]"); - Console.WriteLine(" cdidx --help-all"); - Console.WriteLine(" cdidx --help-flags"); - Console.WriteLine(); - PrintCommandSummary(); - Console.WriteLine(); - Console.WriteLine("Run `cdidx --help-all` for every command and option, `cdidx --help-flags` for shared flags, or `cdidx --help` for one command."); - Console.WriteLine(); - Console.WriteLine("Examples:"); - Console.WriteLine(" cdidx ./myproject"); - Console.WriteLine(" cdidx search \"authenticate\""); - Console.WriteLine(" cdidx inspect Run --body --exclude-tests"); - } - - public static void PrintUsageFull(bool showBanner = true) - { - if (showBanner) - { - PrintBanner(); - } - - var helpWidth = ShouldUseInteractiveConsole() ? Math.Min(GetWindowWidth(), 120) : 0; - void WriteHelpLine(string line = "") - { - if (helpWidth <= 0) - { - Console.WriteLine(line); - return; - } - - foreach (var wrapped in WrapHelpLine(line, helpWidth)) - Console.WriteLine(wrapped); - } - - Console.WriteLine("Usage:"); - Console.WriteLine(" cdidx "); - foreach (var (name, usage) in CommandUsageLines) - { - if (HiddenCommandUsageNames.Contains(name)) - continue; - - WriteHelpLine($" {usage}"); - } - Console.WriteLine(); - PrintCommandSummary(); - Console.WriteLine(); - PrintFlagReference(WriteHelpLine); - Console.WriteLine(); - PrintExamples(); - } - - public static void PrintFlagUsage(bool showBanner = true) - { - if (showBanner) - { - PrintBanner(); - } - - var helpWidth = ShouldUseInteractiveConsole() ? Math.Min(GetWindowWidth(), 120) : 0; - void WriteHelpLine(string line = "") - { - if (helpWidth <= 0) - { - Console.WriteLine(line); - return; - } - - foreach (var wrapped in WrapHelpLine(line, helpWidth)) - Console.WriteLine(wrapped); - } - - Console.WriteLine("Usage:"); - Console.WriteLine(" cdidx --help-flags"); - Console.WriteLine(); - PrintFlagReference(WriteHelpLine); - Console.WriteLine(); - Console.WriteLine("Run `cdidx --help-all` to show commands and examples."); - } - - private static void PrintCommandSummary() - { - Console.WriteLine("Commands:"); - Console.WriteLine(" help [subcommand] Show help without running the command"); - Console.WriteLine(" index Build or update the index for a project"); - Console.WriteLine(" hooks Install, uninstall, or inspect git hook integration"); - Console.WriteLine(" backfill-fold Upgrade folded-name columns in an existing index DB"); - Console.WriteLine(" optimize Optimize FTS5 segments in an existing index DB"); - Console.WriteLine(" vacuum Reclaim free SQLite pages from an existing index DB"); - Console.WriteLine(" search Full-text search across indexed chunks"); - Console.WriteLine(" recipes List built-in search audit recipes"); - Console.WriteLine(" audit Run a built-in search audit recipe"); - Console.WriteLine(" definition Resolve symbol definitions with extracted ranges"); - Console.WriteLine(" goto Return one best LSP Location for a definition"); - Console.WriteLine(" references Find indexed references for a symbol (--kind uses reference kind)"); - Console.WriteLine(" callers Find callers of a symbol (--kind uses reference kind)"); - Console.WriteLine(" callees Find callees used by a caller (--kind uses reference kind)"); - Console.WriteLine(" symbols [query] Search symbols (functions, classes, imports)"); - Console.WriteLine(" files [query|glob] List indexed files (* and ? positionals use path-glob semantics)"); - Console.WriteLine(" find Find literal substring matches inside known indexed files"); - Console.WriteLine(" excerpt Reconstruct a line-range excerpt from indexed chunks"); - Console.WriteLine(" map Show a repo-level overview for AI orientation"); - Console.WriteLine(" inspect Bundle definition, graph, and nearby symbol context"); - Console.WriteLine(" outline Show a file outline ordered by line, start column, kind, and name"); - Console.WriteLine(" status Show database statistics; add --check for freshness, --config for effective config, --explain for field details, or --log-path for logs"); - Console.WriteLine(" workspace List manifest members and manage the active workspace"); - Console.WriteLine(" config show Show resolved workspace config and precedence"); - Console.WriteLine(" upgrade Check for and install the latest release via install.sh"); - Console.WriteLine(" validate-config Validate .cdidx/config.json or .cdidxrc.json"); - Console.WriteLine(" doctor Print a redacted environment summary or env-var inventory for bug reports"); - Console.WriteLine(" db --integrity-check Run SQLite `PRAGMA integrity_check` and report findings"); - Console.WriteLine(" db schema Dump SQLite schema entries and PRAGMA user_version"); - Console.WriteLine(" db prune --dry-run|--apply Count or delete orphaned DB rows"); - Console.WriteLine(" diff Compare two index databases; exit 0 identical, 1 drift, 2 schema mismatch, 3 unreadable"); - Console.WriteLine(" report --output Build a redacted crash-repro tarball (.tgz/.tar.gz); --json reports stdout metadata"); - Console.WriteLine(" validate Report encoding issues (U+FFFD origin/severity, BOM, null bytes, mixed line endings, UTF-16 BOM, likely non-UTF8)"); - Console.WriteLine(" impact Show transitive callers; type queries may return heuristic file-level dependency hints"); - Console.WriteLine(" deps Show file-level dependency edges from the reference graph"); - Console.WriteLine(" unused Find symbols defined but never referenced (dead code)"); - Console.WriteLine(" hotspots Find high-impact symbols; duplicate-name families may fall back conservatively"); - Console.WriteLine(" suggestions Add, list, inspect, and export local suggestion history"); - Console.WriteLine(" export Export ctags or a portable CodeIndex archive"); - Console.WriteLine(" import Import a portable CodeIndex archive"); - Console.WriteLine(" languages List supported languages and their capabilities"); - Console.WriteLine(" batch Run newline-delimited JSON query commands with one DB connection"); - Console.WriteLine(" mcp Start MCP server (for AI tools: Claude, Cursor, etc.)"); - Console.WriteLine(" lsp Start LSP server over stdio (for LSP-native editors)"); - Console.WriteLine(" completions Generate shell completions for bash, zsh, fish, or PowerShell"); - Console.WriteLine(" license Show licensing, trademark, and commercial-use summary"); - } - - private static void PrintFlagReference(Action WriteHelpLine) - { - Console.WriteLine(); - Console.WriteLine("Index and update options:"); - Console.WriteLine(" --db Database file path (default for index: /.cdidx/codeindex.db)"); - WriteHelpLine(" .cdidxignore Optional project-local ignore file; loaded after .gitignore in each directory"); - Console.WriteLine(" --rebuild Delete existing DB and rebuild from scratch"); - Console.WriteLine(" --verbose Show per-file status ([OK ]/[SKIP]/[DEL ]/[ERR ])"); - Console.WriteLine(" --dry-run Scan files without writing to the database"); - WriteHelpLine($" --dry-run-path-limit Dry run only: process at most candidate paths before returning truncated lower-bound estimates (default: {IndexCommandRunner.DefaultDryRunPathLimit}, max: {IndexCommandRunner.MaxDryRunPathLimit})"); - Console.WriteLine(" --force Bypass the per-database index lock; only use when no other cdidx index is active"); - WriteHelpLine(" --symbols-only Build chunks and symbols but skip reference extraction; graph queries stay degraded until a normal index run"); - Console.WriteLine(" --json Output results as JSON (for AI/machine use)"); - Console.WriteLine(" --memory-trace Include phase memory samples in index JSON output"); - Console.WriteLine(" --quiet, -q, --silent Suppress informational stderr output; errors still print (also honors CDIDX_QUIET=1)"); - Console.WriteLine(" --duration-format Index elapsed time format: `auto` (default), `seconds`, or `hms`; JSON keeps raw elapsed_ms"); - WriteHelpLine(" --notify Long index completion signal: auto, bell, osc9, desktop, or none (also honors CDIDX_NOTIFY; quiet/json suppress it)"); - WriteHelpLine(" --max-file-bytes Index only files up to this size (default: 4MiB; also honors CDIDX_MAX_FILE_BYTES; accepts K/M/G suffixes)"); - WriteHelpLine(" --max-symbols-per-file Skip file content, symbols, and references when one file emits too many symbols (default: 5000; max: 50000)"); - WriteHelpLine(" --max-references-per-file Skip references when one file emits too many references (default: 100000; max: 1000000)"); - WriteHelpLine(" --parallelism Full-scan extraction workers (default: CPU count capped at 8; explicit max: 16; also honors CDIDX_INDEX_PARALLELISM)"); - WriteHelpLine(" --follow-symlinks Symlink policy for directories and files: none (default), internal, or all"); - WriteHelpLine(" --include-symbol-kind [,] Keep only matching symbol kinds during indexing"); - WriteHelpLine(" --exclude-symbol-kind [,] Drop matching symbol kinds during indexing"); - Console.WriteLine(" --commits [commit-ref ...]"); - Console.WriteLine($" Update only files changed in the specified git commits (preferred after commits; max {IndexCommandRunner.MaxCommitRefCount} refs, {IndexCommandRunner.MaxCommitRefLength} chars each)"); - Console.WriteLine(" --changed-between "); - Console.WriteLine(" Update only files changed between two git refs (useful after branch switches)"); - Console.WriteLine(" --files [path ...] Update only the specified files; old rename/delete paths are not purged unless also listed"); - WriteHelpLine(" --watch After the initial scan, stay running and reindex on file changes (FileSystemWatcher / inotify / FSEvents); rejects --commits / --changed-between / --files / --dry-run"); - Console.WriteLine($" --debounce Watch only: coalesce bursts of file events into one update after of quiet (default: {IndexWatchRunner.DefaultDebounceMs}, max {IndexWatchRunner.MaxDebounceMs})"); - WriteHelpLine($" --watch-pending-path-limit Watch only: pending changed-path queue limit before falling back to a full rescan (default: {IndexWatchRunner.DefaultWatchPendingPathLimit}, max: {IndexWatchRunner.MaxWatchPendingPathLimit}; also honors {IndexCommandRunner.WatchPendingPathLimitEnvironmentVariable})"); - Console.WriteLine(" --optimize index only: optimize the existing FTS5 table for this project's DB without scanning files"); - WriteHelpLine(" --color Color output: `auto` (default), `always`, or `never`; flag wins over `CLICOLOR_FORCE` / `NO_COLOR` / `CLICOLOR` env vars, which win over TTY auto-detect"); - WriteHelpLine(" --palette ANSI palette: `basic` (8-color, default fallback), `256`, or `truecolor`; flag wins over `CDIDX_COLOR_PALETTE` env var, which wins over `COLORTERM` / `TERM` auto-detect"); - WriteHelpLine(" --ascii Use ASCII spinner/progress glyphs instead of Unicode glyphs (also honors CDIDX_ASCII=1, NO_UNICODE, TERM=dumb, accessibility env hints, and non-UTF-8 locales)"); - WriteHelpLine(" --no-progress Disable animated progress/spinner output (also honors CDIDX_DISABLE_PROGRESS=1 and PREFERS_REDUCED_MOTION)"); - Console.WriteLine(" --metrics Append one JSONL record per CLI command / MCP tool call to (also honors CDIDX_METRICS=)"); - Console.WriteLine(" --log-format Persistent stderr log format (also honors CDIDX_LOG_FORMAT)"); - Console.WriteLine(" --log-retain-count Persistent stderr log file retention count (also honors CDIDX_LOG_RETAIN)"); - Console.WriteLine(" --log-max-size-mb Persistent stderr log rotation size cap in MiB (also honors CDIDX_LOG_MAX_SIZE_MB)"); - WriteHelpLine(" --debug-unsafe Allow raw debug dumps only when CDIDX_DEBUG=unsafe is also set; local troubleshooting only"); - WriteHelpLine(" --strict-version Treat workspace version pin mismatches as exit code 64 instead of warnings"); - Console.WriteLine(" --help, -h Show this help message"); - Console.WriteLine(" --version, -V Show version information"); - Console.WriteLine(" --license Show licensing, trademark, and commercial-use summary"); - Console.WriteLine(" --completions Generate shell completions (bash, zsh, fish, powershell)"); - Console.WriteLine(); - Console.WriteLine("Update workflows:"); - Console.WriteLine(" Use --commits with a project path after normal commits; git diff sees rename/delete paths too."); - Console.WriteLine(" Use --changed-between after switching branches to refresh only changed files."); - Console.WriteLine(" Use --files only for known in-place edits or new files; old rename/delete paths stay indexed unless also listed."); - Console.WriteLine(" Incremental writes optimize FTS5 opportunistically after a small maintenance threshold; run `cdidx optimize` for manual maintenance."); - Console.WriteLine(); - Console.WriteLine("Query options:"); - Console.WriteLine(" --db Database file path (default: .cdidx/codeindex.db in current directory)"); - WriteHelpLine(" --json Output as JSON (search/symbols/files stream ndjson by default; search/symbols/files/validate accept --json=array for one array)"); - WriteHelpLine(" --verbose Query commands: emit debug diagnostics to stderr; with --json, append an _debug JSON object"); - WriteHelpLine(" --quiet, -q, --silent Query commands: suppress informational stderr output, including zero-result hints and summaries; errors still print. Overrides --verbose stderr text."); - WriteHelpLine(" --profile Read commands: append SQL timing, row-count, and EXPLAIN QUERY PLAN JSON after the normal result"); - WriteHelpLine(" --slow-query-ms Read commands: log profiled SQL statements that take at least ms (use 0 to log every statement)"); - Console.WriteLine(" --limit , --top , --max-results "); - Console.WriteLine(" Max results to return (default: 20)"); - Console.WriteLine(" --lang Filter by language (aliases: bat, cmd, cshtml, razor, ts, tsx, cts, mts)"); - Console.WriteLine(" --path Restrict matches to glob-style path patterns (* and ?)"); - WriteHelpLine($" --query Pass a query literal, useful when the query starts with '-' (`search`/`find` max {QueryLimits.MaxQueryLength} chars)"); - WriteHelpLine(" --named-query = search only: add a named ad hoc batch query; repeat to run related searches with grouped compact results"); - Console.WriteLine(" --exclude-path Exclude glob-style path patterns (* and ?) (repeatable)"); - Console.WriteLine(" --exclude-tests Exclude likely test files"); - WriteHelpLine(" --audit-scope search/unused: source uses production-code cleanup defaults; all disables source-scope defaults"); - Console.WriteLine(" --source-only search only: shorthand for --audit-scope source on ad hoc and named searches"); - Console.WriteLine(" --exclude-comments search only: suppress comment-only matches"); - Console.WriteLine(" --exclude-strings search only: suppress string, regex, and help-text matches"); - Console.WriteLine(" --exclude-fixtures search only: suppress fixture-only matches in tests"); - WriteHelpLine(" --origin/--match-origin search only: keep only matches from selected origins (code, comment, string_literal, regex_literal, help_text, unknown; repeatable or comma-separated)"); - WriteHelpLine(" --exclude-origin search only: drop matches from selected origins while keeping other origins in the same result"); - Console.WriteLine(" --include-generated Include generated files in query results"); - Console.WriteLine(" --snippet-lines search/find snippet length (1-20, default: search 8; find 1)"); - Console.WriteLine(" --snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)"); - WriteHelpLine($" --max-line-width search/references/callers/callees/find/excerpt/impact/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: {LineWidthFormatter.DefaultMaxLineWidth})"); - WriteHelpLine(" --focus-line find/excerpt: focus a line; excerpt keeps the leading window when no column is supplied"); - Console.WriteLine(" --focus-column find/excerpt: focus a specific 1-based column"); - Console.WriteLine(" --focus-length excerpt: width of the focused span (default: 1, requires --focus-column)"); - Console.WriteLine(" --no-semantic-tokens excerpt JSON: omit semantic_tokens for compact line/window payloads"); - WriteHelpLine($" --fts Use raw FTS5 query syntax for search (content:term, NEAR(a b, 5), OR, NOT, groups, prefix*, \"phrase\"; search query max {QueryLimits.MaxQueryLength} chars; raw FTS parser max {DbReader.MaxRawFtsQueryLength} chars, {DbReader.MaxRawFtsBooleanOperators} boolean ops, {DbReader.MaxRawFtsNearOperators} NEAR ops; trailing * is a prefix shorthand in literal-safe mode)"); - Console.WriteLine(" --exact Backward-compatible shorthand."); - Console.WriteLine(" Prefer --exact-substring for search,"); - Console.WriteLine(" --exact for find,"); - Console.WriteLine(" and --exact-name for symbol/graph lookups."); - Console.WriteLine(" Combining exact-match flags is rejected."); - Console.WriteLine(" --exact-substring Search only: case-sensitive exact substring"); - Console.WriteLine(" (no FTS5)"); - Console.WriteLine(" --token-boundary Search only: exact substring plus code-token"); - Console.WriteLine(" boundaries; excludes longer identifiers."); - Console.WriteLine(" --exact-name Exact name match for symbols, definition,"); - Console.WriteLine(" references, callers, callees, and inspect."); - Console.WriteLine(" Uses NFKC + Unicode CaseFold when ready."); - Console.WriteLine(" Legacy/stale-fold DBs fall back to ASCII NOCASE;"); - Console.WriteLine(" run `cdidx backfill-fold` or check fold_ready."); - WriteHelpLine(" --kind definition/symbols/outline/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation/type_tag/bcl_regex_without_timeout); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind"); - WriteHelpLine(" --sort Symbols/outline: order audit output by a ranking signal; outline also accepts source, kind, references, size, complexity, path, and name"); - Console.WriteLine(" --severity validate only: filter issues by severity: info, warning, error"); - Console.WriteLine(" --visibility Filter symbols/definitions/unused/hotspots by visibility: public, protected, internal, private"); - WriteHelpLine(" --exclude-visibility Exclude symbols/definitions/unused/hotspots by visibility"); - WriteHelpLine(" --count Count only; result limits are ignored by count modes, but scan caps can still mark approximate counts as degraded"); - WriteHelpLine(" --group-partials definition/symbols/inspect symbol mode: collapse partial type declarations into logical families while preserving physical counts and definition_sites"); - WriteHelpLine(" --bucket unused only: filter one unused confidence bucket"); - WriteHelpLine(" --min-confidence unused only: filter medium or low confidence candidates; --confidence is an alias"); - WriteHelpLine(" --all unused only: include low-confidence contract-domain candidates suppressed by default"); - WriteHelpLine(" --actionable unused only: preset for private medium-confidence cleanup candidates"); - Console.WriteLine(" --since Filter to files modified since this timestamp (ISO 8601)"); - Console.WriteLine(" --no-dedup search only: return every raw overlapping chunk hit (debug/density)"); - WriteHelpLine($" --require-before/--require-after search only: keep primary matches only when the guard query appears within --guard-window lines before/after the match (default {DbReader.DefaultSearchGuardWindow}, max {DbReader.MaxSearchGuardWindow})"); - WriteHelpLine(" --reject-before/--reject-after search only: drop primary matches when the guard query appears within the same before/after window; useful for finding API calls missing nearby checks"); - WriteHelpLine(" --guard-scope search only: evaluate guards in the line window (default) or only on the same line before/after the primary match"); - WriteHelpLine(" --bytes files: sort by size and show raw byte counts in human output; map: show raw byte counts; JSON always keeps raw integer bytes"); - Console.WriteLine(" --min-entrypoint-confidence map only: omit entrypoint candidates below this 0.0..1.0 confidence"); - WriteHelpLine(" --max-hops Max BFS hops for impact analysis, inclusive (default: 5; --max-hops 2 returns callers at hop 1 and 2; --max-hops 0 resolves the symbol without traversing callers)"); - Console.WriteLine(" --depth Deprecated alias for --max-hops"); - Console.WriteLine(" --reverse Reverse direction for deps (show dependents)"); - WriteHelpLine(" --group-by search: with --count, group rows by file, symbol, origin, return-type, or subsystem; hotspots: group by symbol or file, or by statement only with --lang sql"); - WriteHelpLine(" --group-by-name hotspots: collapse rows sharing (name, kind) across files; JSON keeps capped paths plus full definition_site_details"); - WriteHelpLine(" --with-paths impact: also emit `paths` per caller — the shortest call chains [root, ..., caller] (diamond graphs surface every converging route, capped per row)"); - WriteHelpLine(" unused reflection note C# nameof/typeof and direct reflection member-name literals such as GetMethod(\"Foo\") are indexed; dynamically constructed reflection names may need manual review"); - WriteHelpLine(" Note: if a query itself starts with '-', pass it with --query or -- ; for option values that start with '--', use --opt=."); - } - - private static void PrintExamples() - { - Console.WriteLine("Examples:"); - Console.WriteLine(" cdidx ./myproject Index a project"); - Console.WriteLine(" cdidx backfill-fold Upgrade folded-name columns in an existing DB"); - Console.WriteLine(" cdidx optimize --dry-run --json Preview FTS5 optimization work without writing"); - Console.WriteLine(" cdidx optimize Optimize FTS5 segments in an existing DB"); - Console.WriteLine(" cdidx vacuum --dry-run --json Estimate DB free pages and maintenance guidance"); - Console.WriteLine(" cdidx index ./myproject --commits abc123 Update DB from one commit"); - Console.WriteLine(" cdidx index ./myproject --commits abc123 def456"); - Console.WriteLine(" Update DB from multiple commits"); - Console.WriteLine(" cdidx index ./myproject --changed-between main feature"); - Console.WriteLine(" Update DB from files changed between two refs"); - Console.WriteLine(" cdidx index ./myproject --files src/app.cs Update specific files"); - Console.WriteLine(" cdidx index ./myproject --watch Run an initial scan, then keep the index live as files change (Ctrl+C to stop)"); - Console.WriteLine(" cdidx export ctags --output tags Export editor tags for Vim, Emacs, and Sublime"); - Console.WriteLine(" cdidx export codeindex.cdidx.zip Export a portable CodeIndex archive"); - Console.WriteLine(" cdidx import codeindex.cdidx.zip Import a portable CodeIndex archive"); - Console.WriteLine(" cdidx import codeindex.cdidx.zip --dry-run Validate an archive without replacing the DB"); - Console.WriteLine(" cdidx search \"authenticate\" Full-text search"); - Console.WriteLine(" cdidx search \"auth*\" Prefix shorthand in literal-safe mode"); - Console.WriteLine(" cdidx search --query --path --path README.md Search for a literal option token"); - Console.WriteLine(" cdidx search --named-query pack=\"dotnet pack\" --named-query push=\"nuget push\" --format compact"); - Console.WriteLine(" Run named ad hoc searches with compact snippets"); - Console.WriteLine(" cdidx search \"Run();\" --exact-substring Case-sensitive exact substring search"); - Console.WriteLine(" cdidx search \"File.ReadAllText\" --exact-substring --reject-before \"Length\" --guard-window 8"); - Console.WriteLine(" Find calls without a nearby preceding size guard"); - Console.WriteLine(" cdidx search authenticate --json=array Emit search results as one JSON array"); - Console.WriteLine(" cdidx search authenticate --profile Append SQL profile JSON for slow-query debugging"); - Console.WriteLine(" cdidx search authenticate --verbose Emit query debug diagnostics on stderr"); - Console.WriteLine(" cdidx definition ResolveGitCommonDir --body Show a symbol definition and body"); - Console.WriteLine(" cdidx references ResolveGitCommonDir Find indexed references"); - Console.WriteLine(" cdidx references DbContext --kind instantiate Filter constructor sites by reference kind"); - Console.WriteLine(" cdidx references e --path dist/app.js --max-line-width 120"); - Console.WriteLine(" Clamp a minified single-line context window"); - Console.WriteLine(" cdidx excerpt src/app.js --start 120 --focus-column 88 --max-line-width 120"); - Console.WriteLine(" Keep the requested token visible inside a long line"); - Console.WriteLine(" cdidx callers ResolveGitCommonDir Find callers"); - Console.WriteLine(" cdidx callees AddToGitExclude Find callees used by a caller"); - Console.WriteLine(" cdidx symbols Run --exact-name Exact symbol-name match"); - Console.WriteLine(" cdidx symbols UserService --kind class Find class definitions"); - Console.WriteLine(" cdidx find guard --path src/Auth.cs --after 2 Find literal matches inside a known file"); - Console.WriteLine(" cdidx find --path README.md -- --path Search a literal that starts with '-'"); - Console.WriteLine(" cdidx excerpt src/app.cs --start 10 --end 20 Reconstruct a file excerpt"); - Console.WriteLine(" cdidx map --path src/ --exclude-tests Show a repo map for source code"); - Console.WriteLine(" cdidx inspect Run --body --exclude-tests Inspect one symbol with bundled context"); - Console.WriteLine(" cdidx outline src/app.cs --json Symbol outline of a single file"); - Console.WriteLine(" cdidx deps --path src/ --exclude-tests Show file-level dependency edges"); - Console.WriteLine(" cdidx deps --reverse --path src/app.cs Show what depends on a file"); - Console.WriteLine(" cdidx unused --lang csharp --actionable Find private cleanup candidates"); - Console.WriteLine(" cdidx hotspots --lang csharp --exclude-tests Find high-impact symbols with conservative duplicate fallback"); - Console.WriteLine(" cdidx hotspots --group-by=file --json Compare hotspot volume by target file"); - Console.WriteLine(" cdidx hotspots --group-by-name --exclude-tests Collapse same-name hotspots across files"); - Console.WriteLine(" cdidx impact Run --max-hops 0 --exclude-tests Resolve a symbol without traversing callers"); - Console.WriteLine(" cdidx impact FolderDiffService --json Type query may return heuristic file-level dependency hints"); - Console.WriteLine(" cdidx files --lang python List Python files"); - Console.WriteLine(" cdidx files --since 2024-01-01 Files modified since a date"); - Console.WriteLine(" cdidx status --json DB stats as JSON"); - Console.WriteLine(" cdidx status --config Effective configuration as JSON"); - Console.WriteLine(" cdidx validate-config Validate checked-in config"); - Console.WriteLine(" cdidx languages Show supported languages"); - Console.WriteLine(" cdidx --completions zsh > ~/.zfunc/_cdidx Generate a zsh completion script"); - Console.WriteLine(" cdidx license Show licensing and commercial-use terms"); - } - - internal static IReadOnlyList WrapHelpLine(string line, int maxWidth) - { - if (maxWidth <= 0 || line.Length <= maxWidth) - return [line]; - - var continuationIndent = GetHelpContinuationIndent(line); - return WrapLineByWords(line, maxWidth, continuationIndent); - } - - private static string GetHelpContinuationIndent(string line) - { - var leading = 0; - while (leading < line.Length && line[leading] == ' ') - leading++; - - for (var i = leading + 1; i < line.Length - 1; i++) - { - if (line[i] == ' ' && line[i + 1] == ' ') - { - while (i < line.Length && line[i] == ' ') - i++; - if (i < line.Length) - return new string(' ', i); - break; - } - } - - return new string(' ', Math.Min(leading + 2, 8)); - } - - private static IReadOnlyList WrapLineByWords(string line, int maxWidth, string continuationIndent) - { - maxWidth = Math.Max(1, maxWidth); - if (continuationIndent.Length >= maxWidth) - continuationIndent = new string(' ', Math.Max(0, Math.Min(2, maxWidth - 1))); - - var lines = new List(); - var current = line; - while (current.Length > maxWidth) - { - var breakAt = current.LastIndexOf(' ', Math.Min(maxWidth, current.Length - 1)); - if (breakAt <= 0 || current[..breakAt].Trim().Length == 0) - breakAt = maxWidth; - - lines.Add(current[..breakAt].TrimEnd()); - var nextStart = breakAt < current.Length && current[breakAt] == ' ' ? breakAt + 1 : breakAt; - current = continuationIndent + current[nextStart..].TrimStart(); - } - - lines.Add(current); - return lines; - } - - public static void PrintLicenseSummary() - { - Console.WriteLine("cdidx / CodeIndex license"); - Console.WriteLine(); - Console.WriteLine("License: Functional Source License, Version 1.1, ALv2 Future License (FSL-1.1-ALv2)"); - Console.WriteLine("Copyright: Copyright 2026 Widthdom."); - Console.WriteLine("Summary: use, modification, and distribution are allowed for non-competing purposes, including internal, commercial, AI, IDE, MCP, CI, and scripting integrations."); - Console.WriteLine("Competing commercial products or services require a separate written agreement with Widthdom."); - Console.WriteLine("Names and trademarks: CodeIndex and cdidx are not licensed for derivative product, package, service, or endorsement branding."); - Console.WriteLine(); - Console.WriteLine("See LICENSE, LICENSES/FSL-1.1-ALv2.txt, LICENSES/Apache-2.0.txt, COMMERCIAL_LICENSE.md, INTEGRATION_POLICY.md, and TRADEMARKS.md for the controlling terms."); - } - - internal static LicenseJsonResult BuildLicenseJsonResult() => - new( - JsonOutputContract.ApiVersion, - new LicenseTermsJsonResult( - "FSL-1.1-ALv2", - "Functional Source License, Version 1.1, ALv2 Future License", - "Apache-2.0", - "LICENSE"), - "Copyright 2026 Widthdom.", - new LicenseCommercialUseJsonResult( - NonCompetingUseAllowed: true, - CompetingProductsOrServicesRequireSeparateAgreement: true, - "Use, modification, and distribution are allowed for non-competing purposes, including internal, commercial, AI, IDE, MCP, CI, and scripting integrations."), - new LicenseTrademarkJsonResult( - ["CodeIndex", "cdidx"], - DerivativeBrandingAllowed: false, - EndorsementBrandingAllowed: false, - "CodeIndex and cdidx are not licensed for derivative product, package, service, or endorsement branding."), - [ - "LICENSE", - "LICENSES/FSL-1.1-ALv2.txt", - "LICENSES/Apache-2.0.txt", - "COMMERCIAL_LICENSE.md", - "INTEGRATION_POLICY.md", - "TRADEMARKS.md", - ]); - - public static string? GetUsageLine(string command) - { - command = NormalizeCommandUsageName(command); - foreach (var (name, usage) in CommandUsageLines) - { - if (string.Equals(name, command, StringComparison.Ordinal)) - return usage; - } - - return null; - } - - public static bool PrintCommandUsage(string command) - { - command = NormalizeCommandUsageName(command); - var usages = GetCommandUsageLines(command); - if (usages.Count == 0) - return false; - - Console.WriteLine("Usage:"); - foreach (var usage in usages) - Console.WriteLine($" {usage}"); - var schemaCommand = GetFlagSchemaCommandName(command); - var helpFlags = string.Equals(command, schemaCommand, StringComparison.Ordinal) - && CliFlagSchema.HasAuthoritativeHelpOptions(schemaCommand) - ? CliFlagSchema.GetCompletionFlagsForCommand(schemaCommand) - : []; - if (helpFlags.Count > 0) - { - Console.WriteLine(); - Console.WriteLine("Options:"); - foreach (var flag in helpFlags) - { - var names = flag.ShortName is null ? flag.Name : $"{flag.Name}, {flag.ShortName}"; - var token = flag.ValuePlaceholder is null ? names : $"{names} {flag.ValuePlaceholder}"; - Console.WriteLine($" {token}"); - Console.WriteLine($" {flag.Description}"); - } - } - var notes = GetCommandUsageNotes(command); - if (notes.Count > 0) - { - Console.WriteLine(); - Console.WriteLine("Notes:"); - foreach (var note in notes) - Console.WriteLine($" {note}"); - } - Console.WriteLine(); - Console.WriteLine("Run `cdidx --help` to show all commands and shared options."); - return true; - } - - private static IReadOnlyList GetCommandUsageLines(string command) - { - command = NormalizeCommandUsageName(command); - var usages = new List(); - foreach (var (name, usage) in CommandUsageLines) - { - if (string.Equals(name, command, StringComparison.Ordinal) - || string.Equals(command, "index", StringComparison.Ordinal) && name.StartsWith("index-", StringComparison.Ordinal)) - { - usages.Add(usage); - } - } - - return usages; - } - - private static IReadOnlyList GetCommandUsageNotes(string command) - { - command = NormalizeCommandUsageName(command); - var notes = new List(); - foreach (var (name, note) in CommandUsageNotes) - { - if (string.Equals(name, command, StringComparison.Ordinal)) - notes.Add(note); - } - - return notes; - } - - private static string GetFlagSchemaCommandName(string command) - { - if (command.StartsWith("db-", StringComparison.Ordinal)) - return "db"; - if (command.StartsWith("hooks-", StringComparison.Ordinal)) - return "hooks"; - return command == "--completions" ? "completions" : command; - } - - private static string NormalizeCommandUsageName(string command) => - CliCommandCatalog.NormalizePublicCommandName(command); - - // --- Did-you-mean / もしかして --- - - /// - /// Find the closest matching command name using Damerau-Levenshtein distance. - /// Short commands use a stricter threshold to avoid unrelated suggestions. - /// Damerau-Levenshtein距離で最も近いコマンド名を返す。短いコマンドは無関係な推薦を避けるため閾値を厳しくする。 - /// - public static string? FindClosestCommand(string input) => - FindClosestMatch(input, CliCommandCatalog.PublicCommandNames); - - /// - /// Find the closest match for from - /// using Damerau-Levenshtein distance with the same length-aware threshold the - /// command suggester uses (#1582). Comparison is case-insensitive. Returns the original - /// (cased) candidate string, or null when no candidate is within the threshold. - /// 任意の候補集合に対して Damerau-Levenshtein 距離で最も近い候補を返す (#1582)。 - /// 短い入力には厳しめの距離閾値を適用し、無関係な推薦を避ける。比較は case-insensitive。 - /// - public static string? FindClosestMatch(string? input, IEnumerable candidates) - { - var normalized = NormalizeSuggestionInput(input); - if (normalized == null) - return null; - - string? best = null; - var bestDist = int.MaxValue; - foreach (var candidate in candidates) - { - if (string.IsNullOrEmpty(candidate)) - continue; - if (candidate.Length > MaxSuggestionInputCharLength) - continue; - var candidateNormalized = candidate.ToLowerInvariant(); - if (string.Equals(normalized, candidateNormalized, StringComparison.Ordinal)) - return candidate; - var dist = DamerauLevenshteinDistance(normalized, candidateNormalized); - if (dist > GetSuggestionDistanceThreshold(normalized.Length, candidateNormalized.Length)) - continue; - if (dist < bestDist) - { - bestDist = dist; - best = candidate; - } - } - return best; - } - - /// - /// Return up to closest candidates for , - /// ordered by Damerau-Levenshtein distance. Useful for structured suggestions in MCP - /// error payloads (#1582). Returns an empty list when no candidate is within the threshold. - /// Damerau-Levenshtein 距離で近い候補を最大 件まで返す。 - /// MCP の structured error payload で `similar_values` を返す用途を想定する (#1582)。 - /// - public static IReadOnlyList FindClosestMatches(string? input, IEnumerable candidates, int maxResults = 3) - { - var normalized = NormalizeSuggestionInput(input); - if (normalized == null || maxResults <= 0) - return Array.Empty(); - - var matches = new List<(string Candidate, int Distance)>(); - foreach (var candidate in candidates) - { - if (string.IsNullOrEmpty(candidate)) - continue; - if (candidate.Length > MaxSuggestionInputCharLength) - continue; - var candidateNormalized = candidate.ToLowerInvariant(); - if (string.Equals(normalized, candidateNormalized, StringComparison.Ordinal)) - continue; - var dist = DamerauLevenshteinDistance(normalized, candidateNormalized); - if (dist > GetSuggestionDistanceThreshold(normalized.Length, candidateNormalized.Length)) - continue; - matches.Add((candidate, dist)); - } - return matches - .OrderBy(m => m.Distance) - .ThenBy(m => m.Candidate, StringComparer.Ordinal) - .Select(m => m.Candidate) - .Take(maxResults) - .ToList(); - } - - private static string? NormalizeSuggestionInput(string? input) - { - if (input == null || input.Length > MaxSuggestionInputCharLength || string.IsNullOrWhiteSpace(input)) - return null; - - return input.ToLowerInvariant(); - } - - private static int GetSuggestionDistanceThreshold(int inputLength, int commandLength) - { - var shorter = Math.Min(inputLength, commandLength); - return shorter switch - { - <= 4 => 1, - <= 10 => 2, - _ => 3, - }; - } - - private static int DamerauLevenshteinDistance(string s, string t) - { - var n = s.Length; - var m = t.Length; - var d = new int[n + 1, m + 1]; - for (var i = 0; i <= n; i++) d[i, 0] = i; - for (var j = 0; j <= m; j++) d[0, j] = j; - for (var i = 1; i <= n; i++) - { - for (var j = 1; j <= m; j++) - { - var cost = s[i - 1] == t[j - 1] ? 0 : 1; - d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost); - if (i > 1 && j > 1 && s[i - 1] == t[j - 2] && s[i - 2] == t[j - 1]) - d[i, j] = Math.Min(d[i, j], d[i - 2, j - 2] + 1); - } - } - return d[n, m]; - } - - // --- Shell Completions / シェル補完 --- - - /// - /// Print shell completion script. Returns false for unknown shells. - /// シェル補完スクリプトを出力。不明なシェルの場合はfalseを返す。 - /// - public static bool PrintCompletions(string shell) - { - try - { - Console.WriteLine(GetCompletionScript(shell)); - return true; - } - catch (ArgumentOutOfRangeException) - { - return false; - } - } - - internal static string GetCompletionScript(string shell) => - ConsoleCompletionRenderer.GetCompletionScript(shell); - - // --- Helpers / ヘルパー --- - - private static ColorMode _colorMode = ColorMode.Auto; - private static ColorPalette? _explicitPalette; - private static bool? _windowsVirtualTerminalProcessingEnabled; - private static Func? _windowsVirtualTerminalProcessingDetectorForTests; - private const int StdOutputHandle = -11; - private const uint EnableVirtualTerminalProcessing = 0x0004; - - /// - /// Set the active color-output mode. and - /// short-circuit env / TTY checks in - /// ; defers to - /// the existing CLICOLOR_FORCE / NO_COLOR / CLICOLOR / TTY chain. - /// 色出力モードを設定する。Always / Never は環境変数と TTY 判定を上書きする。 - /// - public static void SetColorMode(ColorMode mode) => _colorMode = mode; - - internal static ColorMode GetColorModeForDiagnostics() - => _colorMode; - - internal static ColorMode GetColorMode() => _colorMode; - - /// - /// Override the active ANSI palette. null restores auto-detection - /// via COLORTERM / TERM / CDIDX_COLOR_PALETTE. - /// - public static void SetColorPalette(ColorPalette? palette) => _explicitPalette = palette; - - internal static ColorPalette? GetExplicitColorPalette() => _explicitPalette; - - /// - /// Parse a user-supplied `--palette` value. Accepts `basic`, `256`, - /// `color256`, `truecolor`, and `24bit` (case-insensitive). Returns false - /// on any other value. - /// `--palette` 値を解析する。`basic` / `256` / `truecolor` などを許可する。 - /// - public static bool TryParseColorPalette(string? value, out ColorPalette palette) - { - switch (value?.Trim().ToLowerInvariant()) - { - case "basic": - case "8": - case "16": - case "ansi": - palette = ColorPalette.Basic; - return true; - case "256": - case "color256": - case "8bit": - palette = ColorPalette.Color256; - return true; - case "truecolor": - case "24bit": - case "rgb": - palette = ColorPalette.Truecolor; - return true; - default: - palette = ColorPalette.Basic; - return false; - } - } - - /// - /// Resolve the palette to use. Honors the explicit override set via - /// first, then falls back to the - /// CDIDX_COLOR_PALETTE environment variable, then to capability - /// detection from COLORTERM / TERM. - /// - public static ColorPalette ResolveColorPalette() - { - if (_explicitPalette is { } explicitPalette) - return explicitPalette; - - var envPalette = CdidxEnvironment.GetEnvironmentVariable("CDIDX_COLOR_PALETTE"); - if (!string.IsNullOrWhiteSpace(envPalette) && TryParseColorPalette(envPalette, out var parsed)) - return parsed; - - return DetectColorPalette(); - } - - /// - /// Detect the terminal palette from the COLORTERM and TERM - /// environment variables. COLORTERM=truecolor / COLORTERM=24bit - /// → . TERM containing - /// 256color (e.g. xterm-256color, screen-256color) → - /// . Otherwise . - /// - internal static ColorPalette DetectColorPalette() - { - var colorTerm = CdidxEnvironment.GetEnvironmentVariable("COLORTERM"); - if (!string.IsNullOrEmpty(colorTerm)) - { - var ct = colorTerm.Trim().ToLowerInvariant(); - if (ct == "truecolor" || ct == "24bit") - return ColorPalette.Truecolor; - } - - var term = CdidxEnvironment.GetEnvironmentVariable("TERM"); - if (!string.IsNullOrEmpty(term)) - { - var t = term.ToLowerInvariant(); - if (t.Contains("256color", StringComparison.Ordinal)) - return ColorPalette.Color256; - if (t.Contains("truecolor", StringComparison.Ordinal) || t.Contains("direct", StringComparison.Ordinal)) - return ColorPalette.Truecolor; - } - - return ColorPalette.Basic; - } - - /// - /// Parse a user-supplied `--color` value. Accepts `auto`, `always`, and - /// `never` (case-insensitive). Returns false on any other value. - /// `--color` 値を解析する。`auto` / `always` / `never` のみ許可。 - /// - public static bool TryParseColorMode(string? value, out ColorMode mode) - { - switch (value?.Trim().ToLowerInvariant()) - { - case "auto": - mode = ColorMode.Auto; - return true; - case "always": - mode = ColorMode.Always; - return true; - case "never": - mode = ColorMode.Never; - return true; - default: - mode = ColorMode.Auto; - return false; - } - } - - /// - /// Colorize a symbol kind name with ANSI escape codes for terminal output. - /// Honors the active ; in - /// falls back to 's env + TTY policy. - /// シンボル種別名を ANSI エスケープコードで色付けする。 を尊重し、 - /// auto では環境変数と TTY 自動判定にフォールバックする。 - /// - public static string ColorizeKind(string kind, int padWidth = 0) - { - var padded = padWidth > 0 ? kind.PadRight(padWidth) : kind; - if (JsonOutputDepth.Value <= 0 && ShouldUseColor()) - { - var color = GetKindColorCode(kind, ResolveColorPalette()); - if (color.Length > 0) - return $"{color}{padded}\x1b[0m"; - } - return padded; - } - - // Per-palette SGR introducer for a given symbol kind. Basic stays within - // the 8 standard ANSI colors (30–37) and intentionally avoids - // `\x1b[90m` (bright-black / dim), which is unreadable on many minimal - // SSH / CI terminals; namespace / import fall back to plain white (37). - // 各パレットでのシンボル種別ごとの SGR コード。Basic は標準8色のみで - // dim (`\x1b[90m`) を避け、SSH/CI 端末でも可読性を確保する。 - internal static string GetKindColorCode(string kind, ColorPalette palette) => palette switch - { - ColorPalette.Truecolor => kind switch - { - "class" => "\x1b[38;2;102;217;239m", // bright cyan - "struct" => "\x1b[38;2;102;217;239m", - "interface" => "\x1b[38;2;102;160;255m", // bright blue - "enum" => "\x1b[38;2;215;110;215m", // bright magenta - "function" => "\x1b[38;2;255;215;75m", // gold yellow - "property" => "\x1b[38;2;160;230;100m", // bright green - "event" => "\x1b[38;2;255;100;100m", // bright red - "delegate" => "\x1b[38;2;215;110;215m", - "namespace" => "\x1b[38;2;180;180;180m", // light gray (readable on dark + light bg) - "import" => "\x1b[38;2;180;180;180m", - _ => "", - }, - ColorPalette.Color256 => kind switch - { - "class" => "\x1b[38;5;81m", // cyan - "struct" => "\x1b[38;5;81m", - "interface" => "\x1b[38;5;75m", // blue - "enum" => "\x1b[38;5;213m", // magenta - "function" => "\x1b[38;5;221m", // gold - "property" => "\x1b[38;5;120m", // green - "event" => "\x1b[38;5;203m", // salmon red - "delegate" => "\x1b[38;5;213m", - "namespace" => "\x1b[38;5;245m", // medium gray (not as dim as 90m) - "import" => "\x1b[38;5;245m", - _ => "", - }, - _ => kind switch - { - "class" => "\x1b[36m", // cyan / シアン - "struct" => "\x1b[36m", // cyan / シアン - "interface" => "\x1b[34m", // blue / 青 - "enum" => "\x1b[35m", // magenta / マゼンタ - "function" => "\x1b[33m", // yellow / 黄 - "property" => "\x1b[32m", // green / 緑 - "event" => "\x1b[31m", // red / 赤 - "delegate" => "\x1b[35m", // magenta / マゼンタ - "namespace" => "\x1b[37m", // white (instead of dim 90m) / 白(dim 回避) - "import" => "\x1b[37m", // white (instead of dim 90m) / 白(dim 回避) - _ => "", - }, - }; - - internal static bool ShouldUseInteractiveConsole() - => ShouldUseInteractiveConsole( - Console.IsOutputRedirected, - Console.Out.Encoding, - Console.Out is StringWriter, - HasTerminalEnvironmentHint(), - IsTerminalEnvironmentDisabled(), - OperatingSystem.IsWindows()); - - internal static bool ShouldUseInteractiveConsole( - bool isOutputRedirected, - Encoding outputEncoding, - bool isTextWriterCapture, - bool hasTerminalEnvironmentHint, - bool isTerminalEnvironmentDisabled, - bool isWindows) - { - if (isOutputRedirected) - return false; - - if (isTerminalEnvironmentDisabled) - return false; - - // StringWriter-based test capture leaves the process console attached, so - // Console.IsOutputRedirected stays false even though interactive terminal - // behavior would be unsafe. Detect it directly instead of inferring from - // encoding, because real terminals may expose UTF-8 or UTF-16 independently - // of ConPTY/ANSI support. - if (isTextWriterCapture) - return false; - - return isWindows || hasTerminalEnvironmentHint; - } - - internal static bool ShouldUseAnsiOutput() - => ShouldUseAnsiOutput( - Console.IsOutputRedirected, - Console.Out.Encoding, - Console.Out is StringWriter, - HasTerminalEnvironmentHint(), - IsTerminalEnvironmentDisabled(), - OperatingSystem.IsWindows(), - GetWindowsVirtualTerminalProcessingEnabled()); - - internal static bool ShouldUseAnsiOutput( - bool isOutputRedirected, - Encoding outputEncoding, - bool isTextWriterCapture, - bool hasTerminalEnvironmentHint, - bool isTerminalEnvironmentDisabled, - bool isWindows, - bool windowsVirtualTerminalProcessingEnabled) - { - if (!ShouldUseInteractiveConsole(isOutputRedirected, outputEncoding, isTextWriterCapture, hasTerminalEnvironmentHint, isTerminalEnvironmentDisabled, isWindows)) - return false; - - if (!isWindows) - return true; - - return windowsVirtualTerminalProcessingEnabled || hasTerminalEnvironmentHint; - } - - /// - /// Decide whether ANSI color escapes should be emitted. Precedence (highest first): - /// 1. Explicit from `--color` flag (Always/Never short-circuit). - /// 2. CLICOLOR_FORCE (any non-empty value other than "0") — force color on. - /// 3. NO_COLOR (any non-empty value) — color off. - /// 4. CLICOLOR=0 — color off. - /// 5. Otherwise fall back to . - /// ANSI 色エスケープを出力するかを判定する。`--color` フラグ > 環境変数 > TTY 判定。 - /// - public static bool ShouldUseColor() - { - if (_colorMode == ColorMode.Always) - return true; - if (_colorMode == ColorMode.Never) - return false; - if (IsForceColorRequested()) - return true; - if (IsNoColorRequested()) - return false; - return ShouldUseAnsiOutput(); - } - - private static bool HasTerminalEnvironmentHint() - { - if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("WT_SESSION"))) - return true; - if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("WT_PROFILE_ID"))) - return true; - if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("TERM_PROGRAM"))) - return true; - - var term = CdidxEnvironment.GetEnvironmentVariable("TERM"); - return !string.IsNullOrWhiteSpace(term) - && !term.Equals("dumb", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsTerminalEnvironmentDisabled() - => IsDumbTerminal() || IsCiEnvironment(); - - private static bool IsCiEnvironment() - { - var ci = CdidxEnvironment.GetEnvironmentVariable("CI"); - return !string.IsNullOrEmpty(ci) - && !ci.Equals("0", StringComparison.OrdinalIgnoreCase) - && !ci.Equals("false", StringComparison.OrdinalIgnoreCase) - && !ci.Equals("no", StringComparison.OrdinalIgnoreCase) - && !ci.Equals("off", StringComparison.OrdinalIgnoreCase); - } - - private static bool GetWindowsVirtualTerminalProcessingEnabled() - { - if (!OperatingSystem.IsWindows()) - return false; - - if (_windowsVirtualTerminalProcessingEnabled is { } cached) - return cached; - - var detected = (_windowsVirtualTerminalProcessingDetectorForTests ?? DetectWindowsVirtualTerminalProcessing)(); - _windowsVirtualTerminalProcessingEnabled = detected; - return detected; - } - - private static bool DetectWindowsVirtualTerminalProcessing() - { - var handle = GetStdHandle(StdOutputHandle); - if (handle == IntPtr.Zero || handle == new IntPtr(-1)) - return false; - - return GetConsoleMode(handle, out var mode) - && (mode & EnableVirtualTerminalProcessing) != 0; - } - - internal static void SetWindowsVirtualTerminalProcessingDetectorForTests(Func? detector) - { - _windowsVirtualTerminalProcessingDetectorForTests = detector; - _windowsVirtualTerminalProcessingEnabled = null; - } - - internal static void ResetTerminalCapabilityCacheForTests() - { - _windowsVirtualTerminalProcessingEnabled = null; - _windowsVirtualTerminalProcessingDetectorForTests = null; - } - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern IntPtr GetStdHandle(int nStdHandle); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); - - private static bool IsForceColorRequested() - { - var force = CdidxEnvironment.GetEnvironmentVariable("CLICOLOR_FORCE"); - return !string.IsNullOrEmpty(force) && force != "0"; - } - - private static bool IsNoColorRequested() - { - var noColor = CdidxEnvironment.GetEnvironmentVariable("NO_COLOR"); - if (!string.IsNullOrEmpty(noColor)) - return true; - - var cliColor = CdidxEnvironment.GetEnvironmentVariable("CLICOLOR"); - return cliColor == "0"; - } - - internal static bool ShouldUseUnicodeGlyphs() - { - if (IsAsciiOutputRequested()) - return false; - - if (IsDumbTerminal()) - return false; - - var locale = FirstNonEmptyEnvironmentVariable("LC_ALL", "LC_CTYPE", "LANG"); - if (locale != null && !IsUnicodeLocale(locale)) - return false; - - return Console.OutputEncoding.CodePage == Encoding.UTF8.CodePage - || Console.OutputEncoding.CodePage == Encoding.Unicode.CodePage; - } - - private static bool IsAsciiOutputRequested() - { - if (_asciiOutputForced) - return true; - - var ascii = CdidxEnvironment.GetEnvironmentVariable("CDIDX_ASCII"); - if (!string.IsNullOrEmpty(ascii) && ascii != "0") - return true; - - var noUnicode = CdidxEnvironment.GetEnvironmentVariable("NO_UNICODE"); - if (!string.IsNullOrEmpty(noUnicode) && noUnicode != "0") - return true; - - var atBridgeType = CdidxEnvironment.GetEnvironmentVariable("AT_BRIDGE_TYPE"); - if (!string.IsNullOrEmpty(atBridgeType)) - return true; - - var accessibilityEnabled = CdidxEnvironment.GetEnvironmentVariable("ACCESSIBILITY_ENABLED"); - if (!string.IsNullOrEmpty(accessibilityEnabled) && accessibilityEnabled != "0") - return true; - - return IsPosixLocale(CdidxEnvironment.GetEnvironmentVariable("LC_ALL")) - || IsPosixLocale(CdidxEnvironment.GetEnvironmentVariable("LC_CTYPE")) - || IsPosixLocale(CdidxEnvironment.GetEnvironmentVariable("LANG")); - } - - private static bool IsTruthyEnvironmentVariable(string name) - => IsTruthyEnvironmentValue(CdidxEnvironment.GetEnvironmentVariable(name)); - - private static bool IsTruthyEnvironmentValue(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return false; - - return value.Trim() is not ("0" or "false" or "False" or "FALSE" or "no" or "No" or "NO"); - } - - private static bool IsDumbTerminal() - => string.Equals(CdidxEnvironment.GetEnvironmentVariable("TERM"), "dumb", StringComparison.OrdinalIgnoreCase); - - private static bool IsPosixLocale(string? locale) - => locale != null - && (locale.Equals("C", StringComparison.OrdinalIgnoreCase) - || locale.Equals("POSIX", StringComparison.OrdinalIgnoreCase)); - - private static bool IsUnicodeLocale(string locale) - => locale.Contains(".UTF-8", StringComparison.OrdinalIgnoreCase) - || locale.Contains(".UTF8", StringComparison.OrdinalIgnoreCase); - - private static string? FirstNonEmptyEnvironmentVariable(params string[] names) - { - foreach (var name in names) - { - var value = CdidxEnvironment.GetEnvironmentVariable(name); - if (!string.IsNullOrEmpty(value)) - return value; - } - - return null; - } - - internal static void SetAsciiOutput(bool enabled) => _asciiOutputForced = enabled; - - internal static bool IsAsciiOutputForced() => _asciiOutputForced; - - internal static bool WidthDetectionFailed => _widthDetectionFailed; - - internal static void SetWidthDetectionTracing(bool enabled) => _traceWidthDetectionFailures = enabled; - - /// - /// Get console window width safely (some environments throw IOException). - /// コンソール幅を安全に取得する(一部環境ではIOExceptionが発生する)。 - /// - internal static int GetWindowWidth() - { - if (TryGetColumnsEnvironmentWidth(out var columnsWidth)) - return columnsWidth; - - try - { - var w = Console.WindowWidth; - if (w > 0) - return w; - } - catch (IOException ex) - { - return GetFallbackWindowWidth(ex); - } - catch (NotSupportedException ex) - { - return GetFallbackWindowWidth(ex); - } - - return GetFallbackWindowWidth(null); - } - - private static int GetFallbackWindowWidth(Exception? exception) - { - _widthDetectionFailed = true; - if (_traceWidthDetectionFailures && !_widthDetectionTraceWritten) - { - var suffix = exception == null ? string.Empty : $" ({CommandErrorWriter.FormatSanitizedExceptionDetail(exception)})"; - CommandErrorWriter.WriteStderr($"cdidx: console width detection failed; using COLUMNS or 80 columns{suffix}"); - _widthDetectionTraceWritten = true; - } - - return TryGetColumnsEnvironmentWidth(out var columnsWidth) ? columnsWidth : 80; - } - - private static bool TryGetColumnsEnvironmentWidth(out int width) - { - var columns = CdidxEnvironment.GetEnvironmentVariable("COLUMNS"); - if (int.TryParse(columns, NumberStyles.Integer, CultureInfo.InvariantCulture, out width) && width > 0) - return true; - - width = 0; - return false; - } - - private sealed class JsonOutputScope : IDisposable - { - public void Dispose() - { - if (JsonOutputDepth.Value > 0) - JsonOutputDepth.Value--; - } - } - - private sealed class NoopDisposable : IDisposable - { - public static readonly NoopDisposable Instance = new(); - public void Dispose() - { - } - } } From 03c32b7ac37980e7340bbc848b05129f64f954db Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:51:52 +0900 Subject: [PATCH 053/101] Split index watch responsibilities --- .../Cli/IndexWatchRunner.Batching.cs | 146 +++ .../Cli/IndexWatchRunner.Reporting.cs | 306 ++++++ src/CodeIndex/Cli/IndexWatchRunner.SubRuns.cs | 323 ++++++ .../Cli/IndexWatchRunner.Watchers.cs | 255 +++++ src/CodeIndex/Cli/IndexWatchRunner.cs | 980 +----------------- 5 files changed, 1031 insertions(+), 979 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexWatchRunner.Batching.cs create mode 100644 src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs create mode 100644 src/CodeIndex/Cli/IndexWatchRunner.SubRuns.cs create mode 100644 src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Batching.cs b/src/CodeIndex/Cli/IndexWatchRunner.Batching.cs new file mode 100644 index 000000000..6d68fe616 --- /dev/null +++ b/src/CodeIndex/Cli/IndexWatchRunner.Batching.cs @@ -0,0 +1,146 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +/// +/// Thread-safe queue that coalesces FileSystemWatcher events into a single batch once the +/// stream has been quiet for the debounce interval. Extracted for unit testing without +/// touching the filesystem. +/// FileSystemWatcher イベントを debounce 期間の静穏まで蓄積し、まとめてバッチ化するスレッドセーフな +/// キュー。ファイルシステムに触れずユニットテストできるよう分離。 +/// +internal sealed class FileChangeBatcher +{ + internal const int DefaultMaxPendingPaths = IndexWatchRunner.DefaultWatchPendingPathLimit; + + private readonly object _gate = new(); + private readonly HashSet _pending; + private long _lastEventTimestamp; + private bool _hasLastEventTimestamp; + private bool _overflowRequested; + private string? _overflowReason; + private readonly TimeSpan _debounce; + private readonly TimeProvider _timeProvider; + private readonly int _maxPendingPaths; + + public FileChangeBatcher( + TimeSpan debounce, + TimeProvider? timeProvider = null, + bool ignoreCase = true, + int maxPendingPaths = DefaultMaxPendingPaths) + { + if (maxPendingPaths <= 0) + throw new ArgumentOutOfRangeException(nameof(maxPendingPaths), "Maximum pending path count must be positive."); + + _debounce = debounce; + _timeProvider = timeProvider ?? TimeProvider.System; + _maxPendingPaths = maxPendingPaths; + // On case-sensitive filesystems (Linux ext4), `foo.py` and `Foo.py` are distinct files, + // so coalescing them via OrdinalIgnoreCase would drop one rename leg and leave the + // renamed-to file unindexed. The watch loop passes the filesystem's case sensitivity in. + // 大小区別する FS (Linux ext4 など) では foo.py と Foo.py が別ファイルになるため、 + // OrdinalIgnoreCase で集約するとリネーム片方が落ち、リネーム先が索引されなくなる。 + _pending = new HashSet(ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + } + + public void Add(string path) + { + lock (_gate) + { + if (_overflowRequested) + { + RecordEventTimestampLocked(); + return; + } + + if (!_pending.Contains(path)) + { + if (_pending.Count >= _maxPendingPaths) + { + RequestFullRescanLocked( + $"pending path limit exceeded ({_maxPendingPaths.ToString("N0", CultureInfo.InvariantCulture)} paths)"); + return; + } + + _pending.Add(path); + } + + RecordEventTimestampLocked(); + } + } + + public void RequestFullRescan(string? reason = null) + { + lock (_gate) + { + RequestFullRescanLocked(reason); + } + } + + public bool TryDrain(out IReadOnlyList batch, out bool fullRescan, out string? overflowReason) + => TryDrainCore(requireDebounce: true, out batch, out fullRescan, out overflowReason); + + public bool TryDrainImmediately(out IReadOnlyList batch, out bool fullRescan, out string? overflowReason) + => TryDrainCore(requireDebounce: false, out batch, out fullRescan, out overflowReason); + + private bool TryDrainCore( + bool requireDebounce, + out IReadOnlyList batch, + out bool fullRescan, + out string? overflowReason) + { + lock (_gate) + { + if (_pending.Count == 0 && !_overflowRequested) + { + batch = Array.Empty(); + fullRescan = false; + overflowReason = null; + return false; + } + + if (requireDebounce + && _hasLastEventTimestamp + && _timeProvider.GetElapsedTime(_lastEventTimestamp) < _debounce) + { + batch = Array.Empty(); + fullRescan = false; + overflowReason = null; + return false; + } + + var snapshot = new List(_pending.Count); + foreach (var path in _pending) + snapshot.Add(path); + batch = snapshot; + fullRescan = _overflowRequested; + overflowReason = _overflowReason; + _pending.Clear(); + _overflowRequested = false; + _overflowReason = null; + return true; + } + } + + private void RequestFullRescanLocked(string? reason) + { + _pending.Clear(); + _overflowRequested = true; + if (!string.IsNullOrEmpty(reason)) + _overflowReason = IndexWatchRunner.FormatWatchDiagnosticText(reason); + RecordEventTimestampLocked(); + } + + private void RecordEventTimestampLocked() + { + _lastEventTimestamp = _timeProvider.GetTimestamp(); + _hasLastEventTimestamp = true; + } +} diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs b/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs new file mode 100644 index 000000000..24352fa49 --- /dev/null +++ b/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs @@ -0,0 +1,306 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +internal static partial class IndexWatchRunner +{ + private static string FormatHumanSummary(string status, int? batchSize, long elapsedMs, string subRunJson, int exitCode) + { + var prefix = status switch + { + "rescanned" => "[watch] rescanned", + "failed" => "[watch] failed", + _ => "[watch] updated", + }; + var batchLabel = batchSize is int n + ? $" {ConsoleUi.Counted(n, "path", format: "N0")}" + : string.Empty; + + // Best-effort parse of the sub-run JSON to surface updated/removed/errors counts. + // The summary is informational; a parse failure must not break the watch loop. + // サブ実行 JSON から件数を best-effort で抽出。失敗してもループは続行する。 + var details = new List + { + $"exit code {exitCode.ToString(CultureInfo.InvariantCulture)}", + }; + var summary = ParseSubRunSummary(subRunJson); + if (summary.ParseStatus == "parsed") + { + details.Add($"updated {summary.Updated.GetValueOrDefault()}"); + details.Add($"removed {summary.Removed.GetValueOrDefault()}"); + details.Add($"errors {summary.Errors.GetValueOrDefault()}"); + if (string.Equals(status, "rescanned", StringComparison.Ordinal)) + { + if (summary.FilesScanned is int filesScanned) + details.Add($"scanned {filesScanned}"); + if (summary.FilesSkipped is int filesSkipped) + details.Add($"skipped {filesSkipped}"); + if (summary.FilesPurged is int filesPurged) + details.Add($"purged {filesPurged}"); + } + } + + var detail = details.Count > 0 ? $" ({string.Join(", ", details)})" : string.Empty; + return $"{prefix}{batchLabel}{detail} in {elapsedMs.ToString("N0", System.Globalization.CultureInfo.InvariantCulture)} ms"; + } + + private static WatchSubRunSummary ParseSubRunSummary(string subRunJson) + { + var trimmedLength = TrimTrailingLineBreaks(subRunJson); + if (trimmedLength == 0) + return WatchSubRunSummary.Unparsed("missing", "sub-run emitted no JSON"); + + if (trimmedLength > MaxHumanSummarySubRunJsonChars) + return WatchSubRunSummary.Unparsed("too_large", $"sub-run JSON exceeded {MaxHumanSummarySubRunJsonChars.ToString(CultureInfo.InvariantCulture)} characters"); + + try + { + using var doc = BoundedJson.ParseDocument( + subRunJson[..trimmedLength], + MaxHumanSummarySubRunJsonChars * 4, + MaxHumanSummaryJsonDepth); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("summary", out var summary) + || summary.ValueKind != JsonValueKind.Object) + { + return WatchSubRunSummary.Unparsed("missing_summary", "sub-run JSON did not contain an object summary"); + } + + return new WatchSubRunSummary( + TryReadInt32(summary, "updated") ?? 0, + TryReadInt32(summary, "removed") ?? 0, + TryReadInt32(summary, "errors") ?? 0, + TryReadInt64(summary, "files_total"), + TryReadInt32(summary, "files_scanned"), + TryReadInt32(summary, "files_skipped") ?? TryReadInt32(summary, "skipped"), + TryReadInt32(summary, "files_purged"), + TryReadInt32(summary, "warnings"), + "parsed", + null); + } + catch (Exception ex) when (ex is JsonException or InvalidDataException) + { + return WatchSubRunSummary.Unparsed("invalid_json", CommandErrorWriter.FormatSanitizedExceptionMessage(ex)); + } + } + + private static int? TryReadInt32(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) && property.TryGetInt32(out var value) + ? value + : null; + + private static long? TryReadInt64(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) && property.TryGetInt64(out var value) + ? value + : null; + + private static List BuildBatchPathSamples(string projectRoot, IReadOnlyList? batchPaths, out bool truncated) + { + truncated = false; + if (batchPaths == null || batchPaths.Count == 0) + return []; + + truncated = batchPaths.Count > BatchPathSampleLimit; + var samples = new List(Math.Min(batchPaths.Count, BatchPathSampleLimit)); + foreach (var path in batchPaths.Take(BatchPathSampleLimit)) + { + var sample = path; + if (Path.IsPathRooted(path)) + sample = FileIndexer.GetRelativePathFromDirectory(projectRoot, path); + sample = FileIndexer.NormalizePathSeparators(sample); + var sanitized = DiagnosticRedactor.RedactSensitiveText(sample, "[redacted]", redactPaths: false); + var bounded = BoundWatchDisplayText(sanitized, BatchPathSampleMaxChars, out var sampleTruncated); + truncated |= sampleTruncated; + samples.Add(bounded); + } + + return samples; + } + + internal static string? FormatWatchDiagnosticText(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var redacted = DiagnosticRedactor.RedactSensitiveText(value, "[redacted]", redactPaths: true); + return BoundWatchDisplayText(redacted, MaxWatchDiagnosticChars, out _); + } + + private static string BoundWatchDisplayText(string value, int maxChars, out bool truncated) + { + if (maxChars < 0) + throw new ArgumentOutOfRangeException(nameof(maxChars), maxChars, "Watch diagnostic limit must be non-negative."); + + var flattened = FlattenWatchDiagnosticControlChars(value); + if (flattened.Length <= maxChars) + { + truncated = false; + return flattened; + } + + truncated = true; + if (maxChars == 0) + return string.Empty; + + if (maxChars <= WatchDiagnosticTruncationMarker.Length) + return WatchDiagnosticTruncationMarker[..maxChars]; + + return flattened[..(maxChars - WatchDiagnosticTruncationMarker.Length)] + WatchDiagnosticTruncationMarker; + } + + private static string FlattenWatchDiagnosticControlChars(string value) + { + var builder = new System.Text.StringBuilder(value.Length); + foreach (var c in value) + builder.Append(char.IsControl(c) ? ' ' : c); + return builder.ToString(); + } + + private static int TrimTrailingLineBreaks(string value) + { + var length = value.Length; + while (length > 0 && (value[length - 1] == '\r' || value[length - 1] == '\n')) + length--; + + return length; + } + + private static void EmitWatchStarted( + IndexCommandOptions baseOptions, + JsonSerializerOptions jsonOptions, + string projectRoot, + string resolvedDbPath, + TimeSpan debounce, + int maxPendingPaths, + bool ignoreCase) + { + if (baseOptions.Json) + { + Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchStartedJsonResult + { + Status = "watching", + Phase = "initial_scan", + ProjectRoot = "[redacted]", + Db = "[redacted]", + DebounceMs = (int)debounce.TotalMilliseconds, + WatchPendingPathLimit = maxPendingPaths, + WatchContract = BuildWatchContract(debounce, maxPendingPaths, ignoreCase), + }, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchStartedJsonResult)); + } + else + { + CommandErrorWriter.WriteStderr(); + CommandErrorWriter.WriteStderr($"[watch] Watching {projectRoot} for changes (debounce {(int)debounce.TotalMilliseconds} ms, pending path limit {maxPendingPaths.ToString("N0", CultureInfo.InvariantCulture)}). Press Ctrl+C to stop."); + } + } + + private static IndexWatchContractJsonResult BuildWatchContract( + TimeSpan debounce, + int maxPendingPaths, + bool ignoreCase) + => new() + { + Debounce = "quiet_window", + DebounceMs = (int)debounce.TotalMilliseconds, + MaxDebounceMs = IndexWatchRunner.MaxDebounceMs, + PollIntervalMs = IndexWatchRunner.PollIntervalMs, + WatchPendingPathLimit = maxPendingPaths, + PathComparison = ignoreCase ? "ordinal_ignore_case" : "ordinal", + ChangeCoalescing = "distinct_paths_refresh_debounce", + RenameEvents = "old_and_new_paths", + OverflowRecovery = "full_rescan_after_debounce", + WatcherErrorRecovery = "full_rescan_after_debounce", + Cancellation = "cancel_active_sub_run_then_emit_stopped", + SubRunOutput = "json_quiet_sub_runs", + McpWatchMode = "unsupported", + }; + + private static void EmitWatchOverflow( + IndexCommandOptions baseOptions, + JsonSerializerOptions jsonOptions, + string? reason, + string resolvedDbPath) + { + var safeReason = FormatWatchDiagnosticText(reason); + if (baseOptions.Json) + { + Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchEventJsonResult + { + Status = "overflow", + Reason = safeReason, + Phase = "incremental", + OverflowReason = safeReason, + WatchPendingPathLimit = baseOptions.WatchPendingPathLimit, + RecoveryCommand = BuildOverflowRecoveryCommand(baseOptions, resolvedDbPath, redactPaths: true), + }, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchEventJsonResult)); + } + else + { + var detail = string.IsNullOrEmpty(safeReason) ? string.Empty : $" ({safeReason})"; + CommandErrorWriter.WriteStderr($"[watch] Watcher buffer overflowed{detail}; falling back to full rescan."); + } + } + + private static void EmitWatchStopped(IndexCommandOptions baseOptions, JsonSerializerOptions jsonOptions) + { + if (baseOptions.Json) + { + Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchEventJsonResult + { + Status = "stopped", + }, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchEventJsonResult)); + } + else + { + CommandErrorWriter.WriteStderr("[watch] Stopped."); + } + } + + private static IndexWatchRecoveryCommandJsonResult BuildOverflowRecoveryCommand(IndexCommandOptions baseOptions, string resolvedDbPath, bool redactPaths = false) + { + var args = BuildSubRunArgs(baseOptions, resolvedDbPath); + args.Insert(0, "index"); + if (redactPaths) + RedactOverflowRecoveryPathArgs(args); + return new IndexWatchRecoveryCommandJsonResult + { + Command = "cdidx", + Args = args, + }; + } + + private static void RedactOverflowRecoveryPathArgs(List args) + { + if (args.Count > 1) + args[1] = "[redacted]"; + + for (var i = 0; i < args.Count - 1; i++) + { + if (string.Equals(args[i], "--db", StringComparison.Ordinal)) + args[i + 1] = "[redacted]"; + } + } + + private readonly record struct WatchSubRunSummary( + int? Updated, + int? Removed, + int? Errors, + long? FilesTotal, + int? FilesScanned, + int? FilesSkipped, + int? FilesPurged, + int? Warnings, + string ParseStatus, + string? ParseReason) + { + internal static WatchSubRunSummary Unparsed(string parseStatus, string parseReason) + => new(null, null, null, null, null, null, null, null, parseStatus, parseReason); + } +} diff --git a/src/CodeIndex/Cli/IndexWatchRunner.SubRuns.cs b/src/CodeIndex/Cli/IndexWatchRunner.SubRuns.cs new file mode 100644 index 000000000..f1801d93f --- /dev/null +++ b/src/CodeIndex/Cli/IndexWatchRunner.SubRuns.cs @@ -0,0 +1,323 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +internal static partial class IndexWatchRunner +{ + private static List BuildSubRunArgs(IndexCommandOptions baseOptions, string? resolvedDbPath = null) + { + // Always pass --json so sub-runs produce a single JSON-line summary on stdout. The + // watch loop then either forwards that line (user --json) or extracts a one-line + // human summary (user non-JSON). Otherwise each sub-run would reprint the banner. + // 常に --json を付けてサブ実行の stdout を1行 JSON に揃える。watch ループ側で + // 透過 or 整形してから出力する。 + var args = new List(8) { baseOptions.ProjectPath!, "--json", "--quiet" }; + var dbPath = string.IsNullOrEmpty(resolvedDbPath) ? baseOptions.DbPath : resolvedDbPath; + if (!string.IsNullOrEmpty(dbPath)) + { + args.Add("--db"); + args.Add(dbPath!); + } + if (baseOptions.Verbose && baseOptions.Json) + args.Add("--verbose"); + if (baseOptions.MaxFileSizeBytes is { } maxFileSizeBytes) + { + args.Add("--max-file-bytes"); + args.Add(maxFileSizeBytes.ToString(CultureInfo.InvariantCulture)); + } + if (baseOptions.MaxSymbolsPerFile != IndexCommandRunner.DefaultMaxSymbolsPerFile) + { + args.Add("--max-symbols-per-file"); + args.Add(baseOptions.MaxSymbolsPerFile.ToString(CultureInfo.InvariantCulture)); + } + if (baseOptions.MaxReferencesPerFile != IndexCommandRunner.DefaultMaxReferencesPerFile) + { + args.Add("--max-references-per-file"); + args.Add(baseOptions.MaxReferencesPerFile.ToString(CultureInfo.InvariantCulture)); + } + if (baseOptions.Parallelism != IndexCommandRunner.DefaultIndexParallelism()) + { + args.Add("--parallelism"); + args.Add(baseOptions.Parallelism.ToString(CultureInfo.InvariantCulture)); + } + if (baseOptions.SymlinkPolicy != FileIndexer.SymlinkPolicy.None) + { + args.Add("--follow-symlinks"); + args.Add(baseOptions.SymlinkPolicy.ToString().ToLowerInvariant()); + } + if (baseOptions.SymbolKindFilter.Include.Count > 0) + { + args.Add("--include-symbol-kind"); + args.Add(string.Join(",", baseOptions.SymbolKindFilter.Include)); + } + if (baseOptions.SymbolKindFilter.Exclude.Count > 0) + { + args.Add("--exclude-symbol-kind"); + args.Add(string.Join(",", baseOptions.SymbolKindFilter.Exclude)); + } + return args; + } + + internal static int InvokeSubRunAndEmit( + IndexCommandOptions baseOptions, + JsonSerializerOptions jsonOptions, + List args, + Stopwatch stopwatch, + string status, + int? batchSize, + string phase, + IReadOnlyList? batchPaths, + CancellationToken cancellationToken) + { + string capturedJson; + string? spoolPath = null; + int subRunExitCode; + WatchSubRunCaptureWriter? captureWriter = null; + try + { + TextWriter? spoolWriter = null; + if (baseOptions.Json) + { + spoolPath = Path.Combine(Path.GetTempPath(), $"cdidx-watch-subrun-{Guid.NewGuid():N}.jsonl"); + spoolWriter = new StreamWriter( + CreateSubRunSpoolFileStream(spoolPath), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + captureWriter = new WatchSubRunCaptureWriter(MaxHumanSummarySubRunJsonChars + 1, spoolWriter); + using var subRunCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + subRunExitCode = IndexCommandRunner.Run(args.ToArray(), jsonOptions, subRunCancellation, captureWriter); + + captureWriter.Flush(); + capturedJson = captureWriter.CapturedText; + } + finally + { + captureWriter?.Dispose(); + } + stopwatch.Stop(); + var eventStatus = subRunExitCode == CommandExitCodes.Success ? status : "failed"; + var failureReason = subRunExitCode == CommandExitCodes.Success + ? null + : $"{status} sub-run exited with code {subRunExitCode.ToString(CultureInfo.InvariantCulture)}"; + var summary = ParseSubRunSummary(capturedJson); + + if (baseOptions.Json) + { + var pathSamples = BuildBatchPathSamples(baseOptions.ProjectPath!, batchPaths, out var pathSamplesTruncated); + // Pre-pend a watch-event header line so MCP clients can distinguish watch + // batches from the initial scan. The underlying sub-run result follows. + // watch バッチであることを示すヘッダ行を先頭に流し、その後にサブ実行 JSON を出す。 + var watchEvent = new IndexWatchEventJsonResult + { + Status = eventStatus, + Phase = phase, + BatchSize = batchSize, + BatchPathSamples = pathSamples.Count > 0 ? pathSamples : null, + BatchPathSampleLimit = batchPaths == null ? null : BatchPathSampleLimit, + BatchPathSamplesTruncated = batchPaths == null ? null : pathSamplesTruncated, + ElapsedMs = stopwatch.ElapsedMilliseconds, + ExitCode = subRunExitCode, + Updated = summary.Updated, + Removed = summary.Removed, + Errors = summary.Errors, + SubRunParseStatus = summary.ParseStatus, + SubRunParseReason = summary.ParseReason, + Reason = failureReason, + }; + var payload = JsonSerializer + .SerializeToNode(watchEvent, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchEventJsonResult)! + .AsObject(); + AddWatchSubRunSummaryFields(payload, status, subRunExitCode, summary); + Console.Out.WriteLine(payload.ToJsonString(EnsureJsonNodeSerializerOptions(jsonOptions))); + + if (!TryWriteSpooledSubRunOutput(spoolPath, out var endedWithLineBreak)) + { + var trimmed = capturedJson.TrimEnd('\r', '\n'); + if (!string.IsNullOrEmpty(trimmed)) + Console.Out.WriteLine(trimmed); + } + else if (!endedWithLineBreak) + { + Console.Out.WriteLine(); + } + } + else + { + var human = FormatHumanSummary(eventStatus, batchSize, stopwatch.ElapsedMilliseconds, capturedJson, subRunExitCode); + CommandErrorWriter.WriteStderr(human); + } + + DeleteSpoolFile(spoolPath); + return subRunExitCode; + } + + internal static FileStream CreateSubRunSpoolFileStream(string spoolPath) + => DataDirectorySecurity.OpenPrivateFileStream(spoolPath, FileMode.CreateNew, FileAccess.Write, FileShare.Read); + + private static bool TryWriteSpooledSubRunOutput(string? spoolPath, out bool endedWithLineBreak) + { + endedWithLineBreak = true; + if (string.IsNullOrWhiteSpace(spoolPath) || !File.Exists(spoolPath) || new FileInfo(spoolPath).Length == 0) + return false; + + var buffer = new char[8192]; + var wroteAny = false; + char lastChar = '\0'; + using var reader = new StreamReader(spoolPath, Encoding.UTF8); + int read; + while ((read = reader.Read(buffer, 0, buffer.Length)) > 0) + { + Console.Out.Write(buffer, 0, read); + wroteAny = true; + lastChar = buffer[read - 1]; + } + + endedWithLineBreak = !wroteAny || lastChar is '\n' or '\r'; + return wroteAny; + } + + private static bool DeleteSpoolFile(string? spoolPath, Action? deleteOverride = null) + { + if (string.IsNullOrWhiteSpace(spoolPath)) + return false; + + return AtomicFileWriter.TryDeleteFile( + spoolPath, + ex => ReportSpoolCleanupFailure(spoolPath, ex), + deleteOverride); + } + + private static void ReportSpoolCleanupFailure(string spoolPath, Exception exception) + { + var target = FormatSpoolCleanupTarget(spoolPath); + var reason = CommandErrorWriter.FormatSanitizedException(exception); + GlobalToolLog.Error($"watch_spool_cleanup_failed target={target} reason={reason}"); + CommandErrorWriter.WriteStderr( + $"Warning [watch_spool_cleanup_failed]: failed to delete watch spool file {target} ({reason})."); + } + + private static string FormatSpoolCleanupTarget(string path) + { + try + { + var target = Path.GetFileName(path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + return ConsoleUi.FormatBoundedValue(string.IsNullOrWhiteSpace(target) ? "" : target); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + return ""; + } + } + + internal sealed class WatchSubRunCaptureWriter : TextWriter + { + private readonly int _maxCapturedChars; + private readonly TextWriter? _inner; + private readonly StringBuilder _captured = new(); + + internal WatchSubRunCaptureWriter(int maxCapturedChars, TextWriter? inner) + { + _maxCapturedChars = Math.Max(0, maxCapturedChars); + _inner = inner; + } + + public override Encoding Encoding => _inner?.Encoding ?? Encoding.UTF8; + + internal string CapturedText => _captured.ToString(); + + internal bool Truncated { get; private set; } + + public override void Write(char value) + { + Capture(stackalloc char[] { value }); + _inner?.Write(value); + } + + public override void Write(string? value) + { + if (value != null) + Capture(value.AsSpan()); + _inner?.Write(value); + } + + public override void Write(char[] buffer, int index, int count) + { + Capture(buffer.AsSpan(index, count)); + _inner?.Write(buffer, index, count); + } + + public override void Flush() + { + _inner?.Flush(); + base.Flush(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + _inner?.Dispose(); + base.Dispose(disposing); + } + + private void Capture(ReadOnlySpan value) + { + var remaining = _maxCapturedChars - _captured.Length; + if (remaining <= 0) + { + if (value.Length > 0) + Truncated = true; + return; + } + + var take = Math.Min(remaining, value.Length); + _captured.Append(value[..take]); + if (take < value.Length) + Truncated = true; + } + } + + + private static void AddWatchSubRunSummaryFields(JsonObject payload, string requestedStatus, int subRunExitCode, WatchSubRunSummary summary) + { + if (!string.Equals(requestedStatus, "rescanned", StringComparison.Ordinal)) + return; + + payload["rescan_scope"] = "full_workspace"; + payload["rescan_completed"] = subRunExitCode == CommandExitCodes.Success && summary.ParseStatus == "parsed"; + + if (summary.FilesTotal is long filesTotal) + payload["files_total"] = filesTotal; + if (summary.FilesScanned is int filesScanned) + payload["files_scanned"] = filesScanned; + if (summary.FilesSkipped is int filesSkipped) + { + payload["files_skipped"] = filesSkipped; + if (filesSkipped > 0) + payload["files_skipped_category"] = "unchanged_or_reused_files"; + } + if (summary.FilesPurged is int filesPurged) + payload["files_purged"] = filesPurged; + if (summary.Warnings is int warnings) + payload["warnings"] = warnings; + } + + private static JsonSerializerOptions EnsureJsonNodeSerializerOptions(JsonSerializerOptions jsonOptions) + { + if (jsonOptions.TypeInfoResolver != null) + return jsonOptions; + + return new JsonSerializerOptions(jsonOptions) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + } + +} diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs new file mode 100644 index 000000000..0eda6ee88 --- /dev/null +++ b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs @@ -0,0 +1,255 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +internal static partial class IndexWatchRunner +{ + private static List CreateAncestorIgnoreWatchers( + string projectRoot, + string ignoreRuleRoot, + bool ignoreCase, + Action enqueue) + { + var watchers = new List(); + var fullProjectRoot = Path.GetFullPath(projectRoot); + var fullIgnoreRuleRoot = Path.GetFullPath(ignoreRuleRoot); + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (string.Equals(fullProjectRoot, fullIgnoreRuleRoot, comparison)) + return watchers; + + var relativeProjectRoot = Path.GetRelativePath(fullIgnoreRuleRoot, fullProjectRoot); + if (Path.IsPathRooted(relativeProjectRoot) + || relativeProjectRoot == ".." + || relativeProjectRoot.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || relativeProjectRoot.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal)) + { + return watchers; + } + + try + { + var directory = Directory.GetParent(fullProjectRoot); + while (directory != null) + { + var ancestorWatcher = new FileSystemWatcher(directory.FullName) + { + IncludeSubdirectories = false, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size, + }; + watchers.Add(ancestorWatcher); + ancestorWatcher.Filters.Add(".gitignore"); + ancestorWatcher.Filters.Add(".cdidxignore"); + ancestorWatcher.Created += (_, e) => enqueue(e.FullPath); + ancestorWatcher.Changed += (_, e) => enqueue(e.FullPath); + ancestorWatcher.Deleted += (_, e) => enqueue(e.FullPath); + ancestorWatcher.Renamed += (_, e) => + { + enqueue(e.OldFullPath); + enqueue(e.FullPath); + }; + ancestorWatcher.EnableRaisingEvents = true; + + if (string.Equals(directory.FullName, fullIgnoreRuleRoot, comparison)) + break; + + directory = directory.Parent; + } + } + catch + { + foreach (var watcher in watchers) + watcher.Dispose(); + throw; + } + + return watchers; + } + + private static int RunPartialUpdate( + IndexCommandOptions baseOptions, + JsonSerializerOptions jsonOptions, + IReadOnlyList changedPaths, + string resolvedDbPath, + CancellationToken cancellationToken, + string phase = "incremental") + { + var baseArgs = BuildSubRunArgs(baseOptions, resolvedDbPath); + var batches = BuildPartialUpdateBatches(baseArgs, changedPaths); + if (batches == null) + return RunFullRescan(baseOptions, jsonOptions, resolvedDbPath, cancellationToken); + + var exitCode = CommandExitCodes.Success; + foreach (var batch in batches) + { + var stopwatch = Stopwatch.StartNew(); + var args = new List(baseArgs.Count + 1 + batch.Count); + args.AddRange(baseArgs); + args.Add("--files"); + args.AddRange(batch); + + var subRunExitCode = InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "updated", batch.Count, phase, batch, cancellationToken); + RecordSubRunExitCode(ref exitCode, subRunExitCode); + if (cancellationToken.IsCancellationRequested) + break; + } + + return exitCode; + } + + internal static List>? BuildPartialUpdateBatches(IReadOnlyList baseArgs, IReadOnlyList changedPaths) + { + var baseArgumentChars = EstimateSubRunArgumentChars(baseArgs) + EstimateSubRunArgumentChars("--files"); + var batches = new List>(); + var current = new List(); + var currentArgumentChars = baseArgumentChars; + + foreach (var path in changedPaths) + { + var pathArgumentChars = EstimateSubRunArgumentChars(path); + if (baseArgumentChars + pathArgumentChars > MaxSubRunArgumentChars) + return null; + + if (current.Count > 0 && currentArgumentChars + pathArgumentChars > MaxSubRunArgumentChars) + { + batches.Add(current); + current = new List(); + currentArgumentChars = baseArgumentChars; + } + + current.Add(path); + currentArgumentChars += pathArgumentChars; + } + + if (current.Count > 0) + batches.Add(current); + return batches; + } + + private static int EstimateSubRunArgumentChars(IEnumerable args) + { + var total = 0; + foreach (var arg in args) + total += EstimateSubRunArgumentChars(arg); + return total; + } + + private static int EstimateSubRunArgumentChars(string arg) + => arg.Length + 1; + + private static int RunFullRescan( + IndexCommandOptions baseOptions, + JsonSerializerOptions jsonOptions, + string resolvedDbPath, + CancellationToken cancellationToken, + string phase = "incremental") + { + var stopwatch = Stopwatch.StartNew(); + var args = BuildSubRunArgs(baseOptions, resolvedDbPath); + // No --files: this is a default incremental full scan. + // --files を付けない: 通常のインクリメンタル全件スキャン。 + return InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "rescanned", batchSize: null, phase, batchPaths: null, cancellationToken); + } + + private static void RecordSubRunExitCode(ref int watchExitCode, int subRunExitCode) + { + if (subRunExitCode != CommandExitCodes.Success) + watchExitCode = subRunExitCode; + } + + private static bool ShouldIgnoreWatchInternalPath( + string projectRoot, + string resolvedDbPath, + string fullPath, + bool ignoreCase, + bool dbPathExplicit) + { + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var normalizedPath = Path.GetFullPath(fullPath); + var normalizedProjectRoot = Path.GetFullPath(projectRoot); + var normalizedDbPath = Path.GetFullPath(DbPathResolver.NormalizeDbPath(resolvedDbPath)); + + var defaultDataDir = Path.Combine(normalizedProjectRoot, ".cdidx"); + var dbDirectory = Path.GetDirectoryName(normalizedDbPath); + if (!dbPathExplicit && !string.IsNullOrEmpty(dbDirectory) + && !IsSamePath(defaultDataDir, dbDirectory, comparison) + && IsSameOrUnderDirectory(dbDirectory, normalizedPath, comparison)) + { + return true; + } + + if (IsSamePath(normalizedPath, normalizedDbPath, comparison)) + return true; + + foreach (var suffix in new[] { "-wal", "-shm", "-journal" }) + { + if (IsSamePath(normalizedPath, normalizedDbPath + suffix, comparison)) + return true; + } + + var lockPath = IndexLock.GetLockPath(normalizedDbPath); + if (IsSamePath(normalizedPath, lockPath, comparison) + || IsSamePath(normalizedPath, IndexLock.GetInfoPath(lockPath), comparison) + || normalizedPath.StartsWith(lockPath + ".", comparison)) + { + return true; + } + + return false; + } + + private static WatchPathDisposition ClassifyWatchPath( + string projectRoot, + string resolvedDbPath, + string fullPath, + bool ignoreCase, + bool dbPathExplicit, + FileIndexer fileIndexer) + { + var invalidation = FileIndexer.ClassifyIndexInputInvalidation(projectRoot, fullPath); + if (invalidation != FileIndexer.IndexInputInvalidationKind.None) + return WatchPathDisposition.Reconcile; + + if (ShouldIgnoreWatchInternalPath(projectRoot, resolvedDbPath, fullPath, ignoreCase, dbPathExplicit) + || fileIndexer.ShouldSkipPath(fullPath)) + { + return WatchPathDisposition.Ignore; + } + + return WatchPathDisposition.Index; + } + + private static bool IsSameOrUnderDirectory(string directory, string fullPath, StringComparison comparison) + { + var normalizedDirectory = Path.GetFullPath(directory); + if (IsSamePath(normalizedDirectory, fullPath, comparison)) + return true; + + var directoryPrefix = Path.EndsInDirectorySeparator(normalizedDirectory) + ? normalizedDirectory + : normalizedDirectory + Path.DirectorySeparatorChar; + return fullPath.StartsWith(directoryPrefix, comparison); + } + + private static bool IsSamePath(string left, string right, StringComparison comparison) + => string.Equals( + TrimDirectorySeparators(left), + TrimDirectorySeparators(right), + comparison); + + private static string TrimDirectorySeparators(string value) + { + var root = Path.GetPathRoot(value); + var trimmed = value.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.IsNullOrEmpty(trimmed) && !string.IsNullOrEmpty(root) + ? root + : trimmed; + } + +} diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index d2d58edd1..abc64713b 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -16,7 +16,7 @@ namespace CodeIndex.Cli; /// `cdidx index --watch` のループ実装。FileSystemWatcher で変更を観測し、debounce ウィンドウで /// バッチ化したうえで部分更新 (`--files`) として再実行する。 /// -internal static class IndexWatchRunner +internal static partial class IndexWatchRunner { internal enum WatchPathDisposition { @@ -293,982 +293,4 @@ void Enqueue(string fullPath) return watchExitCode; } - private static List CreateAncestorIgnoreWatchers( - string projectRoot, - string ignoreRuleRoot, - bool ignoreCase, - Action enqueue) - { - var watchers = new List(); - var fullProjectRoot = Path.GetFullPath(projectRoot); - var fullIgnoreRuleRoot = Path.GetFullPath(ignoreRuleRoot); - var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - if (string.Equals(fullProjectRoot, fullIgnoreRuleRoot, comparison)) - return watchers; - - var relativeProjectRoot = Path.GetRelativePath(fullIgnoreRuleRoot, fullProjectRoot); - if (Path.IsPathRooted(relativeProjectRoot) - || relativeProjectRoot == ".." - || relativeProjectRoot.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) - || relativeProjectRoot.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal)) - { - return watchers; - } - - try - { - var directory = Directory.GetParent(fullProjectRoot); - while (directory != null) - { - var ancestorWatcher = new FileSystemWatcher(directory.FullName) - { - IncludeSubdirectories = false, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size, - }; - watchers.Add(ancestorWatcher); - ancestorWatcher.Filters.Add(".gitignore"); - ancestorWatcher.Filters.Add(".cdidxignore"); - ancestorWatcher.Created += (_, e) => enqueue(e.FullPath); - ancestorWatcher.Changed += (_, e) => enqueue(e.FullPath); - ancestorWatcher.Deleted += (_, e) => enqueue(e.FullPath); - ancestorWatcher.Renamed += (_, e) => - { - enqueue(e.OldFullPath); - enqueue(e.FullPath); - }; - ancestorWatcher.EnableRaisingEvents = true; - - if (string.Equals(directory.FullName, fullIgnoreRuleRoot, comparison)) - break; - - directory = directory.Parent; - } - } - catch - { - foreach (var watcher in watchers) - watcher.Dispose(); - throw; - } - - return watchers; - } - - private static int RunPartialUpdate( - IndexCommandOptions baseOptions, - JsonSerializerOptions jsonOptions, - IReadOnlyList changedPaths, - string resolvedDbPath, - CancellationToken cancellationToken, - string phase = "incremental") - { - var baseArgs = BuildSubRunArgs(baseOptions, resolvedDbPath); - var batches = BuildPartialUpdateBatches(baseArgs, changedPaths); - if (batches == null) - return RunFullRescan(baseOptions, jsonOptions, resolvedDbPath, cancellationToken); - - var exitCode = CommandExitCodes.Success; - foreach (var batch in batches) - { - var stopwatch = Stopwatch.StartNew(); - var args = new List(baseArgs.Count + 1 + batch.Count); - args.AddRange(baseArgs); - args.Add("--files"); - args.AddRange(batch); - - var subRunExitCode = InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "updated", batch.Count, phase, batch, cancellationToken); - RecordSubRunExitCode(ref exitCode, subRunExitCode); - if (cancellationToken.IsCancellationRequested) - break; - } - - return exitCode; - } - - internal static List>? BuildPartialUpdateBatches(IReadOnlyList baseArgs, IReadOnlyList changedPaths) - { - var baseArgumentChars = EstimateSubRunArgumentChars(baseArgs) + EstimateSubRunArgumentChars("--files"); - var batches = new List>(); - var current = new List(); - var currentArgumentChars = baseArgumentChars; - - foreach (var path in changedPaths) - { - var pathArgumentChars = EstimateSubRunArgumentChars(path); - if (baseArgumentChars + pathArgumentChars > MaxSubRunArgumentChars) - return null; - - if (current.Count > 0 && currentArgumentChars + pathArgumentChars > MaxSubRunArgumentChars) - { - batches.Add(current); - current = new List(); - currentArgumentChars = baseArgumentChars; - } - - current.Add(path); - currentArgumentChars += pathArgumentChars; - } - - if (current.Count > 0) - batches.Add(current); - return batches; - } - - private static int EstimateSubRunArgumentChars(IEnumerable args) - { - var total = 0; - foreach (var arg in args) - total += EstimateSubRunArgumentChars(arg); - return total; - } - - private static int EstimateSubRunArgumentChars(string arg) - => arg.Length + 1; - - private static int RunFullRescan( - IndexCommandOptions baseOptions, - JsonSerializerOptions jsonOptions, - string resolvedDbPath, - CancellationToken cancellationToken, - string phase = "incremental") - { - var stopwatch = Stopwatch.StartNew(); - var args = BuildSubRunArgs(baseOptions, resolvedDbPath); - // No --files: this is a default incremental full scan. - // --files を付けない: 通常のインクリメンタル全件スキャン。 - return InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "rescanned", batchSize: null, phase, batchPaths: null, cancellationToken); - } - - private static void RecordSubRunExitCode(ref int watchExitCode, int subRunExitCode) - { - if (subRunExitCode != CommandExitCodes.Success) - watchExitCode = subRunExitCode; - } - - private static bool ShouldIgnoreWatchInternalPath( - string projectRoot, - string resolvedDbPath, - string fullPath, - bool ignoreCase, - bool dbPathExplicit) - { - var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - var normalizedPath = Path.GetFullPath(fullPath); - var normalizedProjectRoot = Path.GetFullPath(projectRoot); - var normalizedDbPath = Path.GetFullPath(DbPathResolver.NormalizeDbPath(resolvedDbPath)); - - var defaultDataDir = Path.Combine(normalizedProjectRoot, ".cdidx"); - var dbDirectory = Path.GetDirectoryName(normalizedDbPath); - if (!dbPathExplicit && !string.IsNullOrEmpty(dbDirectory) - && !IsSamePath(defaultDataDir, dbDirectory, comparison) - && IsSameOrUnderDirectory(dbDirectory, normalizedPath, comparison)) - { - return true; - } - - if (IsSamePath(normalizedPath, normalizedDbPath, comparison)) - return true; - - foreach (var suffix in new[] { "-wal", "-shm", "-journal" }) - { - if (IsSamePath(normalizedPath, normalizedDbPath + suffix, comparison)) - return true; - } - - var lockPath = IndexLock.GetLockPath(normalizedDbPath); - if (IsSamePath(normalizedPath, lockPath, comparison) - || IsSamePath(normalizedPath, IndexLock.GetInfoPath(lockPath), comparison) - || normalizedPath.StartsWith(lockPath + ".", comparison)) - { - return true; - } - - return false; - } - - private static WatchPathDisposition ClassifyWatchPath( - string projectRoot, - string resolvedDbPath, - string fullPath, - bool ignoreCase, - bool dbPathExplicit, - FileIndexer fileIndexer) - { - var invalidation = FileIndexer.ClassifyIndexInputInvalidation(projectRoot, fullPath); - if (invalidation != FileIndexer.IndexInputInvalidationKind.None) - return WatchPathDisposition.Reconcile; - - if (ShouldIgnoreWatchInternalPath(projectRoot, resolvedDbPath, fullPath, ignoreCase, dbPathExplicit) - || fileIndexer.ShouldSkipPath(fullPath)) - { - return WatchPathDisposition.Ignore; - } - - return WatchPathDisposition.Index; - } - - private static bool IsSameOrUnderDirectory(string directory, string fullPath, StringComparison comparison) - { - var normalizedDirectory = Path.GetFullPath(directory); - if (IsSamePath(normalizedDirectory, fullPath, comparison)) - return true; - - var directoryPrefix = Path.EndsInDirectorySeparator(normalizedDirectory) - ? normalizedDirectory - : normalizedDirectory + Path.DirectorySeparatorChar; - return fullPath.StartsWith(directoryPrefix, comparison); - } - - private static bool IsSamePath(string left, string right, StringComparison comparison) - => string.Equals( - TrimDirectorySeparators(left), - TrimDirectorySeparators(right), - comparison); - - private static string TrimDirectorySeparators(string value) - { - var root = Path.GetPathRoot(value); - var trimmed = value.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(trimmed) && !string.IsNullOrEmpty(root) - ? root - : trimmed; - } - - private static List BuildSubRunArgs(IndexCommandOptions baseOptions, string? resolvedDbPath = null) - { - // Always pass --json so sub-runs produce a single JSON-line summary on stdout. The - // watch loop then either forwards that line (user --json) or extracts a one-line - // human summary (user non-JSON). Otherwise each sub-run would reprint the banner. - // 常に --json を付けてサブ実行の stdout を1行 JSON に揃える。watch ループ側で - // 透過 or 整形してから出力する。 - var args = new List(8) { baseOptions.ProjectPath!, "--json", "--quiet" }; - var dbPath = string.IsNullOrEmpty(resolvedDbPath) ? baseOptions.DbPath : resolvedDbPath; - if (!string.IsNullOrEmpty(dbPath)) - { - args.Add("--db"); - args.Add(dbPath!); - } - if (baseOptions.Verbose && baseOptions.Json) - args.Add("--verbose"); - if (baseOptions.MaxFileSizeBytes is { } maxFileSizeBytes) - { - args.Add("--max-file-bytes"); - args.Add(maxFileSizeBytes.ToString(CultureInfo.InvariantCulture)); - } - if (baseOptions.MaxSymbolsPerFile != IndexCommandRunner.DefaultMaxSymbolsPerFile) - { - args.Add("--max-symbols-per-file"); - args.Add(baseOptions.MaxSymbolsPerFile.ToString(CultureInfo.InvariantCulture)); - } - if (baseOptions.MaxReferencesPerFile != IndexCommandRunner.DefaultMaxReferencesPerFile) - { - args.Add("--max-references-per-file"); - args.Add(baseOptions.MaxReferencesPerFile.ToString(CultureInfo.InvariantCulture)); - } - if (baseOptions.Parallelism != IndexCommandRunner.DefaultIndexParallelism()) - { - args.Add("--parallelism"); - args.Add(baseOptions.Parallelism.ToString(CultureInfo.InvariantCulture)); - } - if (baseOptions.SymlinkPolicy != FileIndexer.SymlinkPolicy.None) - { - args.Add("--follow-symlinks"); - args.Add(baseOptions.SymlinkPolicy.ToString().ToLowerInvariant()); - } - if (baseOptions.SymbolKindFilter.Include.Count > 0) - { - args.Add("--include-symbol-kind"); - args.Add(string.Join(",", baseOptions.SymbolKindFilter.Include)); - } - if (baseOptions.SymbolKindFilter.Exclude.Count > 0) - { - args.Add("--exclude-symbol-kind"); - args.Add(string.Join(",", baseOptions.SymbolKindFilter.Exclude)); - } - return args; - } - - internal static int InvokeSubRunAndEmit( - IndexCommandOptions baseOptions, - JsonSerializerOptions jsonOptions, - List args, - Stopwatch stopwatch, - string status, - int? batchSize, - string phase, - IReadOnlyList? batchPaths, - CancellationToken cancellationToken) - { - string capturedJson; - string? spoolPath = null; - int subRunExitCode; - WatchSubRunCaptureWriter? captureWriter = null; - try - { - TextWriter? spoolWriter = null; - if (baseOptions.Json) - { - spoolPath = Path.Combine(Path.GetTempPath(), $"cdidx-watch-subrun-{Guid.NewGuid():N}.jsonl"); - spoolWriter = new StreamWriter( - CreateSubRunSpoolFileStream(spoolPath), - new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - } - - captureWriter = new WatchSubRunCaptureWriter(MaxHumanSummarySubRunJsonChars + 1, spoolWriter); - using var subRunCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - subRunExitCode = IndexCommandRunner.Run(args.ToArray(), jsonOptions, subRunCancellation, captureWriter); - - captureWriter.Flush(); - capturedJson = captureWriter.CapturedText; - } - finally - { - captureWriter?.Dispose(); - } - stopwatch.Stop(); - var eventStatus = subRunExitCode == CommandExitCodes.Success ? status : "failed"; - var failureReason = subRunExitCode == CommandExitCodes.Success - ? null - : $"{status} sub-run exited with code {subRunExitCode.ToString(CultureInfo.InvariantCulture)}"; - var summary = ParseSubRunSummary(capturedJson); - - if (baseOptions.Json) - { - var pathSamples = BuildBatchPathSamples(baseOptions.ProjectPath!, batchPaths, out var pathSamplesTruncated); - // Pre-pend a watch-event header line so MCP clients can distinguish watch - // batches from the initial scan. The underlying sub-run result follows. - // watch バッチであることを示すヘッダ行を先頭に流し、その後にサブ実行 JSON を出す。 - var watchEvent = new IndexWatchEventJsonResult - { - Status = eventStatus, - Phase = phase, - BatchSize = batchSize, - BatchPathSamples = pathSamples.Count > 0 ? pathSamples : null, - BatchPathSampleLimit = batchPaths == null ? null : BatchPathSampleLimit, - BatchPathSamplesTruncated = batchPaths == null ? null : pathSamplesTruncated, - ElapsedMs = stopwatch.ElapsedMilliseconds, - ExitCode = subRunExitCode, - Updated = summary.Updated, - Removed = summary.Removed, - Errors = summary.Errors, - SubRunParseStatus = summary.ParseStatus, - SubRunParseReason = summary.ParseReason, - Reason = failureReason, - }; - var payload = JsonSerializer - .SerializeToNode(watchEvent, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchEventJsonResult)! - .AsObject(); - AddWatchSubRunSummaryFields(payload, status, subRunExitCode, summary); - Console.Out.WriteLine(payload.ToJsonString(EnsureJsonNodeSerializerOptions(jsonOptions))); - - if (!TryWriteSpooledSubRunOutput(spoolPath, out var endedWithLineBreak)) - { - var trimmed = capturedJson.TrimEnd('\r', '\n'); - if (!string.IsNullOrEmpty(trimmed)) - Console.Out.WriteLine(trimmed); - } - else if (!endedWithLineBreak) - { - Console.Out.WriteLine(); - } - } - else - { - var human = FormatHumanSummary(eventStatus, batchSize, stopwatch.ElapsedMilliseconds, capturedJson, subRunExitCode); - CommandErrorWriter.WriteStderr(human); - } - - DeleteSpoolFile(spoolPath); - return subRunExitCode; - } - - internal static FileStream CreateSubRunSpoolFileStream(string spoolPath) - => DataDirectorySecurity.OpenPrivateFileStream(spoolPath, FileMode.CreateNew, FileAccess.Write, FileShare.Read); - - private static bool TryWriteSpooledSubRunOutput(string? spoolPath, out bool endedWithLineBreak) - { - endedWithLineBreak = true; - if (string.IsNullOrWhiteSpace(spoolPath) || !File.Exists(spoolPath) || new FileInfo(spoolPath).Length == 0) - return false; - - var buffer = new char[8192]; - var wroteAny = false; - char lastChar = '\0'; - using var reader = new StreamReader(spoolPath, Encoding.UTF8); - int read; - while ((read = reader.Read(buffer, 0, buffer.Length)) > 0) - { - Console.Out.Write(buffer, 0, read); - wroteAny = true; - lastChar = buffer[read - 1]; - } - - endedWithLineBreak = !wroteAny || lastChar is '\n' or '\r'; - return wroteAny; - } - - private static bool DeleteSpoolFile(string? spoolPath, Action? deleteOverride = null) - { - if (string.IsNullOrWhiteSpace(spoolPath)) - return false; - - return AtomicFileWriter.TryDeleteFile( - spoolPath, - ex => ReportSpoolCleanupFailure(spoolPath, ex), - deleteOverride); - } - - private static void ReportSpoolCleanupFailure(string spoolPath, Exception exception) - { - var target = FormatSpoolCleanupTarget(spoolPath); - var reason = CommandErrorWriter.FormatSanitizedException(exception); - GlobalToolLog.Error($"watch_spool_cleanup_failed target={target} reason={reason}"); - CommandErrorWriter.WriteStderr( - $"Warning [watch_spool_cleanup_failed]: failed to delete watch spool file {target} ({reason})."); - } - - private static string FormatSpoolCleanupTarget(string path) - { - try - { - var target = Path.GetFileName(path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - return ConsoleUi.FormatBoundedValue(string.IsNullOrWhiteSpace(target) ? "" : target); - } - catch (Exception ex) when (ex is ArgumentException or NotSupportedException) - { - return ""; - } - } - - internal sealed class WatchSubRunCaptureWriter : TextWriter - { - private readonly int _maxCapturedChars; - private readonly TextWriter? _inner; - private readonly StringBuilder _captured = new(); - - internal WatchSubRunCaptureWriter(int maxCapturedChars, TextWriter? inner) - { - _maxCapturedChars = Math.Max(0, maxCapturedChars); - _inner = inner; - } - - public override Encoding Encoding => _inner?.Encoding ?? Encoding.UTF8; - - internal string CapturedText => _captured.ToString(); - - internal bool Truncated { get; private set; } - - public override void Write(char value) - { - Capture(stackalloc char[] { value }); - _inner?.Write(value); - } - - public override void Write(string? value) - { - if (value != null) - Capture(value.AsSpan()); - _inner?.Write(value); - } - - public override void Write(char[] buffer, int index, int count) - { - Capture(buffer.AsSpan(index, count)); - _inner?.Write(buffer, index, count); - } - - public override void Flush() - { - _inner?.Flush(); - base.Flush(); - } - - protected override void Dispose(bool disposing) - { - if (disposing) - _inner?.Dispose(); - base.Dispose(disposing); - } - - private void Capture(ReadOnlySpan value) - { - var remaining = _maxCapturedChars - _captured.Length; - if (remaining <= 0) - { - if (value.Length > 0) - Truncated = true; - return; - } - - var take = Math.Min(remaining, value.Length); - _captured.Append(value[..take]); - if (take < value.Length) - Truncated = true; - } - } - - - private static void AddWatchSubRunSummaryFields(JsonObject payload, string requestedStatus, int subRunExitCode, WatchSubRunSummary summary) - { - if (!string.Equals(requestedStatus, "rescanned", StringComparison.Ordinal)) - return; - - payload["rescan_scope"] = "full_workspace"; - payload["rescan_completed"] = subRunExitCode == CommandExitCodes.Success && summary.ParseStatus == "parsed"; - - if (summary.FilesTotal is long filesTotal) - payload["files_total"] = filesTotal; - if (summary.FilesScanned is int filesScanned) - payload["files_scanned"] = filesScanned; - if (summary.FilesSkipped is int filesSkipped) - { - payload["files_skipped"] = filesSkipped; - if (filesSkipped > 0) - payload["files_skipped_category"] = "unchanged_or_reused_files"; - } - if (summary.FilesPurged is int filesPurged) - payload["files_purged"] = filesPurged; - if (summary.Warnings is int warnings) - payload["warnings"] = warnings; - } - - private static JsonSerializerOptions EnsureJsonNodeSerializerOptions(JsonSerializerOptions jsonOptions) - { - if (jsonOptions.TypeInfoResolver != null) - return jsonOptions; - - return new JsonSerializerOptions(jsonOptions) - { - TypeInfoResolver = new DefaultJsonTypeInfoResolver(), - }; - } - - private static string FormatHumanSummary(string status, int? batchSize, long elapsedMs, string subRunJson, int exitCode) - { - var prefix = status switch - { - "rescanned" => "[watch] rescanned", - "failed" => "[watch] failed", - _ => "[watch] updated", - }; - var batchLabel = batchSize is int n - ? $" {ConsoleUi.Counted(n, "path", format: "N0")}" - : string.Empty; - - // Best-effort parse of the sub-run JSON to surface updated/removed/errors counts. - // The summary is informational; a parse failure must not break the watch loop. - // サブ実行 JSON から件数を best-effort で抽出。失敗してもループは続行する。 - var details = new List - { - $"exit code {exitCode.ToString(CultureInfo.InvariantCulture)}", - }; - var summary = ParseSubRunSummary(subRunJson); - if (summary.ParseStatus == "parsed") - { - details.Add($"updated {summary.Updated.GetValueOrDefault()}"); - details.Add($"removed {summary.Removed.GetValueOrDefault()}"); - details.Add($"errors {summary.Errors.GetValueOrDefault()}"); - if (string.Equals(status, "rescanned", StringComparison.Ordinal)) - { - if (summary.FilesScanned is int filesScanned) - details.Add($"scanned {filesScanned}"); - if (summary.FilesSkipped is int filesSkipped) - details.Add($"skipped {filesSkipped}"); - if (summary.FilesPurged is int filesPurged) - details.Add($"purged {filesPurged}"); - } - } - - var detail = details.Count > 0 ? $" ({string.Join(", ", details)})" : string.Empty; - return $"{prefix}{batchLabel}{detail} in {elapsedMs.ToString("N0", System.Globalization.CultureInfo.InvariantCulture)} ms"; - } - - private static WatchSubRunSummary ParseSubRunSummary(string subRunJson) - { - var trimmedLength = TrimTrailingLineBreaks(subRunJson); - if (trimmedLength == 0) - return WatchSubRunSummary.Unparsed("missing", "sub-run emitted no JSON"); - - if (trimmedLength > MaxHumanSummarySubRunJsonChars) - return WatchSubRunSummary.Unparsed("too_large", $"sub-run JSON exceeded {MaxHumanSummarySubRunJsonChars.ToString(CultureInfo.InvariantCulture)} characters"); - - try - { - using var doc = BoundedJson.ParseDocument( - subRunJson[..trimmedLength], - MaxHumanSummarySubRunJsonChars * 4, - MaxHumanSummaryJsonDepth); - var root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("summary", out var summary) - || summary.ValueKind != JsonValueKind.Object) - { - return WatchSubRunSummary.Unparsed("missing_summary", "sub-run JSON did not contain an object summary"); - } - - return new WatchSubRunSummary( - TryReadInt32(summary, "updated") ?? 0, - TryReadInt32(summary, "removed") ?? 0, - TryReadInt32(summary, "errors") ?? 0, - TryReadInt64(summary, "files_total"), - TryReadInt32(summary, "files_scanned"), - TryReadInt32(summary, "files_skipped") ?? TryReadInt32(summary, "skipped"), - TryReadInt32(summary, "files_purged"), - TryReadInt32(summary, "warnings"), - "parsed", - null); - } - catch (Exception ex) when (ex is JsonException or InvalidDataException) - { - return WatchSubRunSummary.Unparsed("invalid_json", CommandErrorWriter.FormatSanitizedExceptionMessage(ex)); - } - } - - private static int? TryReadInt32(JsonElement element, string propertyName) - => element.TryGetProperty(propertyName, out var property) && property.TryGetInt32(out var value) - ? value - : null; - - private static long? TryReadInt64(JsonElement element, string propertyName) - => element.TryGetProperty(propertyName, out var property) && property.TryGetInt64(out var value) - ? value - : null; - - private static List BuildBatchPathSamples(string projectRoot, IReadOnlyList? batchPaths, out bool truncated) - { - truncated = false; - if (batchPaths == null || batchPaths.Count == 0) - return []; - - truncated = batchPaths.Count > BatchPathSampleLimit; - var samples = new List(Math.Min(batchPaths.Count, BatchPathSampleLimit)); - foreach (var path in batchPaths.Take(BatchPathSampleLimit)) - { - var sample = path; - if (Path.IsPathRooted(path)) - sample = FileIndexer.GetRelativePathFromDirectory(projectRoot, path); - sample = FileIndexer.NormalizePathSeparators(sample); - var sanitized = DiagnosticRedactor.RedactSensitiveText(sample, "[redacted]", redactPaths: false); - var bounded = BoundWatchDisplayText(sanitized, BatchPathSampleMaxChars, out var sampleTruncated); - truncated |= sampleTruncated; - samples.Add(bounded); - } - - return samples; - } - - internal static string? FormatWatchDiagnosticText(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return null; - - var redacted = DiagnosticRedactor.RedactSensitiveText(value, "[redacted]", redactPaths: true); - return BoundWatchDisplayText(redacted, MaxWatchDiagnosticChars, out _); - } - - private static string BoundWatchDisplayText(string value, int maxChars, out bool truncated) - { - if (maxChars < 0) - throw new ArgumentOutOfRangeException(nameof(maxChars), maxChars, "Watch diagnostic limit must be non-negative."); - - var flattened = FlattenWatchDiagnosticControlChars(value); - if (flattened.Length <= maxChars) - { - truncated = false; - return flattened; - } - - truncated = true; - if (maxChars == 0) - return string.Empty; - - if (maxChars <= WatchDiagnosticTruncationMarker.Length) - return WatchDiagnosticTruncationMarker[..maxChars]; - - return flattened[..(maxChars - WatchDiagnosticTruncationMarker.Length)] + WatchDiagnosticTruncationMarker; - } - - private static string FlattenWatchDiagnosticControlChars(string value) - { - var builder = new System.Text.StringBuilder(value.Length); - foreach (var c in value) - builder.Append(char.IsControl(c) ? ' ' : c); - return builder.ToString(); - } - - private static int TrimTrailingLineBreaks(string value) - { - var length = value.Length; - while (length > 0 && (value[length - 1] == '\r' || value[length - 1] == '\n')) - length--; - - return length; - } - - private static void EmitWatchStarted( - IndexCommandOptions baseOptions, - JsonSerializerOptions jsonOptions, - string projectRoot, - string resolvedDbPath, - TimeSpan debounce, - int maxPendingPaths, - bool ignoreCase) - { - if (baseOptions.Json) - { - Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchStartedJsonResult - { - Status = "watching", - Phase = "initial_scan", - ProjectRoot = "[redacted]", - Db = "[redacted]", - DebounceMs = (int)debounce.TotalMilliseconds, - WatchPendingPathLimit = maxPendingPaths, - WatchContract = BuildWatchContract(debounce, maxPendingPaths, ignoreCase), - }, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchStartedJsonResult)); - } - else - { - CommandErrorWriter.WriteStderr(); - CommandErrorWriter.WriteStderr($"[watch] Watching {projectRoot} for changes (debounce {(int)debounce.TotalMilliseconds} ms, pending path limit {maxPendingPaths.ToString("N0", CultureInfo.InvariantCulture)}). Press Ctrl+C to stop."); - } - } - - private static IndexWatchContractJsonResult BuildWatchContract( - TimeSpan debounce, - int maxPendingPaths, - bool ignoreCase) - => new() - { - Debounce = "quiet_window", - DebounceMs = (int)debounce.TotalMilliseconds, - MaxDebounceMs = IndexWatchRunner.MaxDebounceMs, - PollIntervalMs = IndexWatchRunner.PollIntervalMs, - WatchPendingPathLimit = maxPendingPaths, - PathComparison = ignoreCase ? "ordinal_ignore_case" : "ordinal", - ChangeCoalescing = "distinct_paths_refresh_debounce", - RenameEvents = "old_and_new_paths", - OverflowRecovery = "full_rescan_after_debounce", - WatcherErrorRecovery = "full_rescan_after_debounce", - Cancellation = "cancel_active_sub_run_then_emit_stopped", - SubRunOutput = "json_quiet_sub_runs", - McpWatchMode = "unsupported", - }; - - private static void EmitWatchOverflow( - IndexCommandOptions baseOptions, - JsonSerializerOptions jsonOptions, - string? reason, - string resolvedDbPath) - { - var safeReason = FormatWatchDiagnosticText(reason); - if (baseOptions.Json) - { - Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchEventJsonResult - { - Status = "overflow", - Reason = safeReason, - Phase = "incremental", - OverflowReason = safeReason, - WatchPendingPathLimit = baseOptions.WatchPendingPathLimit, - RecoveryCommand = BuildOverflowRecoveryCommand(baseOptions, resolvedDbPath, redactPaths: true), - }, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchEventJsonResult)); - } - else - { - var detail = string.IsNullOrEmpty(safeReason) ? string.Empty : $" ({safeReason})"; - CommandErrorWriter.WriteStderr($"[watch] Watcher buffer overflowed{detail}; falling back to full rescan."); - } - } - - private static void EmitWatchStopped(IndexCommandOptions baseOptions, JsonSerializerOptions jsonOptions) - { - if (baseOptions.Json) - { - Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchEventJsonResult - { - Status = "stopped", - }, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchEventJsonResult)); - } - else - { - CommandErrorWriter.WriteStderr("[watch] Stopped."); - } - } - - private static IndexWatchRecoveryCommandJsonResult BuildOverflowRecoveryCommand(IndexCommandOptions baseOptions, string resolvedDbPath, bool redactPaths = false) - { - var args = BuildSubRunArgs(baseOptions, resolvedDbPath); - args.Insert(0, "index"); - if (redactPaths) - RedactOverflowRecoveryPathArgs(args); - return new IndexWatchRecoveryCommandJsonResult - { - Command = "cdidx", - Args = args, - }; - } - - private static void RedactOverflowRecoveryPathArgs(List args) - { - if (args.Count > 1) - args[1] = "[redacted]"; - - for (var i = 0; i < args.Count - 1; i++) - { - if (string.Equals(args[i], "--db", StringComparison.Ordinal)) - args[i + 1] = "[redacted]"; - } - } - - private readonly record struct WatchSubRunSummary( - int? Updated, - int? Removed, - int? Errors, - long? FilesTotal, - int? FilesScanned, - int? FilesSkipped, - int? FilesPurged, - int? Warnings, - string ParseStatus, - string? ParseReason) - { - internal static WatchSubRunSummary Unparsed(string parseStatus, string parseReason) - => new(null, null, null, null, null, null, null, null, parseStatus, parseReason); - } -} - -/// -/// Thread-safe queue that coalesces FileSystemWatcher events into a single batch once the -/// stream has been quiet for the debounce interval. Extracted for unit testing without -/// touching the filesystem. -/// FileSystemWatcher イベントを debounce 期間の静穏まで蓄積し、まとめてバッチ化するスレッドセーフな -/// キュー。ファイルシステムに触れずユニットテストできるよう分離。 -/// -internal sealed class FileChangeBatcher -{ - internal const int DefaultMaxPendingPaths = IndexWatchRunner.DefaultWatchPendingPathLimit; - - private readonly object _gate = new(); - private readonly HashSet _pending; - private long _lastEventTimestamp; - private bool _hasLastEventTimestamp; - private bool _overflowRequested; - private string? _overflowReason; - private readonly TimeSpan _debounce; - private readonly TimeProvider _timeProvider; - private readonly int _maxPendingPaths; - - public FileChangeBatcher( - TimeSpan debounce, - TimeProvider? timeProvider = null, - bool ignoreCase = true, - int maxPendingPaths = DefaultMaxPendingPaths) - { - if (maxPendingPaths <= 0) - throw new ArgumentOutOfRangeException(nameof(maxPendingPaths), "Maximum pending path count must be positive."); - - _debounce = debounce; - _timeProvider = timeProvider ?? TimeProvider.System; - _maxPendingPaths = maxPendingPaths; - // On case-sensitive filesystems (Linux ext4), `foo.py` and `Foo.py` are distinct files, - // so coalescing them via OrdinalIgnoreCase would drop one rename leg and leave the - // renamed-to file unindexed. The watch loop passes the filesystem's case sensitivity in. - // 大小区別する FS (Linux ext4 など) では foo.py と Foo.py が別ファイルになるため、 - // OrdinalIgnoreCase で集約するとリネーム片方が落ち、リネーム先が索引されなくなる。 - _pending = new HashSet(ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); - } - - public void Add(string path) - { - lock (_gate) - { - if (_overflowRequested) - { - RecordEventTimestampLocked(); - return; - } - - if (!_pending.Contains(path)) - { - if (_pending.Count >= _maxPendingPaths) - { - RequestFullRescanLocked( - $"pending path limit exceeded ({_maxPendingPaths.ToString("N0", CultureInfo.InvariantCulture)} paths)"); - return; - } - - _pending.Add(path); - } - - RecordEventTimestampLocked(); - } - } - - public void RequestFullRescan(string? reason = null) - { - lock (_gate) - { - RequestFullRescanLocked(reason); - } - } - - public bool TryDrain(out IReadOnlyList batch, out bool fullRescan, out string? overflowReason) - => TryDrainCore(requireDebounce: true, out batch, out fullRescan, out overflowReason); - - public bool TryDrainImmediately(out IReadOnlyList batch, out bool fullRescan, out string? overflowReason) - => TryDrainCore(requireDebounce: false, out batch, out fullRescan, out overflowReason); - - private bool TryDrainCore( - bool requireDebounce, - out IReadOnlyList batch, - out bool fullRescan, - out string? overflowReason) - { - lock (_gate) - { - if (_pending.Count == 0 && !_overflowRequested) - { - batch = Array.Empty(); - fullRescan = false; - overflowReason = null; - return false; - } - - if (requireDebounce - && _hasLastEventTimestamp - && _timeProvider.GetElapsedTime(_lastEventTimestamp) < _debounce) - { - batch = Array.Empty(); - fullRescan = false; - overflowReason = null; - return false; - } - - var snapshot = new List(_pending.Count); - foreach (var path in _pending) - snapshot.Add(path); - batch = snapshot; - fullRescan = _overflowRequested; - overflowReason = _overflowReason; - _pending.Clear(); - _overflowRequested = false; - _overflowReason = null; - return true; - } - } - - private void RequestFullRescanLocked(string? reason) - { - _pending.Clear(); - _overflowRequested = true; - if (!string.IsNullOrEmpty(reason)) - _overflowReason = IndexWatchRunner.FormatWatchDiagnosticText(reason); - RecordEventTimestampLocked(); - } - - private void RecordEventTimestampLocked() - { - _lastEventTimestamp = _timeProvider.GetTimestamp(); - _hasLastEventTimestamp = true; - } } From b3137250ee7220bec88d7e570c361f2915b029d3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:54:42 +0900 Subject: [PATCH 054/101] Split export and import responsibilities --- .../ExportImportCommandRunner.ArchiveScope.cs | 322 ++++ .../ExportImportCommandRunner.Contracts.cs | 311 ++++ .../Cli/ExportImportCommandRunner.Ctags.cs | 160 ++ ...ortImportCommandRunner.ImportValidation.cs | 475 +++++ .../Cli/ExportImportCommandRunner.Manifest.cs | 111 ++ .../ExportImportCommandRunner.Replacement.cs | 313 ++++ .../Cli/ExportImportCommandRunner.cs | 1592 +---------------- 7 files changed, 1693 insertions(+), 1591 deletions(-) create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.ArchiveScope.cs create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.Contracts.cs create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.Ctags.cs create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.ImportValidation.cs create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.Manifest.cs create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.Replacement.cs diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ArchiveScope.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ArchiveScope.cs new file mode 100644 index 000000000..9ff1e81e9 --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ArchiveScope.cs @@ -0,0 +1,322 @@ +using System.IO.Compression; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIndex.Archives; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ExportImportCommandRunner +{ + private static ArchiveExportScopeResult ApplyArchiveScope( + SqliteConnection connection, + ArchiveExportOptions options, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var sourceFileCount = ReadTableCount(connection, "files", cancellationToken); + var projectPathPatterns = Array.Empty(); + if (options.Projects.Count > 0) + { + var projectRoot = ReadMetaString(connection, DbContext.IndexedProjectRootMetaKey); + if (string.IsNullOrWhiteSpace(projectRoot)) + throw new InvalidOperationException("archive project filters require indexed_project_root metadata"); + projectPathPatterns = SolutionProjectResolver + .ResolveProjectDirectoryGlobs(projectRoot, options.Projects, options.Solution) + .ToArray(); + } + + var effectivePathPatterns = options.PathPatterns.Concat(projectPathPatterns).ToArray(); + var scoped = + !string.IsNullOrWhiteSpace(options.Lang) + || effectivePathPatterns.Length > 0 + || options.ExcludePathPatterns.Count > 0 + || options.ExcludeTests; + if (!scoped) + { + return new ArchiveExportScopeResult( + false, + options.Lang, + options.PathPatterns, + options.ExcludePathPatterns, + options.Projects, + options.Solution, + options.ExcludeTests, + projectPathPatterns, + sourceFileCount, + sourceFileCount); + } + + using (var foreignKeys = connection.CreateCommand()) + { + foreignKeys.CommandText = "PRAGMA foreign_keys = ON"; + foreignKeys.ExecuteNonQuery(); + } + + using (var transaction = connection.BeginTransaction()) + { + using var keepCommand = connection.CreateCommand(); + keepCommand.Transaction = transaction; + keepCommand.CommandText = """ + CREATE TEMP TABLE archive_scope_files(id INTEGER PRIMARY KEY); + INSERT INTO archive_scope_files(id) + SELECT f.id + FROM files f + WHERE 1 = 1 + """; + if (!string.IsNullOrWhiteSpace(options.Lang)) + keepCommand.CommandText += " AND f.lang = @lang"; + if (effectivePathPatterns.Length > 0) + { + var pathPredicates = new List(effectivePathPatterns.Length); + for (var i = 0; i < effectivePathPatterns.Length; i++) + pathPredicates.Add(DbReader.BuildPathFilterPredicate("f", "archivePath", i, effectivePathPatterns[i])); + keepCommand.CommandText += " AND (" + string.Join(" OR ", pathPredicates) + ")"; + } + for (var i = 0; i < options.ExcludePathPatterns.Count; i++) + keepCommand.CommandText += $" AND NOT {DbReader.BuildPathFilterPredicate("f", "archiveExcludePath", i, options.ExcludePathPatterns[i])}"; + if (options.ExcludeTests) + keepCommand.CommandText += $" AND NOT {DbReader.TestPathCondition}"; + if (!string.IsNullOrWhiteSpace(options.Lang)) + SqliteCommandPolicy.Add(keepCommand, "@lang", options.Lang); + DbReader.AddPathFilterParameterSet(keepCommand, "archivePath", effectivePathPatterns); + DbReader.AddPathFilterParameterSet(keepCommand, "archiveExcludePath", options.ExcludePathPatterns); + keepCommand.ExecuteNonQuery(); + + using var pruneCommand = connection.CreateCommand(); + pruneCommand.Transaction = transaction; + pruneCommand.CommandText = """ + DELETE FROM symbol_reference_candidates + WHERE reference_id IN ( + SELECT r.id + FROM symbol_references r + WHERE r.file_id NOT IN (SELECT id FROM archive_scope_files) + ) + OR symbol_id IN ( + SELECT s.id + FROM symbols s + WHERE s.file_id NOT IN (SELECT id FROM archive_scope_files) + ); + + UPDATE symbol_references + SET source_symbol_id = NULL + WHERE source_symbol_id IN ( + SELECT s.id + FROM symbols s + WHERE s.file_id NOT IN (SELECT id FROM archive_scope_files) + ); + + UPDATE symbol_references + SET target_symbol_id = NULL + WHERE target_symbol_id IN ( + SELECT s.id + FROM symbols s + WHERE s.file_id NOT IN (SELECT id FROM archive_scope_files) + ); + + DELETE FROM files + WHERE id NOT IN (SELECT id FROM archive_scope_files); + + DELETE FROM symbol_reference_candidates + WHERE reference_id NOT IN (SELECT id FROM symbol_references) + OR symbol_id NOT IN (SELECT id FROM symbols); + + DROP TABLE archive_scope_files; + """; + pruneCommand.ExecuteNonQuery(); + DbWriter.RebuildRetainedReferenceGraph(connection, transaction, cancellationToken); + transaction.Commit(); + } + + cancellationToken.ThrowIfCancellationRequested(); + using (var foreignKeyCheck = connection.CreateCommand()) + { + foreignKeyCheck.CommandText = "PRAGMA foreign_key_check"; + using var reader = foreignKeyCheck.ExecuteReader(); + if (reader.Read()) + throw new InvalidDataException("scoped archive snapshot failed SQLite foreign-key validation"); + } + + using (var vacuum = connection.CreateCommand()) + { + vacuum.CommandText = "VACUUM"; + vacuum.ExecuteNonQuery(); + } + + var exportedFileCount = ReadTableCount(connection, "files", cancellationToken); + return new ArchiveExportScopeResult( + true, + options.Lang, + options.PathPatterns, + options.ExcludePathPatterns, + options.Projects, + options.Solution, + options.ExcludeTests, + projectPathPatterns, + sourceFileCount, + exportedFileCount); + } + + private static bool TryValidateArchiveScopeValues( + IReadOnlyList pathPatterns, + IReadOnlyList excludePathPatterns, + IReadOnlyList projects, + string? solution, + out string message) + { + var values = pathPatterns.Concat(excludePathPatterns).Concat(projects).ToList(); + if (solution != null) + values.Add(solution); + if (values.Count > MaxArchiveScopeValues) + { + message = $"archive export accepts at most {MaxArchiveScopeValues} scope values"; + return false; + } + + var totalChars = 0; + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value)) + { + message = "archive scope values must not be empty"; + return false; + } + if (value.Length > MaxArchiveScopeValueChars) + { + message = $"archive scope values must not exceed {MaxArchiveScopeValueChars} characters"; + return false; + } + totalChars += value.Length; + if (totalChars > MaxArchiveScopeTotalChars) + { + message = $"archive scope values exceed the combined limit of {MaxArchiveScopeTotalChars} characters"; + return false; + } + } + + message = string.Empty; + return true; + } + + private static ImportDestinationDeltaResult BuildImportDestinationDelta( + string destinationDbPath, + string importedDbPath, + string archivePath, + int limit, + int offset, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!File.Exists(destinationDbPath)) + { + return new ImportDestinationDeltaResult( + DestinationExists: false, + Comparable: false, + Status: "destination_missing", + Comparison: null, + Message: "destination database does not exist; the archive would create it"); + } + + var snapshotDirectory = Path.GetDirectoryName(importedDbPath) + ?? throw new InvalidOperationException("import comparison directory could not be resolved"); + var destinationSnapshotPath = Path.Combine(snapshotDirectory, "destination-codeindex.db"); + try + { + try + { + using (var source = BoundedFile.OpenReadForIndexContent(destinationDbPath)) + { + if (source.Length > MaxImportDatabaseBytes) + { + return new ImportDestinationDeltaResult( + DestinationExists: true, + Comparable: false, + Status: "destination_too_large", + Comparison: null, + Message: $"destination database exceeds the comparison limit of {ConsoleUi.FormatBytes(MaxImportDatabaseBytes)}"); + } + + Span header = stackalloc byte[16]; + if (source.Read(header) != header.Length || !header.SequenceEqual("SQLite format 3\0"u8)) + { + return new ImportDestinationDeltaResult( + DestinationExists: true, + Comparable: false, + Status: "destination_unreadable", + Comparison: null, + Message: "destination database could not be compared from a non-mutating snapshot: file header is not SQLite format 3"); + } + } + + CreateDatabaseSnapshot(destinationDbPath, destinationSnapshotPath, cancellationToken); + } + catch (Exception ex) when (ex is SqliteException or CodeIndexException or IOException or UnauthorizedAccessException or InvalidOperationException) + { + return new ImportDestinationDeltaResult( + DestinationExists: true, + Comparable: false, + Status: "destination_unreadable", + Comparison: null, + Message: $"destination database could not be compared from a non-mutating snapshot: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); + } + + if (!DbContext.TryValidateExistingCodeIndexDb( + destinationSnapshotPath, + requireWritable: false, + requireSupportedUserVersion: false, + out var validationMessage, + out _, + out _, + cancellationToken)) + { + return new ImportDestinationDeltaResult( + DestinationExists: true, + Comparable: false, + Status: "destination_unreadable", + Comparison: null, + Message: $"destination database could not be compared from a non-mutating snapshot: {validationMessage}"); + } + + var comparison = DiffCommandRunner.CompareDatabases( + destinationSnapshotPath, + importedDbPath, + limit, + offset, + detailed: true, + cancellationToken, + destinationDbPath, + archivePath); + var comparable = comparison.Status != "schema_mismatch"; + return new ImportDestinationDeltaResult( + DestinationExists: true, + Comparable: comparable, + Status: comparable ? "compared" : "schema_mismatch", + Comparison: comparison, + Message: comparable + ? "destination database was compared from a non-mutating snapshot with the validated archive snapshot" + : "destination and archive schema versions differ"); + } + finally + { + SqliteConnection.ClearAllPools(); + TryDeleteFile(destinationSnapshotPath, "import destination comparison snapshot"); + DeleteSqliteSidecars(destinationSnapshotPath, "import destination comparison snapshot sidecar"); + } + } + + private static string FormatDestinationDeltaSummary(ImportDestinationDeltaResult destinationDelta) + { + if (!destinationDelta.Comparable || destinationDelta.Comparison is not { } comparison) + return $"; {destinationDelta.Message}"; + return string.Create( + CultureInfo.InvariantCulture, + $"; destination delta: files {comparison.Summary.FileCountDelta:+#;-#;0}, symbols {comparison.Summary.SymbolCountDelta:+#;-#;0}, references {comparison.Summary.ReferenceCountDelta:+#;-#;0}"); + } + +} diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.Contracts.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.Contracts.cs new file mode 100644 index 000000000..1a4c722cc --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.Contracts.cs @@ -0,0 +1,311 @@ +using System.IO.Compression; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIndex.Archives; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ExportImportCommandRunner +{ + private static bool TryReadValueOption(string[] args, ref int index, string optionName, string arg, out string? value, out string? error) + { + value = null; + error = null; + if (arg == optionName) + { + if (index + 1 >= args.Length || string.IsNullOrWhiteSpace(args[index + 1])) + { + error = $"{optionName} requires a non-empty value."; + return true; + } + value = args[++index]; + return true; + } + + var prefix = optionName + "="; + if (arg.StartsWith(prefix, StringComparison.Ordinal)) + { + value = arg[prefix.Length..]; + if (string.IsNullOrWhiteSpace(value)) + error = $"{optionName} requires a non-empty value."; + return true; + } + + return false; + } + + private static int WriteImportError( + bool json, + JsonSerializerOptions jsonOptions, + string phase, + string errorCode, + string message, + string hint, + string usage, + int exitCode = CommandExitCodes.UsageError, + IReadOnlyList? diagnostics = null, + string? rootCause = null) + => WriteStructuredError(json, jsonOptions, ImportCommandName, phase, errorCode, message, hint, usage, exitCode, diagnostics, rootCause); + + private static int WriteExportError( + bool json, + JsonSerializerOptions jsonOptions, + string phase, + string errorCode, + string message, + string hint, + string usage, + int exitCode = CommandExitCodes.UsageError, + IReadOnlyList? diagnostics = null) + => WriteStructuredError(json, jsonOptions, ExportCommandName, phase, errorCode, message, hint, usage, exitCode, diagnostics); + + private static int WriteStructuredError( + bool json, + JsonSerializerOptions jsonOptions, + string command, + string phase, + string errorCode, + string message, + string hint, + string usage, + int exitCode, + IReadOnlyList? diagnostics, + string? rootCause = null) + { + if (json) + { + Console.WriteLine(JsonSerializer.Serialize( + new ExportImportErrorResult("1", "error", command, phase, errorCode, message, hint, usage, rootCause, diagnostics), + CliJsonSerializerContextFactory.Create(jsonOptions).ExportImportErrorResult)); + return exitCode; + } + + return CommandErrorWriter.Write(message, exitCode, hint, usage); + } + + private static void AddImportValidationPhase( + List validationPhases, + string phase, + string status = "success", + string? message = null) + => validationPhases.Add(new ImportValidationPhaseResult(phase, status, message)); + + private static string ClassifyImportFailureRootCause(string phase, Exception exception) + => exception switch + { + InvalidDataException when phase == PhaseOpenArchive => "invalid_archive", + UnauthorizedAccessException => "permission_denied", + SqliteException => "sqlite_error", + IOException => "io_error", + InvalidDataException => "invalid_data", + _ => "unknown", + }; + + internal sealed record ExportManifest( + [property: JsonPropertyName("format_version")] + string FormatVersion, + [property: JsonPropertyName("cdidx_version")] + string CdidxVersion, + [property: JsonPropertyName("user_version")] + int UserVersion, + [property: JsonPropertyName("project_root")] + string? ProjectRoot, + [property: JsonPropertyName("indexed_head_sha")] + string? IndexedHeadSha, + [property: JsonPropertyName("database_sha256")] + string DatabaseSha256, + [property: JsonPropertyName("file_count")] + long? FileCount = null, + [property: JsonPropertyName("chunk_count")] + long? ChunkCount = null, + [property: JsonPropertyName("symbol_count")] + long? SymbolCount = null, + [property: JsonPropertyName("reference_count")] + long? ReferenceCount = null, + [property: JsonPropertyName("graph_ready")] + bool? GraphReady = null, + [property: JsonPropertyName("issues_ready")] + bool? IssuesReady = null, + [property: JsonPropertyName("fold_ready")] + bool? FoldReady = null, + [property: JsonPropertyName("index_writer_version")] + string? IndexWriterVersion = null, + [property: JsonPropertyName("indexed_head_branch")] + string? IndexedHeadBranch = null, + [property: JsonPropertyName("indexed_head_timestamp")] + string? IndexedHeadTimestamp = null, + [property: JsonPropertyName("codeindex_meta_schema_version")] + int? CodeIndexMetaSchemaVersion = null, + [property: JsonPropertyName("csharp_symbol_name_contract_version")] + int? CSharpSymbolNameContractVersion = null, + [property: JsonPropertyName("sql_graph_contract_version")] + int? SqlGraphContractVersion = null, + [property: JsonPropertyName("hotspot_family_version")] + int? HotspotFamilyVersion = null, + [property: JsonPropertyName("unknown_extension_file_count")] + long? UnknownExtensionFileCount = null, + [property: JsonPropertyName("unknown_extension_files")] + string[]? UnknownExtensionFiles = null, + [property: JsonPropertyName("unknown_extension_files_truncated")] + bool? UnknownExtensionFilesTruncated = null, + [property: JsonPropertyName("unknown_extension_file_path_limit")] + int? UnknownExtensionFilePathLimit = null, + [property: JsonPropertyName("unknown_extension_file_sample_count")] + int? UnknownExtensionFileSampleCount = null, + [property: JsonPropertyName("unknown_extension_file_sample_limit")] + int? UnknownExtensionFileSampleLimit = null, + [property: JsonPropertyName("unknown_extension_file_sample_truncated")] + bool? UnknownExtensionFileSampleTruncated = null, + [property: JsonPropertyName("scope")] + ArchiveExportScopeResult? Scope = null); + internal sealed record ExportImportErrorResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("command")] string Command, + [property: JsonPropertyName("phase")] string Phase, + [property: JsonPropertyName("error_code")] string ErrorCode, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("hint")] string Hint, + [property: JsonPropertyName("usage")] string Usage, + [property: JsonPropertyName("root_cause")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? RootCause = null, + [property: JsonPropertyName("diagnostics")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + IReadOnlyList? Diagnostics = null); + internal sealed record ExportImportDiagnosticResult( + [property: JsonPropertyName("code")] string Code, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("path")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? Path = null); + internal sealed record ImportValidationPhaseResult( + [property: JsonPropertyName("phase")] string Phase, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("message")] string? Message); + internal sealed record ImportDryRunResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("archive_path")] string ArchivePath, + [property: JsonPropertyName("db_path")] string DbPath, + [property: JsonPropertyName("mode")] string Mode, + [property: JsonPropertyName("dry_run")] bool DryRun, + [property: JsonPropertyName("pruned_paths")] bool PrunedPaths, + [property: JsonPropertyName("pruned_project_root")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? PrunedProjectRoot, + [property: JsonPropertyName("replacement_would_be_allowed")] bool ReplacementWouldBeAllowed, + [property: JsonPropertyName("validation_phases")] IReadOnlyList ValidationPhases, + [property: JsonPropertyName("destination_delta")] + ImportDestinationDeltaResult? DestinationDelta = null, + [property: JsonPropertyName("unknown_extension_file_count")] long? UnknownExtensionFileCount = null, + [property: JsonPropertyName("unknown_extension_files")] string[]? UnknownExtensionFiles = null, + [property: JsonPropertyName("unknown_extension_files_truncated")] bool? UnknownExtensionFilesTruncated = null, + [property: JsonPropertyName("unknown_extension_file_path_limit")] int? UnknownExtensionFilePathLimit = null, + [property: JsonPropertyName("unknown_extension_file_sample_count")] int? UnknownExtensionFileSampleCount = null, + [property: JsonPropertyName("unknown_extension_file_sample_limit")] int? UnknownExtensionFileSampleLimit = null, + [property: JsonPropertyName("unknown_extension_file_sample_truncated")] bool? UnknownExtensionFileSampleTruncated = null); + internal sealed record ImportDestinationDeltaResult( + [property: JsonPropertyName("destination_exists")] bool DestinationExists, + [property: JsonPropertyName("comparable")] bool Comparable, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("comparison")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + DiffJsonResult? Comparison, + [property: JsonPropertyName("message")] string Message); + internal sealed record ExportArchiveResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("archive_path")] string ArchivePath, + [property: JsonPropertyName("db_path")] string DbPath, + [property: JsonPropertyName("scope")] ArchiveExportScopeResult Scope); + private sealed record ArchiveExportOptions( + string? Lang, + IReadOnlyList PathPatterns, + IReadOnlyList ExcludePathPatterns, + IReadOnlyList Projects, + string? Solution, + bool ExcludeTests) + { + internal bool IsScoped => + !string.IsNullOrWhiteSpace(Lang) || + PathPatterns.Count > 0 || + ExcludePathPatterns.Count > 0 || + Projects.Count > 0 || + !string.IsNullOrWhiteSpace(Solution) || + ExcludeTests; + } + internal sealed record ArchiveExportScopeResult( + [property: JsonPropertyName("scoped")] bool Scoped, + [property: JsonPropertyName("lang")] string? Lang, + [property: JsonPropertyName("path")] IReadOnlyList PathPatterns, + [property: JsonPropertyName("exclude_path")] IReadOnlyList ExcludePathPatterns, + [property: JsonPropertyName("project")] IReadOnlyList Projects, + [property: JsonPropertyName("solution")] string? Solution, + [property: JsonPropertyName("exclude_tests")] bool ExcludeTests, + [property: JsonPropertyName("resolved_project_path")] IReadOnlyList ResolvedProjectPathPatterns, + [property: JsonPropertyName("source_file_count")] long SourceFileCount, + [property: JsonPropertyName("exported_file_count")] long ExportedFileCount); + private sealed record CtagsExportOptions( + string? Lang, + IReadOnlyList PathPatterns, + IReadOnlyList ExcludePathPatterns, + bool ExcludeTests, + bool IncludeGenerated, + bool GeneratedFileFilterAvailable); + internal sealed record CtagsExportFilterResult( + [property: JsonPropertyName("lang")] string? Lang, + [property: JsonPropertyName("path")] IReadOnlyList PathPatterns, + [property: JsonPropertyName("exclude_path")] IReadOnlyList ExcludePathPatterns, + [property: JsonPropertyName("exclude_tests")] bool ExcludeTests, + [property: JsonPropertyName("include_generated")] bool IncludeGenerated, + [property: JsonPropertyName("generated_code_policy")] string GeneratedCodePolicy, + [property: JsonPropertyName("generated_file_filter_available")] bool GeneratedFileFilterAvailable); + internal sealed record CtagsExportResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("output_path")] string OutputPath, + [property: JsonPropertyName("db_path")] string DbPath, + [property: JsonPropertyName("tag_count")] long TagCount, + [property: JsonPropertyName("emitted_count")] long EmittedCount, + [property: JsonPropertyName("skipped_count")] long SkippedCount, + [property: JsonPropertyName("skip_reason_counts")] IReadOnlyDictionary SkipReasonCounts, + [property: JsonPropertyName("filters")] CtagsExportFilterResult Filters, + [property: JsonPropertyName("metadata_fields")] IReadOnlyList MetadataFields); + internal sealed record ImportResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("archive_path")] string ArchivePath, + [property: JsonPropertyName("db_path")] string DbPath, + [property: JsonPropertyName("mode")] string Mode, + [property: JsonPropertyName("dry_run")] bool DryRun, + [property: JsonPropertyName("pruned_paths")] bool PrunedPaths, + [property: JsonPropertyName("pruned_project_root")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? PrunedProjectRoot, + [property: JsonPropertyName("validation_phases")] IReadOnlyList ValidationPhases, + [property: JsonPropertyName("unknown_extension_file_count")] long? UnknownExtensionFileCount = null, + [property: JsonPropertyName("unknown_extension_files")] string[]? UnknownExtensionFiles = null, + [property: JsonPropertyName("unknown_extension_files_truncated")] bool? UnknownExtensionFilesTruncated = null, + [property: JsonPropertyName("unknown_extension_file_path_limit")] int? UnknownExtensionFilePathLimit = null, + [property: JsonPropertyName("unknown_extension_file_sample_count")] int? UnknownExtensionFileSampleCount = null, + [property: JsonPropertyName("unknown_extension_file_sample_limit")] int? UnknownExtensionFileSampleLimit = null, + [property: JsonPropertyName("unknown_extension_file_sample_truncated")] bool? UnknownExtensionFileSampleTruncated = null); + + private sealed class ImportReplacementException : IOException + { + internal ImportReplacementException(string message, Exception innerException, IReadOnlyList diagnostics) + : base(message, innerException) + { + Diagnostics = diagnostics; + } + + internal IReadOnlyList Diagnostics { get; } + } +} diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.Ctags.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.Ctags.cs new file mode 100644 index 000000000..ecf07c3e2 --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.Ctags.cs @@ -0,0 +1,160 @@ +using System.IO.Compression; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIndex.Archives; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ExportImportCommandRunner +{ + private static SqliteCommand CreateCtagsSymbolCommand(SqliteConnection connection, CtagsExportOptions filters) + { + var cmd = connection.CreateCommand(); + var sql = $""" + SELECT + s.name, + f.path, + COALESCE(s.start_line, s.line, 1), + s.kind, + f.lang, + s.container_kind, + s.container_name, + s.visibility + FROM symbols s + JOIN files f ON s.file_id = f.id + WHERE s.name IS NOT NULL + AND trim(s.name) != '' + AND s.kind IS NOT NULL + AND trim(s.kind) != '' + AND s.kind IN ({SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds)}) + """; + AppendCtagsFilters(ref sql, filters); + sql += " ORDER BY s.name COLLATE NOCASE, f.path, COALESCE(s.start_line, s.line, 1)"; + cmd.CommandText = sql; + AddCtagsFilterParameters(cmd, filters); + return cmd; + } + + private static SqliteCommand CreateCtagsSkipReasonCommand(SqliteConnection connection, CtagsExportOptions filters) + { + var cmd = connection.CreateCommand(); + var skipReasonCases = new List + { + $"WHEN s.name IS NULL OR trim(s.name) = '' THEN '{CtagsSkipInvalidName}'", + $"WHEN s.kind IS NULL OR trim(s.kind) = '' OR s.kind NOT IN ({SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds)}) THEN '{CtagsSkipUnsupportedKind}'", + }; + if (filters.GeneratedFileFilterAvailable && !filters.IncludeGenerated) + skipReasonCases.Add($"WHEN COALESCE(f.generated, 0) != 0 THEN '{CtagsSkipGeneratedCode}'"); + if (!string.IsNullOrWhiteSpace(filters.Lang)) + skipReasonCases.Add($"WHEN COALESCE(f.lang, '') != @lang THEN '{CtagsSkipLanguageFilter}'"); + if (filters.ExcludeTests) + skipReasonCases.Add($"WHEN {DbReader.TestPathCondition} THEN '{CtagsSkipTestFilter}'"); + if (filters.PathPatterns.Count > 0) + { + var pathPredicates = new List(filters.PathPatterns.Count); + for (var i = 0; i < filters.PathPatterns.Count; i++) + pathPredicates.Add(DbReader.BuildPathFilterPredicate("f", "pathPattern", i, filters.PathPatterns[i])); + skipReasonCases.Add($"WHEN NOT ({string.Join(" OR ", pathPredicates)}) THEN '{CtagsSkipPathFilter}'"); + } + if (filters.ExcludePathPatterns.Count > 0) + { + var excludePathPredicates = new List(filters.ExcludePathPatterns.Count); + for (var i = 0; i < filters.ExcludePathPatterns.Count; i++) + excludePathPredicates.Add(DbReader.BuildPathFilterPredicate("f", "excludePathPattern", i, filters.ExcludePathPatterns[i])); + skipReasonCases.Add($"WHEN ({string.Join(" OR ", excludePathPredicates)}) THEN '{CtagsSkipExcludePathFilter}'"); + } + + cmd.CommandText = $""" + SELECT skip_reason, COUNT(*) + FROM ( + SELECT + CASE + {string.Join(Environment.NewLine + " ", skipReasonCases)} + ELSE NULL + END AS skip_reason + FROM symbols s + JOIN files f ON s.file_id = f.id + ) + WHERE skip_reason IS NOT NULL + GROUP BY skip_reason + """; + AddCtagsFilterParameters(cmd, filters); + return cmd; + } + + private static Dictionary CountCtagsSkipReasons(SqliteConnection connection, CtagsExportOptions filters) + { + var counts = new Dictionary(StringComparer.Ordinal) + { + [CtagsSkipInvalidName] = 0, + [CtagsSkipUnsupportedKind] = 0, + [CtagsSkipGeneratedCode] = 0, + [CtagsSkipLanguageFilter] = 0, + [CtagsSkipTestFilter] = 0, + [CtagsSkipPathFilter] = 0, + [CtagsSkipExcludePathFilter] = 0, + [CtagsSkipOther] = 0, + }; + using var cmd = CreateCtagsSkipReasonCommand(connection, filters); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var reason = reader.GetString(0); + var boundedReason = counts.ContainsKey(reason) ? reason : CtagsSkipOther; + counts[boundedReason] += reader.GetInt64(1); + } + return counts; + } + + private static void AppendCtagsFilters(ref string sql, CtagsExportOptions filters) + { + if (filters.GeneratedFileFilterAvailable && !filters.IncludeGenerated) + sql += " AND COALESCE(f.generated, 0) = 0"; + + if (!string.IsNullOrWhiteSpace(filters.Lang)) + sql += " AND f.lang = @lang"; + + if (filters.ExcludeTests) + sql += $" AND NOT {DbReader.TestPathCondition}"; + + if (filters.PathPatterns.Count > 0) + { + var pathPredicates = new List(filters.PathPatterns.Count); + for (var i = 0; i < filters.PathPatterns.Count; i++) + pathPredicates.Add(DbReader.BuildPathFilterPredicate("f", "pathPattern", i, filters.PathPatterns[i])); + sql += " AND (" + string.Join(" OR ", pathPredicates) + ")"; + } + + for (var i = 0; i < filters.ExcludePathPatterns.Count; i++) + sql += $" AND NOT {DbReader.BuildPathFilterPredicate("f", "excludePathPattern", i, filters.ExcludePathPatterns[i])}"; + } + + private static void AddCtagsFilterParameters(SqliteCommand cmd, CtagsExportOptions filters) + { + if (!string.IsNullOrWhiteSpace(filters.Lang)) + SqliteCommandPolicy.Add(cmd, "@lang", filters.Lang); + + DbReader.AddPathFilterParameterSet(cmd, "pathPattern", filters.PathPatterns); + DbReader.AddPathFilterParameterSet(cmd, "excludePathPattern", filters.ExcludePathPatterns); + } + + private static void AppendCtagsExtensionField(StringBuilder builder, string name, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return; + + builder + .Append('\t') + .Append(name) + .Append(':') + .Append(SanitizeCtagsField(value)); + } + +} diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ImportValidation.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportValidation.cs new file mode 100644 index 000000000..37a811389 --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportValidation.cs @@ -0,0 +1,475 @@ +using System.IO.Compression; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIndex.Archives; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ExportImportCommandRunner +{ + private static bool TryValidateImportArchiveEntries( + ZipArchive archive, + out ZipArchiveEntry manifestEntry, + out ZipArchiveEntry? databaseEntry, + out string phase, + out string errorCode, + out string message) + { + manifestEntry = null!; + databaseEntry = null!; + phase = PhaseOpenArchive; + errorCode = string.Empty; + message = string.Empty; + + var entries = new Dictionary(StringComparer.Ordinal); + foreach (var entry in archive.Entries) + { + if (!ZipArchiveSafetyPolicy.TryNormalizeRelativeEntryName(entry.FullName, out var normalizedEntryName, out var entryNameFailureReason)) + { + errorCode = "import_archive_unsafe_entry_name"; + message = $"archive contains unsafe entry {ConsoleUi.FormatBoundedValue(entry.FullName)}: ZIP entry name {entryNameFailureReason}; expected only {FormatExpectedImportArchiveEntryNames()}."; + return false; + } + + if (!string.Equals(normalizedEntryName, entry.FullName, StringComparison.Ordinal)) + { + errorCode = "import_archive_noncanonical_entry_name"; + message = $"archive contains non-canonical entry {ConsoleUi.FormatBoundedValue(entry.FullName)} that normalizes to {ConsoleUi.FormatBoundedValue(normalizedEntryName)}; expected only {FormatExpectedImportArchiveEntryNames()}."; + return false; + } + + if (!IsExpectedImportArchiveEntryName(entry.FullName)) + { + errorCode = "import_archive_unexpected_entry"; + message = $"archive contains unexpected entry {ConsoleUi.FormatBoundedValue(entry.FullName)}; expected only {FormatExpectedImportArchiveEntryNames()}."; + return false; + } + + if (!ZipArchiveSafetyPolicy.TryAddUniqueEntryName(entries, entry.FullName, entry)) + { + phase = GetImportArchiveEntryPhase(entry.FullName); + errorCode = "import_archive_duplicate_entry"; + message = $"archive contains duplicate entry {ConsoleUi.FormatBoundedValue(entry.FullName)}."; + return false; + } + } + + if (!entries.TryGetValue(ManifestEntryName, out var foundManifestEntry)) + { + phase = PhaseManifest; + errorCode = "import_manifest_missing"; + message = $"archive is missing {ManifestEntryName}."; + return false; + } + + manifestEntry = foundManifestEntry; + entries.TryGetValue(DatabaseEntryName, out databaseEntry); + return true; + } + + private static bool IsExpectedImportArchiveEntryName(string name) + => Array.Exists(ExpectedImportArchiveEntryNames, expected => string.Equals(expected, name, StringComparison.Ordinal)); + + private static string GetImportArchiveEntryPhase(string name) + => string.Equals(name, ManifestEntryName, StringComparison.Ordinal) + ? PhaseManifest + : string.Equals(name, DatabaseEntryName, StringComparison.Ordinal) + ? PhaseDatabaseEntry + : PhaseOpenArchive; + + private static string FormatExpectedImportArchiveEntryNames() + => string.Join(", ", ExpectedImportArchiveEntryNames.Select(name => $"`{name}`")); + + internal static string FormatImportManifestReadException(Exception ex) + => CommandErrorWriter.FormatSanitizedException(ex); + + private static bool TryReadManifest(ZipArchiveEntry manifestEntry, JsonSerializerOptions jsonOptions, out ExportManifest manifest, out string message, CancellationToken cancellationToken) + { + if (!ExportImportManifestCodec.TryValidateEntrySize(manifestEntry, out message)) + { + manifest = null!; + return false; + } + + try + { + cancellationToken.ThrowIfCancellationRequested(); + using var stream = manifestEntry.Open(); + using var manifestBytes = new MemoryStream((int)Math.Min(Math.Max(manifestEntry.Length, 0), MaxImportManifestBytes)); + CopyToWithLimit(stream, manifestBytes, MaxImportManifestBytes, ManifestEntryName, cancellationToken); + manifestBytes.Position = 0; + cancellationToken.ThrowIfCancellationRequested(); + return ExportImportManifestCodec.TryDeserialize( + manifestBytes.GetBuffer().AsSpan(0, (int)manifestBytes.Length), + jsonOptions, + out manifest, + out message); + } + catch (InvalidDataException ex) + { + manifest = null!; + message = FormatImportManifestReadException(ex); + return false; + } + } + + private static bool TryValidateImportedManifest( + ExportManifest manifest, + string dbPath, + out string message, + out string phase, + CancellationToken cancellationToken = default) + { + phase = PhaseSha256; + var actualSha256 = ComputeSha256(dbPath, cancellationToken); + if (!string.Equals(manifest.DatabaseSha256, actualSha256, StringComparison.OrdinalIgnoreCase)) + { + message = "database_sha256 does not match codeindex.db"; + return false; + } + + phase = PhaseSqliteValidate; + int actualUserVersion; + try + { + cancellationToken.ThrowIfCancellationRequested(); + using var connection = new SqliteConnection(CreateUnpooledConnectionString(dbPath)); + connection.Open(); + actualUserVersion = ReadSqliteUserVersion(connection); + if (!TryValidateManifestCount(manifest.FileCount, connection, "files", "file_count", out message, cancellationToken) + || !TryValidateManifestCount(manifest.ChunkCount, connection, "chunks", "chunk_count", out message, cancellationToken) + || !TryValidateManifestCount(manifest.SymbolCount, connection, "symbols", "symbol_count", out message, cancellationToken) + || !TryValidateManifestCount(manifest.ReferenceCount, connection, "symbol_references", "reference_count", out message, cancellationToken)) + { + return false; + } + } + catch (SqliteException ex) + { + message = $"could not validate codeindex.db manifest metadata ({CommandErrorWriter.FormatSanitizedException(ex)})"; + return false; + } + + if (actualUserVersion != manifest.UserVersion) + { + message = $"manifest user_version `{manifest.UserVersion}` does not match codeindex.db user_version `{actualUserVersion}`"; + return false; + } + + phase = string.Empty; + message = string.Empty; + return true; + } + + private static int ReadSqliteUserVersion(SqliteConnection connection) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA user_version"; + return Convert.ToInt32(cmd.ExecuteScalar(), CultureInfo.InvariantCulture); + } + + private static bool TryValidateManifestCount(long? expected, SqliteConnection connection, string tableName, string fieldName, out string message, CancellationToken cancellationToken) + { + if (expected == null) + { + message = string.Empty; + return true; + } + + var actual = ReadTableCount(connection, tableName, cancellationToken); + if (actual != expected.Value) + { + message = $"manifest {fieldName} `{expected.Value}` does not match codeindex.db {tableName} count `{actual}`"; + return false; + } + + message = string.Empty; + return true; + } + + private static long ReadTableCount(SqliteConnection connection, string tableName, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = tableName switch + { + "files" => "SELECT COUNT(*) FROM files", + "chunks" => "SELECT COUNT(*) FROM chunks", + "symbols" => "SELECT COUNT(*) FROM symbols", + "symbol_references" => "SELECT COUNT(*) FROM symbol_references", + _ => throw new ArgumentOutOfRangeException(nameof(tableName), tableName, "Unsupported manifest count table."), + }; + var count = Convert.ToInt64(cmd.ExecuteScalar(), CultureInfo.InvariantCulture); + cancellationToken.ThrowIfCancellationRequested(); + return count; + } + + private static string? ReadMetaString(SqliteConnection connection, string key) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key LIMIT 1"; + SqliteCommandPolicy.Add(cmd, "@key", key); + return cmd.ExecuteScalar() as string; + } + + private static int? ReadMetaInt(SqliteConnection connection, string key) + { + var value = ReadMetaString(connection, key); + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed >= 0 + ? parsed + : null; + } + + private static long? ReadMetaLong(SqliteConnection connection, string key) + { + var value = ReadMetaString(connection, key); + return long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed >= 0 + ? parsed + : null; + } + + private static bool? ReadMetaBool(SqliteConnection connection, string key) + { + var value = ReadMetaString(connection, key); + return bool.TryParse(value, out var parsed) ? parsed : null; + } + + private readonly record struct UnknownExtensionFileSample(string[]? Files, int? Count, int? Limit, bool? Truncated); + + private static UnknownExtensionFileSample ReadUnknownExtensionFileSample(SqliteConnection connection) + { + var json = ReadMetaString(connection, DbContext.UnknownExtensionFilePathsMetaKey); + if (string.IsNullOrWhiteSpace(json) || Encoding.UTF8.GetByteCount(json) > MaxImportManifestBytes) + return new(null, null, null, null); + + try + { + var jsonBytes = Encoding.UTF8.GetBytes(json); + var reader = new Utf8JsonReader( + jsonBytes, + new JsonReaderOptions { MaxDepth = ManifestUnknownExtensionJsonDepth }); + if (!reader.Read()) + return new(null, null, null, null); + if (reader.TokenType == JsonTokenType.Null) + { + if (reader.Read()) + return new(null, null, null, null); + + return new(null, 0, ManifestUnknownExtensionFileLimit, false); + } + if (reader.TokenType != JsonTokenType.StartArray) + return new(null, null, null, null); + + var sample = new List(ManifestUnknownExtensionFileLimit); + var decodedItems = 0; + var truncated = false; + var completed = false; + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + completed = true; + break; + } + if (reader.TokenType != JsonTokenType.String) + return new(null, null, null, null); + + decodedItems++; + if (decodedItems > ManifestUnknownExtensionDecodedItemLimit) + { + truncated = true; + break; + } + + var path = reader.GetString(); + if (string.IsNullOrWhiteSpace(path)) + continue; + + if (sample.Count >= ManifestUnknownExtensionFileLimit) + { + truncated = true; + break; + } + + sample.Add(path.Length <= ManifestUnknownExtensionPathCharLimit + ? path + : path[..ManifestUnknownExtensionPathCharLimit]); + } + + if (!completed && !truncated) + return new(null, null, null, null); + if (completed && reader.Read()) + return new(null, null, null, null); + if (sample.Count == 0) + return new(null, 0, ManifestUnknownExtensionFileLimit, false); + + return new(sample.ToArray(), sample.Count, ManifestUnknownExtensionFileLimit, truncated); + } + catch (JsonException) + { + return new(null, null, null, null); + } + } + + internal static bool TryValidateDatabaseEntrySize(long uncompressedLength, long compressedLength, out string message) + { + if (uncompressedLength < 0 || compressedLength < 0) + { + message = "archive codeindex.db size metadata is invalid"; + return false; + } + + if (uncompressedLength > MaxImportDatabaseBytes) + { + message = $"archive codeindex.db is too large: {ConsoleUi.FormatBytes(uncompressedLength)} uncompressed exceeds the import limit of {ConsoleUi.FormatBytes(MaxImportDatabaseBytes)}"; + return false; + } + + if (compressedLength > MaxImportDatabaseBytes) + { + message = $"archive codeindex.db is too large: {ConsoleUi.FormatBytes(compressedLength)} compressed exceeds the import limit of {ConsoleUi.FormatBytes(MaxImportDatabaseBytes)}"; + return false; + } + + if (uncompressedLength > 0 && compressedLength == 0) + { + message = "archive codeindex.db compression metadata is invalid: non-empty entry has zero compressed bytes"; + return false; + } + + if (compressedLength > 0 && uncompressedLength > compressedLength * MaxImportDatabaseCompressionRatio) + { + message = $"archive codeindex.db compression ratio exceeds the import limit of {MaxImportDatabaseCompressionRatio}:1"; + return false; + } + + message = string.Empty; + return true; + } + + private static void ExtractDatabaseEntryToFile(ZipArchiveEntry dbEntry, string destinationPath, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var source = dbEntry.Open(); + using var target = File.Open(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None); + CopyToWithLimit(source, target, MaxImportDatabaseBytes, cancellationToken); + } + + internal static long CopyToWithLimit( + Stream source, + Stream target, + long maxBytes, + CancellationToken cancellationToken = default) + => CopyToWithLimit(source, target, maxBytes, DatabaseEntryName, cancellationToken); + + internal static long CopyToExactLength( + Stream source, + Stream target, + long expectedBytes, + string entryName, + CancellationToken cancellationToken = default) + { + if (expectedBytes < 0) + throw new ArgumentOutOfRangeException(nameof(expectedBytes), expectedBytes, "Expected byte length must be non-negative."); + + var buffer = new byte[ImportCopyBufferSize]; + long totalBytes = 0; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var bytesRead = source.Read(buffer, 0, buffer.Length); + if (bytesRead == 0) + break; + + if (totalBytes > expectedBytes - bytesRead) + throw new InvalidDataException($"archive {entryName} source grew beyond the expected snapshot length of {ConsoleUi.FormatBytes(expectedBytes)}."); + + target.Write(buffer, 0, bytesRead); + totalBytes += bytesRead; + } + + if (totalBytes != expectedBytes) + throw new EndOfStreamException($"archive {entryName} source ended after {ConsoleUi.FormatBytes(totalBytes)}; expected {ConsoleUi.FormatBytes(expectedBytes)}."); + + return totalBytes; + } + + internal static long CopyToWithLimit( + Stream source, + Stream target, + long maxBytes, + CancellationToken cancellationToken, + IProgress? progress = null) + => CopyToWithLimit(source, target, maxBytes, DatabaseEntryName, cancellationToken, progress); + + private static long CopyToWithLimit( + Stream source, + Stream target, + long maxBytes, + string entryName, + CancellationToken cancellationToken = default, + IProgress? progress = null) + { + var buffer = new byte[ImportCopyBufferSize]; + long totalBytes = 0; + int bytesRead; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + bytesRead = source.Read(buffer, 0, buffer.Length); + if (bytesRead == 0) + break; + + if (totalBytes > maxBytes - bytesRead) + throw new InvalidDataException($"archive {entryName} exceeds the import limit of {ConsoleUi.FormatBytes(maxBytes)}."); + + target.Write(buffer, 0, bytesRead); + totalBytes += bytesRead; + progress?.Report(totalBytes); + } + + return totalBytes; + } + + private static void RewriteImportedProjectRoot(string dbPath, string projectRoot) + { + using var connection = new SqliteConnection(CreateUnpooledConnectionString(dbPath)); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + INSERT INTO codeindex_meta(key, value) + VALUES ('indexed_project_root', @projectRoot) + ON CONFLICT(key) DO UPDATE SET value = excluded.value"; + SqliteCommandPolicy.Add(cmd, "@projectRoot", Path.GetFullPath(projectRoot)); + cmd.ExecuteNonQuery(); + } + + internal static string ResolveImportTargetProjectRoot(string fullDbPath) + { + var normalizedDbPath = Path.GetFullPath(fullDbPath); + var dbDirectory = Path.GetDirectoryName(normalizedDbPath); + if (!string.IsNullOrWhiteSpace(dbDirectory) + && string.Equals(Path.GetFileName(normalizedDbPath), "codeindex.db", StringComparison.OrdinalIgnoreCase) + && string.Equals(Path.GetFileName(dbDirectory), ".cdidx", StringComparison.OrdinalIgnoreCase)) + { + var siblingRoot = Path.GetDirectoryName(dbDirectory); + if (!string.IsNullOrWhiteSpace(siblingRoot)) + return Path.GetFullPath(siblingRoot); + } + + return Path.GetFullPath(Environment.CurrentDirectory); + } + + private static string FormatImportSuccessMessage(string prefix, bool prunePaths, string importTargetProjectRoot) + => prunePaths + ? $"{prefix}; pruned paths to project root {importTargetProjectRoot}" + : prefix; + +} diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.Manifest.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.Manifest.cs new file mode 100644 index 000000000..c52d8fcf9 --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.Manifest.cs @@ -0,0 +1,111 @@ +using System.IO.Compression; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIndex.Archives; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ExportImportCommandRunner +{ + private static ExportManifest BuildManifest( + SqliteConnection connection, + string appVersion, + ArchiveExportScopeResult scope, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var userVersion = ReadSqliteUserVersion(connection); + var projectRoot = ReadMetaString(connection, DbContext.IndexedProjectRootMetaKey); + var indexedHead = ReadMetaString(connection, DbContext.IndexedHeadShaMetaKey); + var unknownExtensionFiles = ReadUnknownExtensionFileSample(connection); + cancellationToken.ThrowIfCancellationRequested(); + return new ExportManifest( + "1", + appVersion, + userVersion, + projectRoot, + indexedHead, + string.Empty, + FileCount: ReadTableCount(connection, "files", cancellationToken), + ChunkCount: ReadTableCount(connection, "chunks", cancellationToken), + SymbolCount: ReadTableCount(connection, "symbols", cancellationToken), + ReferenceCount: ReadTableCount(connection, "symbol_references", cancellationToken), + GraphReady: (userVersion & DbContext.GraphReadyFlag) != 0, + IssuesReady: (userVersion & DbContext.IssuesReadyFlag) != 0, + FoldReady: (userVersion & DbContext.FoldReadyFlag) != 0, + IndexWriterVersion: ReadMetaString(connection, DbContext.CdidxWriterVersionMetaKey), + IndexedHeadBranch: ReadMetaString(connection, DbContext.IndexedHeadBranchMetaKey), + IndexedHeadTimestamp: ReadMetaString(connection, DbContext.IndexedHeadTimestampMetaKey), + CodeIndexMetaSchemaVersion: ReadMetaInt(connection, DbContext.CodeIndexMetaSchemaVersionMetaKey), + CSharpSymbolNameContractVersion: ReadMetaInt(connection, DbContext.CSharpSymbolNameContractVersionMetaKey), + SqlGraphContractVersion: ReadMetaInt(connection, DbContext.SqlGraphContractVersionMetaKey), + HotspotFamilyVersion: ReadMetaInt(connection, DbContext.HotspotFamilyVersionMetaKey), + UnknownExtensionFileCount: ReadMetaLong(connection, DbContext.UnknownExtensionFileCountMetaKey), + UnknownExtensionFiles: unknownExtensionFiles.Files, + UnknownExtensionFilesTruncated: ReadMetaBool(connection, DbContext.UnknownExtensionFilesTruncatedMetaKey), + UnknownExtensionFilePathLimit: ReadMetaInt(connection, DbContext.UnknownExtensionFilePathLimitMetaKey), + UnknownExtensionFileSampleCount: unknownExtensionFiles.Count, + UnknownExtensionFileSampleLimit: unknownExtensionFiles.Limit, + UnknownExtensionFileSampleTruncated: unknownExtensionFiles.Truncated, + Scope: scope); + } + + private static void AddTextEntry(ZipArchive archive, string name, string content) + { + var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize); + entry.LastWriteTime = DeterministicZipTimestamp; + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + writer.Write(content); + } + + internal static void WriteExportArchiveFile(string outputPath, string snapshotPath, ExportManifest manifest, JsonSerializerOptions jsonOptions, CancellationToken cancellationToken) + { + var fullOutputPath = Path.GetFullPath(outputPath); + AtomicFileWriter.Write( + fullOutputPath, + stream => + { + cancellationToken.ThrowIfCancellationRequested(); + using var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true); + AddTextEntry(archive, ManifestEntryName, JsonSerializer.Serialize(manifest, jsonOptions)); + var dbEntry = archive.CreateEntry(DatabaseEntryName, CompressionLevel.SmallestSize); + dbEntry.LastWriteTime = DeterministicZipTimestamp; + using var source = BoundedFile.OpenReadTrustedArchiveSource(snapshotPath); + using var target = dbEntry.Open(); + CopyToExactLength(source, target, source.Length, DatabaseEntryName, cancellationToken); + }); + } + + internal static void WriteCtagsFile(string outputPath, Action writeContents) + { + ArgumentNullException.ThrowIfNull(writeContents); + + var fullOutputPath = Path.GetFullPath(outputPath); + AtomicFileWriter.Write( + fullOutputPath, + stream => + { + using var writer = new StreamWriter( + stream, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + bufferSize: 1024, + leaveOpen: true); + writeContents(writer); + }); + } + + private static string ComputeSha256(string path, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + using var stream = BoundedFile.OpenReadForHash(path); + return Sha256StreamHasher.ComputeHex(stream, cancellationToken); + } + +} diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.Replacement.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.Replacement.cs new file mode 100644 index 000000000..2ebe82bf7 --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.Replacement.cs @@ -0,0 +1,313 @@ +using System.IO.Compression; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIndex.Archives; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ExportImportCommandRunner +{ + internal static void CreateDatabaseSnapshot(string sourceDbPath, string snapshotPath, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var source = DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( + sourceDbPath, + pooling: false, + out _, + out _); + using var destination = new SqliteConnection(CreateUnpooledConnectionString(snapshotPath)); + source.Open(); + destination.Open(); + DataDirectorySecurity.ApplyPrivateFileMode(snapshotPath); + cancellationToken.ThrowIfCancellationRequested(); + source.BackupDatabase(destination); + cancellationToken.ThrowIfCancellationRequested(); + DataDirectorySecurity.ApplyPrivateFileMode(snapshotPath); + } + + private static string CreateUnpooledConnectionString(string dbPath) + => SqliteConnectionPolicy.BuildConnectionString(dbPath, SqliteConnectionPolicyMode.Unpooled); + + internal static void ReplaceImportedDatabase(string tempPath, string fullDbPath, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var dbBackupPath = MoveExistingReplacementFileToBackup(fullDbPath); + var sidecarBackups = new List(capacity: 2); + try + { + AddReplacementBackup(sidecarBackups, fullDbPath + "-wal"); + AddReplacementBackup(sidecarBackups, fullDbPath + "-shm"); + + cancellationToken.ThrowIfCancellationRequested(); + AtomicFileWriter.MoveFile( + tempPath, + fullDbPath, + overwrite: false, + applyDestinationMode: ApplyImportedDatabasePrivateFileMode); + cancellationToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) + { + try + { + RollBackImportedDatabaseReplacement(fullDbPath, dbBackupPath, sidecarBackups); + } + catch (Exception rollbackEx) when (IsRecoverableReplacementException(rollbackEx)) + { + CommandErrorWriter.WriteStderr($"Warning: failed to roll back cancelled imported database replacement ({CommandErrorWriter.FormatSanitizedException(rollbackEx)})."); + } + + throw; + } + catch (Exception ex) when (IsRecoverableReplacementException(ex)) + { + Exception? rollbackFailure = null; + try + { + RollBackImportedDatabaseReplacement(fullDbPath, dbBackupPath, sidecarBackups); + } + catch (Exception rollbackEx) when (IsRecoverableReplacementException(rollbackEx)) + { + rollbackFailure = rollbackEx; + CommandErrorWriter.WriteStderr($"Warning: failed to roll back imported database replacement ({CommandErrorWriter.FormatSanitizedException(rollbackEx)})."); + } + + throw new ImportReplacementException( + "import database replacement failed; rolled back the previous destination database when possible.", + ex, + BuildReplacementDiagnostics(tempPath, fullDbPath, dbBackupPath, sidecarBackups, rollbackFailure)); + } + + DeleteReplacementBackup(dbBackupPath, "import replaced database backup"); + foreach (var backup in sidecarBackups) + DeleteReplacementBackup(backup.BackupPath, "import replaced database sidecar backup", DeleteSqliteSidecarForTesting); + } + + private static IReadOnlyList BuildReplacementDiagnostics( + string tempPath, + string fullDbPath, + string? dbBackupPath, + IReadOnlyList sidecarBackups, + Exception? rollbackFailure) + { + var diagnostics = new List + { + CreateResidualStateDiagnostic("import_replace_destination_state", "destination database", fullDbPath), + CreateResidualStateDiagnostic("import_replace_staged_state", "staged import database", tempPath), + }; + + if (dbBackupPath != null) + diagnostics.Add(CreateResidualStateDiagnostic("import_replace_backup_state", "destination database backup", dbBackupPath)); + + foreach (var backup in sidecarBackups) + diagnostics.Add(CreateResidualStateDiagnostic("import_replace_sidecar_backup_state", "destination sidecar backup", backup.BackupPath)); + + if (rollbackFailure != null) + { + diagnostics.Add(new ExportImportDiagnosticResult( + "import_replace_rollback_failed", + $"Rollback failed while restoring the previous destination database ({CommandErrorWriter.FormatSanitizedException(rollbackFailure)}).", + ConsoleUi.FormatBoundedValue(fullDbPath))); + } + + return diagnostics; + } + + private static ExportImportDiagnosticResult CreateResidualStateDiagnostic(string code, string description, string path) + => new( + code, + $"{description} exists after replacement failure: {(File.Exists(path) ? "true" : "false")}.", + ConsoleUi.FormatBoundedValue(path)); + + private static void ApplyImportedDatabasePrivateFileMode(string fullDbPath) + { + if (ApplyPrivateFileModeForTesting != null) + { + ApplyPrivateFileModeForTesting(fullDbPath); + return; + } + + DataDirectorySecurity.ApplyPrivateFileMode(fullDbPath); + } + + private static void AddReplacementBackup(List backups, string path) + { + var backupPath = MoveExistingReplacementFileToBackup(path); + if (backupPath != null) + backups.Add(new ReplacementBackup(path, backupPath)); + } + + private static string? MoveExistingReplacementFileToBackup(string path) + { + if (!File.Exists(path)) + return null; + + var backupPath = $"{path}.replace-backup-{Guid.NewGuid():N}"; + AtomicFileWriter.MoveFile(path, backupPath, overwrite: false); + return backupPath; + } + + private static void RollBackImportedDatabaseReplacement( + string fullDbPath, + string? dbBackupPath, + IReadOnlyList sidecarBackups) + { + if (dbBackupPath != null) + { + AtomicFileWriter.MoveReplacing(dbBackupPath, fullDbPath); + } + else if (File.Exists(fullDbPath)) + { + AtomicFileWriter.DeleteFileIfExists(fullDbPath); + } + + foreach (var backup in sidecarBackups) + AtomicFileWriter.MoveReplacing(backup.BackupPath, backup.OriginalPath); + } + + private static void DeleteReplacementBackup(string? path, string cleanupDescription, Action? deleteOverride = null) + { + if (path != null) + TryDeleteFile(path, cleanupDescription, deleteOverride); + } + + private static bool IsRecoverableReplacementException(Exception ex) + => ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or PathTooLongException; + + private static void DeleteSqliteSidecars(string dbPath, string? cleanupDescription = null) + { + TryDeleteFile(dbPath + "-wal", cleanupDescription, DeleteSqliteSidecarForTesting); + TryDeleteFile(dbPath + "-shm", cleanupDescription, DeleteSqliteSidecarForTesting); + } + + private static void TryDeleteFile(string path, string? cleanupDescription = null, Action? deleteOverride = null) + { + try + { + _ = AtomicFileWriter.TryDeleteFile( + path, + ex => + { + if (!string.IsNullOrWhiteSpace(cleanupDescription)) + CommandErrorWriter.WriteStderr($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + }, + deleteOverride ?? DeleteFileForTesting); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) + { + if (!string.IsNullOrWhiteSpace(cleanupDescription)) + CommandErrorWriter.WriteStderr($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + } + } + + private static void TryDeleteDirectoryIfEmpty( + string path, + string? cleanupDescription, + string safeRoot, + string expectedNamePrefix) + { + try + { + var options = new DirectoryCleanupBoundaryOptions( + expectedNamePrefix, + "target is outside the expected cleanup root", + "target name does not match the expected temporary-directory prefix", + "target is not a regular temporary directory"); + if (!FileSystemBoundary.TryValidateDirectoryCleanupTarget(path, safeRoot, options, out var fullPath, out var validationFailure)) + { + if (!string.IsNullOrWhiteSpace(cleanupDescription)) + CommandErrorWriter.WriteStderr($"Warning: skipped deleting {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({validationFailure})."); + return; + } + + if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath)) || CodeIndex.FileSystemTraversalPolicy.HasAnyFileSystemEntry(fullPath)) + return; + + Directory.Delete(LongPath.EnsureWindowsPrefix(fullPath)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) + { + if (!string.IsNullOrWhiteSpace(cleanupDescription)) + CommandErrorWriter.WriteStderr($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + } + } + + internal static Action? DeleteFileForTesting { get; set; } + internal static Action? DeleteSqliteSidecarForTesting { get; set; } + internal static Action? ApplyPrivateFileModeForTesting { get; set; } + + private readonly record struct ReplacementBackup(string OriginalPath, string BackupPath); + + internal static StringComparison ResolveDatabasePathComparison(string dbPath) + { + if (TryReadDatabasePathCaseSensitive(dbPath, out var pathCaseSensitive)) + return pathCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + return PathCasing.ComparisonFor(dbPath); + } + + private static bool TryReadDatabasePathCaseSensitive(string dbPath, out bool pathCaseSensitive) + { + pathCaseSensitive = false; + try + { + using var connection = DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( + dbPath, + pooling: false, + out _, + out _); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key LIMIT 1"; + SqliteCommandPolicy.Add(cmd, "@key", DbContext.WorkspacePathCaseSensitiveMetaKey); + var raw = cmd.ExecuteScalar(); + return raw is string value && bool.TryParse(value, out pathCaseSensitive); + } + catch (Exception ex) when (ex is SqliteException or CodeIndexException or IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + } + + private static bool IsSamePath(string left, string right, StringComparison comparison) + => string.Equals( + Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + comparison); + + internal static bool IsDatabaseOrSqliteSidecarPath(string path, string dbPath, StringComparison comparison) + => IsSamePath(path, dbPath, comparison) + || IsSamePath(path, dbPath + "-wal", comparison) + || IsSamePath(path, dbPath + "-shm", comparison); + + internal static bool IsDatabaseOrSqliteSidecarPath(string path, string dbPath) + { + var liveComparison = PathCasing.ComparisonFor(dbPath); + if (IsDatabaseOrSqliteSidecarPath(path, dbPath, liveComparison)) + return true; + + if (!TryReadDatabasePathCaseSensitive(dbPath, out var pathCaseSensitive)) + return false; + + var stampedComparison = pathCaseSensitive + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + return stampedComparison != liveComparison + && IsDatabaseOrSqliteSidecarPath(path, dbPath, stampedComparison); + } + + private static string SanitizeCtagsField(string value) + => value.Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' '); + +} diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index ac605035e..e4f64efb0 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -12,7 +12,7 @@ namespace CodeIndex.Cli; -internal static class ExportImportCommandRunner +internal static partial class ExportImportCommandRunner { private const string ManifestEntryName = "manifest.json"; private const string DatabaseEntryName = "codeindex.db"; @@ -761,1594 +761,4 @@ private static int RunExportCtags(string[] args, JsonSerializerOptions jsonOptio } } - private static SqliteCommand CreateCtagsSymbolCommand(SqliteConnection connection, CtagsExportOptions filters) - { - var cmd = connection.CreateCommand(); - var sql = $""" - SELECT - s.name, - f.path, - COALESCE(s.start_line, s.line, 1), - s.kind, - f.lang, - s.container_kind, - s.container_name, - s.visibility - FROM symbols s - JOIN files f ON s.file_id = f.id - WHERE s.name IS NOT NULL - AND trim(s.name) != '' - AND s.kind IS NOT NULL - AND trim(s.kind) != '' - AND s.kind IN ({SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds)}) - """; - AppendCtagsFilters(ref sql, filters); - sql += " ORDER BY s.name COLLATE NOCASE, f.path, COALESCE(s.start_line, s.line, 1)"; - cmd.CommandText = sql; - AddCtagsFilterParameters(cmd, filters); - return cmd; - } - - private static SqliteCommand CreateCtagsSkipReasonCommand(SqliteConnection connection, CtagsExportOptions filters) - { - var cmd = connection.CreateCommand(); - var skipReasonCases = new List - { - $"WHEN s.name IS NULL OR trim(s.name) = '' THEN '{CtagsSkipInvalidName}'", - $"WHEN s.kind IS NULL OR trim(s.kind) = '' OR s.kind NOT IN ({SymbolKindCatalog.ToSqlCheckInList(SymbolKindCatalog.SymbolKinds)}) THEN '{CtagsSkipUnsupportedKind}'", - }; - if (filters.GeneratedFileFilterAvailable && !filters.IncludeGenerated) - skipReasonCases.Add($"WHEN COALESCE(f.generated, 0) != 0 THEN '{CtagsSkipGeneratedCode}'"); - if (!string.IsNullOrWhiteSpace(filters.Lang)) - skipReasonCases.Add($"WHEN COALESCE(f.lang, '') != @lang THEN '{CtagsSkipLanguageFilter}'"); - if (filters.ExcludeTests) - skipReasonCases.Add($"WHEN {DbReader.TestPathCondition} THEN '{CtagsSkipTestFilter}'"); - if (filters.PathPatterns.Count > 0) - { - var pathPredicates = new List(filters.PathPatterns.Count); - for (var i = 0; i < filters.PathPatterns.Count; i++) - pathPredicates.Add(DbReader.BuildPathFilterPredicate("f", "pathPattern", i, filters.PathPatterns[i])); - skipReasonCases.Add($"WHEN NOT ({string.Join(" OR ", pathPredicates)}) THEN '{CtagsSkipPathFilter}'"); - } - if (filters.ExcludePathPatterns.Count > 0) - { - var excludePathPredicates = new List(filters.ExcludePathPatterns.Count); - for (var i = 0; i < filters.ExcludePathPatterns.Count; i++) - excludePathPredicates.Add(DbReader.BuildPathFilterPredicate("f", "excludePathPattern", i, filters.ExcludePathPatterns[i])); - skipReasonCases.Add($"WHEN ({string.Join(" OR ", excludePathPredicates)}) THEN '{CtagsSkipExcludePathFilter}'"); - } - - cmd.CommandText = $""" - SELECT skip_reason, COUNT(*) - FROM ( - SELECT - CASE - {string.Join(Environment.NewLine + " ", skipReasonCases)} - ELSE NULL - END AS skip_reason - FROM symbols s - JOIN files f ON s.file_id = f.id - ) - WHERE skip_reason IS NOT NULL - GROUP BY skip_reason - """; - AddCtagsFilterParameters(cmd, filters); - return cmd; - } - - private static Dictionary CountCtagsSkipReasons(SqliteConnection connection, CtagsExportOptions filters) - { - var counts = new Dictionary(StringComparer.Ordinal) - { - [CtagsSkipInvalidName] = 0, - [CtagsSkipUnsupportedKind] = 0, - [CtagsSkipGeneratedCode] = 0, - [CtagsSkipLanguageFilter] = 0, - [CtagsSkipTestFilter] = 0, - [CtagsSkipPathFilter] = 0, - [CtagsSkipExcludePathFilter] = 0, - [CtagsSkipOther] = 0, - }; - using var cmd = CreateCtagsSkipReasonCommand(connection, filters); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) - { - var reason = reader.GetString(0); - var boundedReason = counts.ContainsKey(reason) ? reason : CtagsSkipOther; - counts[boundedReason] += reader.GetInt64(1); - } - return counts; - } - - private static void AppendCtagsFilters(ref string sql, CtagsExportOptions filters) - { - if (filters.GeneratedFileFilterAvailable && !filters.IncludeGenerated) - sql += " AND COALESCE(f.generated, 0) = 0"; - - if (!string.IsNullOrWhiteSpace(filters.Lang)) - sql += " AND f.lang = @lang"; - - if (filters.ExcludeTests) - sql += $" AND NOT {DbReader.TestPathCondition}"; - - if (filters.PathPatterns.Count > 0) - { - var pathPredicates = new List(filters.PathPatterns.Count); - for (var i = 0; i < filters.PathPatterns.Count; i++) - pathPredicates.Add(DbReader.BuildPathFilterPredicate("f", "pathPattern", i, filters.PathPatterns[i])); - sql += " AND (" + string.Join(" OR ", pathPredicates) + ")"; - } - - for (var i = 0; i < filters.ExcludePathPatterns.Count; i++) - sql += $" AND NOT {DbReader.BuildPathFilterPredicate("f", "excludePathPattern", i, filters.ExcludePathPatterns[i])}"; - } - - private static void AddCtagsFilterParameters(SqliteCommand cmd, CtagsExportOptions filters) - { - if (!string.IsNullOrWhiteSpace(filters.Lang)) - SqliteCommandPolicy.Add(cmd, "@lang", filters.Lang); - - DbReader.AddPathFilterParameterSet(cmd, "pathPattern", filters.PathPatterns); - DbReader.AddPathFilterParameterSet(cmd, "excludePathPattern", filters.ExcludePathPatterns); - } - - private static void AppendCtagsExtensionField(StringBuilder builder, string name, string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return; - - builder - .Append('\t') - .Append(name) - .Append(':') - .Append(SanitizeCtagsField(value)); - } - - private static ArchiveExportScopeResult ApplyArchiveScope( - SqliteConnection connection, - ArchiveExportOptions options, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - var sourceFileCount = ReadTableCount(connection, "files", cancellationToken); - var projectPathPatterns = Array.Empty(); - if (options.Projects.Count > 0) - { - var projectRoot = ReadMetaString(connection, DbContext.IndexedProjectRootMetaKey); - if (string.IsNullOrWhiteSpace(projectRoot)) - throw new InvalidOperationException("archive project filters require indexed_project_root metadata"); - projectPathPatterns = SolutionProjectResolver - .ResolveProjectDirectoryGlobs(projectRoot, options.Projects, options.Solution) - .ToArray(); - } - - var effectivePathPatterns = options.PathPatterns.Concat(projectPathPatterns).ToArray(); - var scoped = - !string.IsNullOrWhiteSpace(options.Lang) - || effectivePathPatterns.Length > 0 - || options.ExcludePathPatterns.Count > 0 - || options.ExcludeTests; - if (!scoped) - { - return new ArchiveExportScopeResult( - false, - options.Lang, - options.PathPatterns, - options.ExcludePathPatterns, - options.Projects, - options.Solution, - options.ExcludeTests, - projectPathPatterns, - sourceFileCount, - sourceFileCount); - } - - using (var foreignKeys = connection.CreateCommand()) - { - foreignKeys.CommandText = "PRAGMA foreign_keys = ON"; - foreignKeys.ExecuteNonQuery(); - } - - using (var transaction = connection.BeginTransaction()) - { - using var keepCommand = connection.CreateCommand(); - keepCommand.Transaction = transaction; - keepCommand.CommandText = """ - CREATE TEMP TABLE archive_scope_files(id INTEGER PRIMARY KEY); - INSERT INTO archive_scope_files(id) - SELECT f.id - FROM files f - WHERE 1 = 1 - """; - if (!string.IsNullOrWhiteSpace(options.Lang)) - keepCommand.CommandText += " AND f.lang = @lang"; - if (effectivePathPatterns.Length > 0) - { - var pathPredicates = new List(effectivePathPatterns.Length); - for (var i = 0; i < effectivePathPatterns.Length; i++) - pathPredicates.Add(DbReader.BuildPathFilterPredicate("f", "archivePath", i, effectivePathPatterns[i])); - keepCommand.CommandText += " AND (" + string.Join(" OR ", pathPredicates) + ")"; - } - for (var i = 0; i < options.ExcludePathPatterns.Count; i++) - keepCommand.CommandText += $" AND NOT {DbReader.BuildPathFilterPredicate("f", "archiveExcludePath", i, options.ExcludePathPatterns[i])}"; - if (options.ExcludeTests) - keepCommand.CommandText += $" AND NOT {DbReader.TestPathCondition}"; - if (!string.IsNullOrWhiteSpace(options.Lang)) - SqliteCommandPolicy.Add(keepCommand, "@lang", options.Lang); - DbReader.AddPathFilterParameterSet(keepCommand, "archivePath", effectivePathPatterns); - DbReader.AddPathFilterParameterSet(keepCommand, "archiveExcludePath", options.ExcludePathPatterns); - keepCommand.ExecuteNonQuery(); - - using var pruneCommand = connection.CreateCommand(); - pruneCommand.Transaction = transaction; - pruneCommand.CommandText = """ - DELETE FROM symbol_reference_candidates - WHERE reference_id IN ( - SELECT r.id - FROM symbol_references r - WHERE r.file_id NOT IN (SELECT id FROM archive_scope_files) - ) - OR symbol_id IN ( - SELECT s.id - FROM symbols s - WHERE s.file_id NOT IN (SELECT id FROM archive_scope_files) - ); - - UPDATE symbol_references - SET source_symbol_id = NULL - WHERE source_symbol_id IN ( - SELECT s.id - FROM symbols s - WHERE s.file_id NOT IN (SELECT id FROM archive_scope_files) - ); - - UPDATE symbol_references - SET target_symbol_id = NULL - WHERE target_symbol_id IN ( - SELECT s.id - FROM symbols s - WHERE s.file_id NOT IN (SELECT id FROM archive_scope_files) - ); - - DELETE FROM files - WHERE id NOT IN (SELECT id FROM archive_scope_files); - - DELETE FROM symbol_reference_candidates - WHERE reference_id NOT IN (SELECT id FROM symbol_references) - OR symbol_id NOT IN (SELECT id FROM symbols); - - DROP TABLE archive_scope_files; - """; - pruneCommand.ExecuteNonQuery(); - DbWriter.RebuildRetainedReferenceGraph(connection, transaction, cancellationToken); - transaction.Commit(); - } - - cancellationToken.ThrowIfCancellationRequested(); - using (var foreignKeyCheck = connection.CreateCommand()) - { - foreignKeyCheck.CommandText = "PRAGMA foreign_key_check"; - using var reader = foreignKeyCheck.ExecuteReader(); - if (reader.Read()) - throw new InvalidDataException("scoped archive snapshot failed SQLite foreign-key validation"); - } - - using (var vacuum = connection.CreateCommand()) - { - vacuum.CommandText = "VACUUM"; - vacuum.ExecuteNonQuery(); - } - - var exportedFileCount = ReadTableCount(connection, "files", cancellationToken); - return new ArchiveExportScopeResult( - true, - options.Lang, - options.PathPatterns, - options.ExcludePathPatterns, - options.Projects, - options.Solution, - options.ExcludeTests, - projectPathPatterns, - sourceFileCount, - exportedFileCount); - } - - private static bool TryValidateArchiveScopeValues( - IReadOnlyList pathPatterns, - IReadOnlyList excludePathPatterns, - IReadOnlyList projects, - string? solution, - out string message) - { - var values = pathPatterns.Concat(excludePathPatterns).Concat(projects).ToList(); - if (solution != null) - values.Add(solution); - if (values.Count > MaxArchiveScopeValues) - { - message = $"archive export accepts at most {MaxArchiveScopeValues} scope values"; - return false; - } - - var totalChars = 0; - foreach (var value in values) - { - if (string.IsNullOrWhiteSpace(value)) - { - message = "archive scope values must not be empty"; - return false; - } - if (value.Length > MaxArchiveScopeValueChars) - { - message = $"archive scope values must not exceed {MaxArchiveScopeValueChars} characters"; - return false; - } - totalChars += value.Length; - if (totalChars > MaxArchiveScopeTotalChars) - { - message = $"archive scope values exceed the combined limit of {MaxArchiveScopeTotalChars} characters"; - return false; - } - } - - message = string.Empty; - return true; - } - - private static ImportDestinationDeltaResult BuildImportDestinationDelta( - string destinationDbPath, - string importedDbPath, - string archivePath, - int limit, - int offset, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - if (!File.Exists(destinationDbPath)) - { - return new ImportDestinationDeltaResult( - DestinationExists: false, - Comparable: false, - Status: "destination_missing", - Comparison: null, - Message: "destination database does not exist; the archive would create it"); - } - - var snapshotDirectory = Path.GetDirectoryName(importedDbPath) - ?? throw new InvalidOperationException("import comparison directory could not be resolved"); - var destinationSnapshotPath = Path.Combine(snapshotDirectory, "destination-codeindex.db"); - try - { - try - { - using (var source = BoundedFile.OpenReadForIndexContent(destinationDbPath)) - { - if (source.Length > MaxImportDatabaseBytes) - { - return new ImportDestinationDeltaResult( - DestinationExists: true, - Comparable: false, - Status: "destination_too_large", - Comparison: null, - Message: $"destination database exceeds the comparison limit of {ConsoleUi.FormatBytes(MaxImportDatabaseBytes)}"); - } - - Span header = stackalloc byte[16]; - if (source.Read(header) != header.Length || !header.SequenceEqual("SQLite format 3\0"u8)) - { - return new ImportDestinationDeltaResult( - DestinationExists: true, - Comparable: false, - Status: "destination_unreadable", - Comparison: null, - Message: "destination database could not be compared from a non-mutating snapshot: file header is not SQLite format 3"); - } - } - - CreateDatabaseSnapshot(destinationDbPath, destinationSnapshotPath, cancellationToken); - } - catch (Exception ex) when (ex is SqliteException or CodeIndexException or IOException or UnauthorizedAccessException or InvalidOperationException) - { - return new ImportDestinationDeltaResult( - DestinationExists: true, - Comparable: false, - Status: "destination_unreadable", - Comparison: null, - Message: $"destination database could not be compared from a non-mutating snapshot: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); - } - - if (!DbContext.TryValidateExistingCodeIndexDb( - destinationSnapshotPath, - requireWritable: false, - requireSupportedUserVersion: false, - out var validationMessage, - out _, - out _, - cancellationToken)) - { - return new ImportDestinationDeltaResult( - DestinationExists: true, - Comparable: false, - Status: "destination_unreadable", - Comparison: null, - Message: $"destination database could not be compared from a non-mutating snapshot: {validationMessage}"); - } - - var comparison = DiffCommandRunner.CompareDatabases( - destinationSnapshotPath, - importedDbPath, - limit, - offset, - detailed: true, - cancellationToken, - destinationDbPath, - archivePath); - var comparable = comparison.Status != "schema_mismatch"; - return new ImportDestinationDeltaResult( - DestinationExists: true, - Comparable: comparable, - Status: comparable ? "compared" : "schema_mismatch", - Comparison: comparison, - Message: comparable - ? "destination database was compared from a non-mutating snapshot with the validated archive snapshot" - : "destination and archive schema versions differ"); - } - finally - { - SqliteConnection.ClearAllPools(); - TryDeleteFile(destinationSnapshotPath, "import destination comparison snapshot"); - DeleteSqliteSidecars(destinationSnapshotPath, "import destination comparison snapshot sidecar"); - } - } - - private static string FormatDestinationDeltaSummary(ImportDestinationDeltaResult destinationDelta) - { - if (!destinationDelta.Comparable || destinationDelta.Comparison is not { } comparison) - return $"; {destinationDelta.Message}"; - return string.Create( - CultureInfo.InvariantCulture, - $"; destination delta: files {comparison.Summary.FileCountDelta:+#;-#;0}, symbols {comparison.Summary.SymbolCountDelta:+#;-#;0}, references {comparison.Summary.ReferenceCountDelta:+#;-#;0}"); - } - - private static ExportManifest BuildManifest( - SqliteConnection connection, - string appVersion, - ArchiveExportScopeResult scope, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - var userVersion = ReadSqliteUserVersion(connection); - var projectRoot = ReadMetaString(connection, DbContext.IndexedProjectRootMetaKey); - var indexedHead = ReadMetaString(connection, DbContext.IndexedHeadShaMetaKey); - var unknownExtensionFiles = ReadUnknownExtensionFileSample(connection); - cancellationToken.ThrowIfCancellationRequested(); - return new ExportManifest( - "1", - appVersion, - userVersion, - projectRoot, - indexedHead, - string.Empty, - FileCount: ReadTableCount(connection, "files", cancellationToken), - ChunkCount: ReadTableCount(connection, "chunks", cancellationToken), - SymbolCount: ReadTableCount(connection, "symbols", cancellationToken), - ReferenceCount: ReadTableCount(connection, "symbol_references", cancellationToken), - GraphReady: (userVersion & DbContext.GraphReadyFlag) != 0, - IssuesReady: (userVersion & DbContext.IssuesReadyFlag) != 0, - FoldReady: (userVersion & DbContext.FoldReadyFlag) != 0, - IndexWriterVersion: ReadMetaString(connection, DbContext.CdidxWriterVersionMetaKey), - IndexedHeadBranch: ReadMetaString(connection, DbContext.IndexedHeadBranchMetaKey), - IndexedHeadTimestamp: ReadMetaString(connection, DbContext.IndexedHeadTimestampMetaKey), - CodeIndexMetaSchemaVersion: ReadMetaInt(connection, DbContext.CodeIndexMetaSchemaVersionMetaKey), - CSharpSymbolNameContractVersion: ReadMetaInt(connection, DbContext.CSharpSymbolNameContractVersionMetaKey), - SqlGraphContractVersion: ReadMetaInt(connection, DbContext.SqlGraphContractVersionMetaKey), - HotspotFamilyVersion: ReadMetaInt(connection, DbContext.HotspotFamilyVersionMetaKey), - UnknownExtensionFileCount: ReadMetaLong(connection, DbContext.UnknownExtensionFileCountMetaKey), - UnknownExtensionFiles: unknownExtensionFiles.Files, - UnknownExtensionFilesTruncated: ReadMetaBool(connection, DbContext.UnknownExtensionFilesTruncatedMetaKey), - UnknownExtensionFilePathLimit: ReadMetaInt(connection, DbContext.UnknownExtensionFilePathLimitMetaKey), - UnknownExtensionFileSampleCount: unknownExtensionFiles.Count, - UnknownExtensionFileSampleLimit: unknownExtensionFiles.Limit, - UnknownExtensionFileSampleTruncated: unknownExtensionFiles.Truncated, - Scope: scope); - } - - private static void AddTextEntry(ZipArchive archive, string name, string content) - { - var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize); - entry.LastWriteTime = DeterministicZipTimestamp; - using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - writer.Write(content); - } - - internal static void WriteExportArchiveFile(string outputPath, string snapshotPath, ExportManifest manifest, JsonSerializerOptions jsonOptions, CancellationToken cancellationToken) - { - var fullOutputPath = Path.GetFullPath(outputPath); - AtomicFileWriter.Write( - fullOutputPath, - stream => - { - cancellationToken.ThrowIfCancellationRequested(); - using var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true); - AddTextEntry(archive, ManifestEntryName, JsonSerializer.Serialize(manifest, jsonOptions)); - var dbEntry = archive.CreateEntry(DatabaseEntryName, CompressionLevel.SmallestSize); - dbEntry.LastWriteTime = DeterministicZipTimestamp; - using var source = BoundedFile.OpenReadTrustedArchiveSource(snapshotPath); - using var target = dbEntry.Open(); - CopyToExactLength(source, target, source.Length, DatabaseEntryName, cancellationToken); - }); - } - - internal static void WriteCtagsFile(string outputPath, Action writeContents) - { - ArgumentNullException.ThrowIfNull(writeContents); - - var fullOutputPath = Path.GetFullPath(outputPath); - AtomicFileWriter.Write( - fullOutputPath, - stream => - { - using var writer = new StreamWriter( - stream, - new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - bufferSize: 1024, - leaveOpen: true); - writeContents(writer); - }); - } - - private static string ComputeSha256(string path, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - using var stream = BoundedFile.OpenReadForHash(path); - return Sha256StreamHasher.ComputeHex(stream, cancellationToken); - } - - private static bool TryValidateImportArchiveEntries( - ZipArchive archive, - out ZipArchiveEntry manifestEntry, - out ZipArchiveEntry? databaseEntry, - out string phase, - out string errorCode, - out string message) - { - manifestEntry = null!; - databaseEntry = null!; - phase = PhaseOpenArchive; - errorCode = string.Empty; - message = string.Empty; - - var entries = new Dictionary(StringComparer.Ordinal); - foreach (var entry in archive.Entries) - { - if (!ZipArchiveSafetyPolicy.TryNormalizeRelativeEntryName(entry.FullName, out var normalizedEntryName, out var entryNameFailureReason)) - { - errorCode = "import_archive_unsafe_entry_name"; - message = $"archive contains unsafe entry {ConsoleUi.FormatBoundedValue(entry.FullName)}: ZIP entry name {entryNameFailureReason}; expected only {FormatExpectedImportArchiveEntryNames()}."; - return false; - } - - if (!string.Equals(normalizedEntryName, entry.FullName, StringComparison.Ordinal)) - { - errorCode = "import_archive_noncanonical_entry_name"; - message = $"archive contains non-canonical entry {ConsoleUi.FormatBoundedValue(entry.FullName)} that normalizes to {ConsoleUi.FormatBoundedValue(normalizedEntryName)}; expected only {FormatExpectedImportArchiveEntryNames()}."; - return false; - } - - if (!IsExpectedImportArchiveEntryName(entry.FullName)) - { - errorCode = "import_archive_unexpected_entry"; - message = $"archive contains unexpected entry {ConsoleUi.FormatBoundedValue(entry.FullName)}; expected only {FormatExpectedImportArchiveEntryNames()}."; - return false; - } - - if (!ZipArchiveSafetyPolicy.TryAddUniqueEntryName(entries, entry.FullName, entry)) - { - phase = GetImportArchiveEntryPhase(entry.FullName); - errorCode = "import_archive_duplicate_entry"; - message = $"archive contains duplicate entry {ConsoleUi.FormatBoundedValue(entry.FullName)}."; - return false; - } - } - - if (!entries.TryGetValue(ManifestEntryName, out var foundManifestEntry)) - { - phase = PhaseManifest; - errorCode = "import_manifest_missing"; - message = $"archive is missing {ManifestEntryName}."; - return false; - } - - manifestEntry = foundManifestEntry; - entries.TryGetValue(DatabaseEntryName, out databaseEntry); - return true; - } - - private static bool IsExpectedImportArchiveEntryName(string name) - => Array.Exists(ExpectedImportArchiveEntryNames, expected => string.Equals(expected, name, StringComparison.Ordinal)); - - private static string GetImportArchiveEntryPhase(string name) - => string.Equals(name, ManifestEntryName, StringComparison.Ordinal) - ? PhaseManifest - : string.Equals(name, DatabaseEntryName, StringComparison.Ordinal) - ? PhaseDatabaseEntry - : PhaseOpenArchive; - - private static string FormatExpectedImportArchiveEntryNames() - => string.Join(", ", ExpectedImportArchiveEntryNames.Select(name => $"`{name}`")); - - internal static string FormatImportManifestReadException(Exception ex) - => CommandErrorWriter.FormatSanitizedException(ex); - - private static bool TryReadManifest(ZipArchiveEntry manifestEntry, JsonSerializerOptions jsonOptions, out ExportManifest manifest, out string message, CancellationToken cancellationToken) - { - if (!ExportImportManifestCodec.TryValidateEntrySize(manifestEntry, out message)) - { - manifest = null!; - return false; - } - - try - { - cancellationToken.ThrowIfCancellationRequested(); - using var stream = manifestEntry.Open(); - using var manifestBytes = new MemoryStream((int)Math.Min(Math.Max(manifestEntry.Length, 0), MaxImportManifestBytes)); - CopyToWithLimit(stream, manifestBytes, MaxImportManifestBytes, ManifestEntryName, cancellationToken); - manifestBytes.Position = 0; - cancellationToken.ThrowIfCancellationRequested(); - return ExportImportManifestCodec.TryDeserialize( - manifestBytes.GetBuffer().AsSpan(0, (int)manifestBytes.Length), - jsonOptions, - out manifest, - out message); - } - catch (InvalidDataException ex) - { - manifest = null!; - message = FormatImportManifestReadException(ex); - return false; - } - } - - private static bool TryValidateImportedManifest( - ExportManifest manifest, - string dbPath, - out string message, - out string phase, - CancellationToken cancellationToken = default) - { - phase = PhaseSha256; - var actualSha256 = ComputeSha256(dbPath, cancellationToken); - if (!string.Equals(manifest.DatabaseSha256, actualSha256, StringComparison.OrdinalIgnoreCase)) - { - message = "database_sha256 does not match codeindex.db"; - return false; - } - - phase = PhaseSqliteValidate; - int actualUserVersion; - try - { - cancellationToken.ThrowIfCancellationRequested(); - using var connection = new SqliteConnection(CreateUnpooledConnectionString(dbPath)); - connection.Open(); - actualUserVersion = ReadSqliteUserVersion(connection); - if (!TryValidateManifestCount(manifest.FileCount, connection, "files", "file_count", out message, cancellationToken) - || !TryValidateManifestCount(manifest.ChunkCount, connection, "chunks", "chunk_count", out message, cancellationToken) - || !TryValidateManifestCount(manifest.SymbolCount, connection, "symbols", "symbol_count", out message, cancellationToken) - || !TryValidateManifestCount(manifest.ReferenceCount, connection, "symbol_references", "reference_count", out message, cancellationToken)) - { - return false; - } - } - catch (SqliteException ex) - { - message = $"could not validate codeindex.db manifest metadata ({CommandErrorWriter.FormatSanitizedException(ex)})"; - return false; - } - - if (actualUserVersion != manifest.UserVersion) - { - message = $"manifest user_version `{manifest.UserVersion}` does not match codeindex.db user_version `{actualUserVersion}`"; - return false; - } - - phase = string.Empty; - message = string.Empty; - return true; - } - - private static int ReadSqliteUserVersion(SqliteConnection connection) - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = "PRAGMA user_version"; - return Convert.ToInt32(cmd.ExecuteScalar(), CultureInfo.InvariantCulture); - } - - private static bool TryValidateManifestCount(long? expected, SqliteConnection connection, string tableName, string fieldName, out string message, CancellationToken cancellationToken) - { - if (expected == null) - { - message = string.Empty; - return true; - } - - var actual = ReadTableCount(connection, tableName, cancellationToken); - if (actual != expected.Value) - { - message = $"manifest {fieldName} `{expected.Value}` does not match codeindex.db {tableName} count `{actual}`"; - return false; - } - - message = string.Empty; - return true; - } - - private static long ReadTableCount(SqliteConnection connection, string tableName, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - using var cmd = connection.CreateCommand(); - cmd.CommandText = tableName switch - { - "files" => "SELECT COUNT(*) FROM files", - "chunks" => "SELECT COUNT(*) FROM chunks", - "symbols" => "SELECT COUNT(*) FROM symbols", - "symbol_references" => "SELECT COUNT(*) FROM symbol_references", - _ => throw new ArgumentOutOfRangeException(nameof(tableName), tableName, "Unsupported manifest count table."), - }; - var count = Convert.ToInt64(cmd.ExecuteScalar(), CultureInfo.InvariantCulture); - cancellationToken.ThrowIfCancellationRequested(); - return count; - } - - private static string? ReadMetaString(SqliteConnection connection, string key) - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key LIMIT 1"; - SqliteCommandPolicy.Add(cmd, "@key", key); - return cmd.ExecuteScalar() as string; - } - - private static int? ReadMetaInt(SqliteConnection connection, string key) - { - var value = ReadMetaString(connection, key); - return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed >= 0 - ? parsed - : null; - } - - private static long? ReadMetaLong(SqliteConnection connection, string key) - { - var value = ReadMetaString(connection, key); - return long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed >= 0 - ? parsed - : null; - } - - private static bool? ReadMetaBool(SqliteConnection connection, string key) - { - var value = ReadMetaString(connection, key); - return bool.TryParse(value, out var parsed) ? parsed : null; - } - - private readonly record struct UnknownExtensionFileSample(string[]? Files, int? Count, int? Limit, bool? Truncated); - - private static UnknownExtensionFileSample ReadUnknownExtensionFileSample(SqliteConnection connection) - { - var json = ReadMetaString(connection, DbContext.UnknownExtensionFilePathsMetaKey); - if (string.IsNullOrWhiteSpace(json) || Encoding.UTF8.GetByteCount(json) > MaxImportManifestBytes) - return new(null, null, null, null); - - try - { - var jsonBytes = Encoding.UTF8.GetBytes(json); - var reader = new Utf8JsonReader( - jsonBytes, - new JsonReaderOptions { MaxDepth = ManifestUnknownExtensionJsonDepth }); - if (!reader.Read()) - return new(null, null, null, null); - if (reader.TokenType == JsonTokenType.Null) - { - if (reader.Read()) - return new(null, null, null, null); - - return new(null, 0, ManifestUnknownExtensionFileLimit, false); - } - if (reader.TokenType != JsonTokenType.StartArray) - return new(null, null, null, null); - - var sample = new List(ManifestUnknownExtensionFileLimit); - var decodedItems = 0; - var truncated = false; - var completed = false; - while (reader.Read()) - { - if (reader.TokenType == JsonTokenType.EndArray) - { - completed = true; - break; - } - if (reader.TokenType != JsonTokenType.String) - return new(null, null, null, null); - - decodedItems++; - if (decodedItems > ManifestUnknownExtensionDecodedItemLimit) - { - truncated = true; - break; - } - - var path = reader.GetString(); - if (string.IsNullOrWhiteSpace(path)) - continue; - - if (sample.Count >= ManifestUnknownExtensionFileLimit) - { - truncated = true; - break; - } - - sample.Add(path.Length <= ManifestUnknownExtensionPathCharLimit - ? path - : path[..ManifestUnknownExtensionPathCharLimit]); - } - - if (!completed && !truncated) - return new(null, null, null, null); - if (completed && reader.Read()) - return new(null, null, null, null); - if (sample.Count == 0) - return new(null, 0, ManifestUnknownExtensionFileLimit, false); - - return new(sample.ToArray(), sample.Count, ManifestUnknownExtensionFileLimit, truncated); - } - catch (JsonException) - { - return new(null, null, null, null); - } - } - - internal static bool TryValidateDatabaseEntrySize(long uncompressedLength, long compressedLength, out string message) - { - if (uncompressedLength < 0 || compressedLength < 0) - { - message = "archive codeindex.db size metadata is invalid"; - return false; - } - - if (uncompressedLength > MaxImportDatabaseBytes) - { - message = $"archive codeindex.db is too large: {ConsoleUi.FormatBytes(uncompressedLength)} uncompressed exceeds the import limit of {ConsoleUi.FormatBytes(MaxImportDatabaseBytes)}"; - return false; - } - - if (compressedLength > MaxImportDatabaseBytes) - { - message = $"archive codeindex.db is too large: {ConsoleUi.FormatBytes(compressedLength)} compressed exceeds the import limit of {ConsoleUi.FormatBytes(MaxImportDatabaseBytes)}"; - return false; - } - - if (uncompressedLength > 0 && compressedLength == 0) - { - message = "archive codeindex.db compression metadata is invalid: non-empty entry has zero compressed bytes"; - return false; - } - - if (compressedLength > 0 && uncompressedLength > compressedLength * MaxImportDatabaseCompressionRatio) - { - message = $"archive codeindex.db compression ratio exceeds the import limit of {MaxImportDatabaseCompressionRatio}:1"; - return false; - } - - message = string.Empty; - return true; - } - - private static void ExtractDatabaseEntryToFile(ZipArchiveEntry dbEntry, string destinationPath, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - using var source = dbEntry.Open(); - using var target = File.Open(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None); - CopyToWithLimit(source, target, MaxImportDatabaseBytes, cancellationToken); - } - - internal static long CopyToWithLimit( - Stream source, - Stream target, - long maxBytes, - CancellationToken cancellationToken = default) - => CopyToWithLimit(source, target, maxBytes, DatabaseEntryName, cancellationToken); - - internal static long CopyToExactLength( - Stream source, - Stream target, - long expectedBytes, - string entryName, - CancellationToken cancellationToken = default) - { - if (expectedBytes < 0) - throw new ArgumentOutOfRangeException(nameof(expectedBytes), expectedBytes, "Expected byte length must be non-negative."); - - var buffer = new byte[ImportCopyBufferSize]; - long totalBytes = 0; - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - var bytesRead = source.Read(buffer, 0, buffer.Length); - if (bytesRead == 0) - break; - - if (totalBytes > expectedBytes - bytesRead) - throw new InvalidDataException($"archive {entryName} source grew beyond the expected snapshot length of {ConsoleUi.FormatBytes(expectedBytes)}."); - - target.Write(buffer, 0, bytesRead); - totalBytes += bytesRead; - } - - if (totalBytes != expectedBytes) - throw new EndOfStreamException($"archive {entryName} source ended after {ConsoleUi.FormatBytes(totalBytes)}; expected {ConsoleUi.FormatBytes(expectedBytes)}."); - - return totalBytes; - } - - internal static long CopyToWithLimit( - Stream source, - Stream target, - long maxBytes, - CancellationToken cancellationToken, - IProgress? progress = null) - => CopyToWithLimit(source, target, maxBytes, DatabaseEntryName, cancellationToken, progress); - - private static long CopyToWithLimit( - Stream source, - Stream target, - long maxBytes, - string entryName, - CancellationToken cancellationToken = default, - IProgress? progress = null) - { - var buffer = new byte[ImportCopyBufferSize]; - long totalBytes = 0; - int bytesRead; - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - bytesRead = source.Read(buffer, 0, buffer.Length); - if (bytesRead == 0) - break; - - if (totalBytes > maxBytes - bytesRead) - throw new InvalidDataException($"archive {entryName} exceeds the import limit of {ConsoleUi.FormatBytes(maxBytes)}."); - - target.Write(buffer, 0, bytesRead); - totalBytes += bytesRead; - progress?.Report(totalBytes); - } - - return totalBytes; - } - - private static void RewriteImportedProjectRoot(string dbPath, string projectRoot) - { - using var connection = new SqliteConnection(CreateUnpooledConnectionString(dbPath)); - connection.Open(); - using var cmd = connection.CreateCommand(); - cmd.CommandText = @" - INSERT INTO codeindex_meta(key, value) - VALUES ('indexed_project_root', @projectRoot) - ON CONFLICT(key) DO UPDATE SET value = excluded.value"; - SqliteCommandPolicy.Add(cmd, "@projectRoot", Path.GetFullPath(projectRoot)); - cmd.ExecuteNonQuery(); - } - - internal static string ResolveImportTargetProjectRoot(string fullDbPath) - { - var normalizedDbPath = Path.GetFullPath(fullDbPath); - var dbDirectory = Path.GetDirectoryName(normalizedDbPath); - if (!string.IsNullOrWhiteSpace(dbDirectory) - && string.Equals(Path.GetFileName(normalizedDbPath), "codeindex.db", StringComparison.OrdinalIgnoreCase) - && string.Equals(Path.GetFileName(dbDirectory), ".cdidx", StringComparison.OrdinalIgnoreCase)) - { - var siblingRoot = Path.GetDirectoryName(dbDirectory); - if (!string.IsNullOrWhiteSpace(siblingRoot)) - return Path.GetFullPath(siblingRoot); - } - - return Path.GetFullPath(Environment.CurrentDirectory); - } - - private static string FormatImportSuccessMessage(string prefix, bool prunePaths, string importTargetProjectRoot) - => prunePaths - ? $"{prefix}; pruned paths to project root {importTargetProjectRoot}" - : prefix; - - internal static void CreateDatabaseSnapshot(string sourceDbPath, string snapshotPath, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - using var source = DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( - sourceDbPath, - pooling: false, - out _, - out _); - using var destination = new SqliteConnection(CreateUnpooledConnectionString(snapshotPath)); - source.Open(); - destination.Open(); - DataDirectorySecurity.ApplyPrivateFileMode(snapshotPath); - cancellationToken.ThrowIfCancellationRequested(); - source.BackupDatabase(destination); - cancellationToken.ThrowIfCancellationRequested(); - DataDirectorySecurity.ApplyPrivateFileMode(snapshotPath); - } - - private static string CreateUnpooledConnectionString(string dbPath) - => SqliteConnectionPolicy.BuildConnectionString(dbPath, SqliteConnectionPolicyMode.Unpooled); - - internal static void ReplaceImportedDatabase(string tempPath, string fullDbPath, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - var dbBackupPath = MoveExistingReplacementFileToBackup(fullDbPath); - var sidecarBackups = new List(capacity: 2); - try - { - AddReplacementBackup(sidecarBackups, fullDbPath + "-wal"); - AddReplacementBackup(sidecarBackups, fullDbPath + "-shm"); - - cancellationToken.ThrowIfCancellationRequested(); - AtomicFileWriter.MoveFile( - tempPath, - fullDbPath, - overwrite: false, - applyDestinationMode: ApplyImportedDatabasePrivateFileMode); - cancellationToken.ThrowIfCancellationRequested(); - } - catch (OperationCanceledException) - { - try - { - RollBackImportedDatabaseReplacement(fullDbPath, dbBackupPath, sidecarBackups); - } - catch (Exception rollbackEx) when (IsRecoverableReplacementException(rollbackEx)) - { - CommandErrorWriter.WriteStderr($"Warning: failed to roll back cancelled imported database replacement ({CommandErrorWriter.FormatSanitizedException(rollbackEx)})."); - } - - throw; - } - catch (Exception ex) when (IsRecoverableReplacementException(ex)) - { - Exception? rollbackFailure = null; - try - { - RollBackImportedDatabaseReplacement(fullDbPath, dbBackupPath, sidecarBackups); - } - catch (Exception rollbackEx) when (IsRecoverableReplacementException(rollbackEx)) - { - rollbackFailure = rollbackEx; - CommandErrorWriter.WriteStderr($"Warning: failed to roll back imported database replacement ({CommandErrorWriter.FormatSanitizedException(rollbackEx)})."); - } - - throw new ImportReplacementException( - "import database replacement failed; rolled back the previous destination database when possible.", - ex, - BuildReplacementDiagnostics(tempPath, fullDbPath, dbBackupPath, sidecarBackups, rollbackFailure)); - } - - DeleteReplacementBackup(dbBackupPath, "import replaced database backup"); - foreach (var backup in sidecarBackups) - DeleteReplacementBackup(backup.BackupPath, "import replaced database sidecar backup", DeleteSqliteSidecarForTesting); - } - - private static IReadOnlyList BuildReplacementDiagnostics( - string tempPath, - string fullDbPath, - string? dbBackupPath, - IReadOnlyList sidecarBackups, - Exception? rollbackFailure) - { - var diagnostics = new List - { - CreateResidualStateDiagnostic("import_replace_destination_state", "destination database", fullDbPath), - CreateResidualStateDiagnostic("import_replace_staged_state", "staged import database", tempPath), - }; - - if (dbBackupPath != null) - diagnostics.Add(CreateResidualStateDiagnostic("import_replace_backup_state", "destination database backup", dbBackupPath)); - - foreach (var backup in sidecarBackups) - diagnostics.Add(CreateResidualStateDiagnostic("import_replace_sidecar_backup_state", "destination sidecar backup", backup.BackupPath)); - - if (rollbackFailure != null) - { - diagnostics.Add(new ExportImportDiagnosticResult( - "import_replace_rollback_failed", - $"Rollback failed while restoring the previous destination database ({CommandErrorWriter.FormatSanitizedException(rollbackFailure)}).", - ConsoleUi.FormatBoundedValue(fullDbPath))); - } - - return diagnostics; - } - - private static ExportImportDiagnosticResult CreateResidualStateDiagnostic(string code, string description, string path) - => new( - code, - $"{description} exists after replacement failure: {(File.Exists(path) ? "true" : "false")}.", - ConsoleUi.FormatBoundedValue(path)); - - private static void ApplyImportedDatabasePrivateFileMode(string fullDbPath) - { - if (ApplyPrivateFileModeForTesting != null) - { - ApplyPrivateFileModeForTesting(fullDbPath); - return; - } - - DataDirectorySecurity.ApplyPrivateFileMode(fullDbPath); - } - - private static void AddReplacementBackup(List backups, string path) - { - var backupPath = MoveExistingReplacementFileToBackup(path); - if (backupPath != null) - backups.Add(new ReplacementBackup(path, backupPath)); - } - - private static string? MoveExistingReplacementFileToBackup(string path) - { - if (!File.Exists(path)) - return null; - - var backupPath = $"{path}.replace-backup-{Guid.NewGuid():N}"; - AtomicFileWriter.MoveFile(path, backupPath, overwrite: false); - return backupPath; - } - - private static void RollBackImportedDatabaseReplacement( - string fullDbPath, - string? dbBackupPath, - IReadOnlyList sidecarBackups) - { - if (dbBackupPath != null) - { - AtomicFileWriter.MoveReplacing(dbBackupPath, fullDbPath); - } - else if (File.Exists(fullDbPath)) - { - AtomicFileWriter.DeleteFileIfExists(fullDbPath); - } - - foreach (var backup in sidecarBackups) - AtomicFileWriter.MoveReplacing(backup.BackupPath, backup.OriginalPath); - } - - private static void DeleteReplacementBackup(string? path, string cleanupDescription, Action? deleteOverride = null) - { - if (path != null) - TryDeleteFile(path, cleanupDescription, deleteOverride); - } - - private static bool IsRecoverableReplacementException(Exception ex) - => ex is IOException - or UnauthorizedAccessException - or ArgumentException - or NotSupportedException - or PathTooLongException; - - private static void DeleteSqliteSidecars(string dbPath, string? cleanupDescription = null) - { - TryDeleteFile(dbPath + "-wal", cleanupDescription, DeleteSqliteSidecarForTesting); - TryDeleteFile(dbPath + "-shm", cleanupDescription, DeleteSqliteSidecarForTesting); - } - - private static void TryDeleteFile(string path, string? cleanupDescription = null, Action? deleteOverride = null) - { - try - { - _ = AtomicFileWriter.TryDeleteFile( - path, - ex => - { - if (!string.IsNullOrWhiteSpace(cleanupDescription)) - CommandErrorWriter.WriteStderr($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); - }, - deleteOverride ?? DeleteFileForTesting); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) - { - if (!string.IsNullOrWhiteSpace(cleanupDescription)) - CommandErrorWriter.WriteStderr($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); - } - } - - private static void TryDeleteDirectoryIfEmpty( - string path, - string? cleanupDescription, - string safeRoot, - string expectedNamePrefix) - { - try - { - var options = new DirectoryCleanupBoundaryOptions( - expectedNamePrefix, - "target is outside the expected cleanup root", - "target name does not match the expected temporary-directory prefix", - "target is not a regular temporary directory"); - if (!FileSystemBoundary.TryValidateDirectoryCleanupTarget(path, safeRoot, options, out var fullPath, out var validationFailure)) - { - if (!string.IsNullOrWhiteSpace(cleanupDescription)) - CommandErrorWriter.WriteStderr($"Warning: skipped deleting {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({validationFailure})."); - return; - } - - if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath)) || CodeIndex.FileSystemTraversalPolicy.HasAnyFileSystemEntry(fullPath)) - return; - - Directory.Delete(LongPath.EnsureWindowsPrefix(fullPath)); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) - { - if (!string.IsNullOrWhiteSpace(cleanupDescription)) - CommandErrorWriter.WriteStderr($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); - } - } - - internal static Action? DeleteFileForTesting { get; set; } - internal static Action? DeleteSqliteSidecarForTesting { get; set; } - internal static Action? ApplyPrivateFileModeForTesting { get; set; } - - private readonly record struct ReplacementBackup(string OriginalPath, string BackupPath); - - internal static StringComparison ResolveDatabasePathComparison(string dbPath) - { - if (TryReadDatabasePathCaseSensitive(dbPath, out var pathCaseSensitive)) - return pathCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; - - return PathCasing.ComparisonFor(dbPath); - } - - private static bool TryReadDatabasePathCaseSensitive(string dbPath, out bool pathCaseSensitive) - { - pathCaseSensitive = false; - try - { - using var connection = DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( - dbPath, - pooling: false, - out _, - out _); - connection.Open(); - using var cmd = connection.CreateCommand(); - cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key LIMIT 1"; - SqliteCommandPolicy.Add(cmd, "@key", DbContext.WorkspacePathCaseSensitiveMetaKey); - var raw = cmd.ExecuteScalar(); - return raw is string value && bool.TryParse(value, out pathCaseSensitive); - } - catch (Exception ex) when (ex is SqliteException or CodeIndexException or IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) - { - return false; - } - } - - private static bool IsSamePath(string left, string right, StringComparison comparison) - => string.Equals( - Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), - comparison); - - internal static bool IsDatabaseOrSqliteSidecarPath(string path, string dbPath, StringComparison comparison) - => IsSamePath(path, dbPath, comparison) - || IsSamePath(path, dbPath + "-wal", comparison) - || IsSamePath(path, dbPath + "-shm", comparison); - - internal static bool IsDatabaseOrSqliteSidecarPath(string path, string dbPath) - { - var liveComparison = PathCasing.ComparisonFor(dbPath); - if (IsDatabaseOrSqliteSidecarPath(path, dbPath, liveComparison)) - return true; - - if (!TryReadDatabasePathCaseSensitive(dbPath, out var pathCaseSensitive)) - return false; - - var stampedComparison = pathCaseSensitive - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; - return stampedComparison != liveComparison - && IsDatabaseOrSqliteSidecarPath(path, dbPath, stampedComparison); - } - - private static string SanitizeCtagsField(string value) - => value.Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' '); - - private static bool TryReadValueOption(string[] args, ref int index, string optionName, string arg, out string? value, out string? error) - { - value = null; - error = null; - if (arg == optionName) - { - if (index + 1 >= args.Length || string.IsNullOrWhiteSpace(args[index + 1])) - { - error = $"{optionName} requires a non-empty value."; - return true; - } - value = args[++index]; - return true; - } - - var prefix = optionName + "="; - if (arg.StartsWith(prefix, StringComparison.Ordinal)) - { - value = arg[prefix.Length..]; - if (string.IsNullOrWhiteSpace(value)) - error = $"{optionName} requires a non-empty value."; - return true; - } - - return false; - } - - private static int WriteImportError( - bool json, - JsonSerializerOptions jsonOptions, - string phase, - string errorCode, - string message, - string hint, - string usage, - int exitCode = CommandExitCodes.UsageError, - IReadOnlyList? diagnostics = null, - string? rootCause = null) - => WriteStructuredError(json, jsonOptions, ImportCommandName, phase, errorCode, message, hint, usage, exitCode, diagnostics, rootCause); - - private static int WriteExportError( - bool json, - JsonSerializerOptions jsonOptions, - string phase, - string errorCode, - string message, - string hint, - string usage, - int exitCode = CommandExitCodes.UsageError, - IReadOnlyList? diagnostics = null) - => WriteStructuredError(json, jsonOptions, ExportCommandName, phase, errorCode, message, hint, usage, exitCode, diagnostics); - - private static int WriteStructuredError( - bool json, - JsonSerializerOptions jsonOptions, - string command, - string phase, - string errorCode, - string message, - string hint, - string usage, - int exitCode, - IReadOnlyList? diagnostics, - string? rootCause = null) - { - if (json) - { - Console.WriteLine(JsonSerializer.Serialize( - new ExportImportErrorResult("1", "error", command, phase, errorCode, message, hint, usage, rootCause, diagnostics), - CliJsonSerializerContextFactory.Create(jsonOptions).ExportImportErrorResult)); - return exitCode; - } - - return CommandErrorWriter.Write(message, exitCode, hint, usage); - } - - private static void AddImportValidationPhase( - List validationPhases, - string phase, - string status = "success", - string? message = null) - => validationPhases.Add(new ImportValidationPhaseResult(phase, status, message)); - - private static string ClassifyImportFailureRootCause(string phase, Exception exception) - => exception switch - { - InvalidDataException when phase == PhaseOpenArchive => "invalid_archive", - UnauthorizedAccessException => "permission_denied", - SqliteException => "sqlite_error", - IOException => "io_error", - InvalidDataException => "invalid_data", - _ => "unknown", - }; - - internal sealed record ExportManifest( - [property: JsonPropertyName("format_version")] - string FormatVersion, - [property: JsonPropertyName("cdidx_version")] - string CdidxVersion, - [property: JsonPropertyName("user_version")] - int UserVersion, - [property: JsonPropertyName("project_root")] - string? ProjectRoot, - [property: JsonPropertyName("indexed_head_sha")] - string? IndexedHeadSha, - [property: JsonPropertyName("database_sha256")] - string DatabaseSha256, - [property: JsonPropertyName("file_count")] - long? FileCount = null, - [property: JsonPropertyName("chunk_count")] - long? ChunkCount = null, - [property: JsonPropertyName("symbol_count")] - long? SymbolCount = null, - [property: JsonPropertyName("reference_count")] - long? ReferenceCount = null, - [property: JsonPropertyName("graph_ready")] - bool? GraphReady = null, - [property: JsonPropertyName("issues_ready")] - bool? IssuesReady = null, - [property: JsonPropertyName("fold_ready")] - bool? FoldReady = null, - [property: JsonPropertyName("index_writer_version")] - string? IndexWriterVersion = null, - [property: JsonPropertyName("indexed_head_branch")] - string? IndexedHeadBranch = null, - [property: JsonPropertyName("indexed_head_timestamp")] - string? IndexedHeadTimestamp = null, - [property: JsonPropertyName("codeindex_meta_schema_version")] - int? CodeIndexMetaSchemaVersion = null, - [property: JsonPropertyName("csharp_symbol_name_contract_version")] - int? CSharpSymbolNameContractVersion = null, - [property: JsonPropertyName("sql_graph_contract_version")] - int? SqlGraphContractVersion = null, - [property: JsonPropertyName("hotspot_family_version")] - int? HotspotFamilyVersion = null, - [property: JsonPropertyName("unknown_extension_file_count")] - long? UnknownExtensionFileCount = null, - [property: JsonPropertyName("unknown_extension_files")] - string[]? UnknownExtensionFiles = null, - [property: JsonPropertyName("unknown_extension_files_truncated")] - bool? UnknownExtensionFilesTruncated = null, - [property: JsonPropertyName("unknown_extension_file_path_limit")] - int? UnknownExtensionFilePathLimit = null, - [property: JsonPropertyName("unknown_extension_file_sample_count")] - int? UnknownExtensionFileSampleCount = null, - [property: JsonPropertyName("unknown_extension_file_sample_limit")] - int? UnknownExtensionFileSampleLimit = null, - [property: JsonPropertyName("unknown_extension_file_sample_truncated")] - bool? UnknownExtensionFileSampleTruncated = null, - [property: JsonPropertyName("scope")] - ArchiveExportScopeResult? Scope = null); - internal sealed record ExportImportErrorResult( - [property: JsonPropertyName("api_version")] string ApiVersion, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("command")] string Command, - [property: JsonPropertyName("phase")] string Phase, - [property: JsonPropertyName("error_code")] string ErrorCode, - [property: JsonPropertyName("message")] string Message, - [property: JsonPropertyName("hint")] string Hint, - [property: JsonPropertyName("usage")] string Usage, - [property: JsonPropertyName("root_cause")] - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - string? RootCause = null, - [property: JsonPropertyName("diagnostics")] - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - IReadOnlyList? Diagnostics = null); - internal sealed record ExportImportDiagnosticResult( - [property: JsonPropertyName("code")] string Code, - [property: JsonPropertyName("message")] string Message, - [property: JsonPropertyName("path")] - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - string? Path = null); - internal sealed record ImportValidationPhaseResult( - [property: JsonPropertyName("phase")] string Phase, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("message")] string? Message); - internal sealed record ImportDryRunResult( - [property: JsonPropertyName("api_version")] string ApiVersion, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("archive_path")] string ArchivePath, - [property: JsonPropertyName("db_path")] string DbPath, - [property: JsonPropertyName("mode")] string Mode, - [property: JsonPropertyName("dry_run")] bool DryRun, - [property: JsonPropertyName("pruned_paths")] bool PrunedPaths, - [property: JsonPropertyName("pruned_project_root")] - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - string? PrunedProjectRoot, - [property: JsonPropertyName("replacement_would_be_allowed")] bool ReplacementWouldBeAllowed, - [property: JsonPropertyName("validation_phases")] IReadOnlyList ValidationPhases, - [property: JsonPropertyName("destination_delta")] - ImportDestinationDeltaResult? DestinationDelta = null, - [property: JsonPropertyName("unknown_extension_file_count")] long? UnknownExtensionFileCount = null, - [property: JsonPropertyName("unknown_extension_files")] string[]? UnknownExtensionFiles = null, - [property: JsonPropertyName("unknown_extension_files_truncated")] bool? UnknownExtensionFilesTruncated = null, - [property: JsonPropertyName("unknown_extension_file_path_limit")] int? UnknownExtensionFilePathLimit = null, - [property: JsonPropertyName("unknown_extension_file_sample_count")] int? UnknownExtensionFileSampleCount = null, - [property: JsonPropertyName("unknown_extension_file_sample_limit")] int? UnknownExtensionFileSampleLimit = null, - [property: JsonPropertyName("unknown_extension_file_sample_truncated")] bool? UnknownExtensionFileSampleTruncated = null); - internal sealed record ImportDestinationDeltaResult( - [property: JsonPropertyName("destination_exists")] bool DestinationExists, - [property: JsonPropertyName("comparable")] bool Comparable, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("comparison")] - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - DiffJsonResult? Comparison, - [property: JsonPropertyName("message")] string Message); - internal sealed record ExportArchiveResult( - [property: JsonPropertyName("api_version")] string ApiVersion, - [property: JsonPropertyName("archive_path")] string ArchivePath, - [property: JsonPropertyName("db_path")] string DbPath, - [property: JsonPropertyName("scope")] ArchiveExportScopeResult Scope); - private sealed record ArchiveExportOptions( - string? Lang, - IReadOnlyList PathPatterns, - IReadOnlyList ExcludePathPatterns, - IReadOnlyList Projects, - string? Solution, - bool ExcludeTests) - { - internal bool IsScoped => - !string.IsNullOrWhiteSpace(Lang) || - PathPatterns.Count > 0 || - ExcludePathPatterns.Count > 0 || - Projects.Count > 0 || - !string.IsNullOrWhiteSpace(Solution) || - ExcludeTests; - } - internal sealed record ArchiveExportScopeResult( - [property: JsonPropertyName("scoped")] bool Scoped, - [property: JsonPropertyName("lang")] string? Lang, - [property: JsonPropertyName("path")] IReadOnlyList PathPatterns, - [property: JsonPropertyName("exclude_path")] IReadOnlyList ExcludePathPatterns, - [property: JsonPropertyName("project")] IReadOnlyList Projects, - [property: JsonPropertyName("solution")] string? Solution, - [property: JsonPropertyName("exclude_tests")] bool ExcludeTests, - [property: JsonPropertyName("resolved_project_path")] IReadOnlyList ResolvedProjectPathPatterns, - [property: JsonPropertyName("source_file_count")] long SourceFileCount, - [property: JsonPropertyName("exported_file_count")] long ExportedFileCount); - private sealed record CtagsExportOptions( - string? Lang, - IReadOnlyList PathPatterns, - IReadOnlyList ExcludePathPatterns, - bool ExcludeTests, - bool IncludeGenerated, - bool GeneratedFileFilterAvailable); - internal sealed record CtagsExportFilterResult( - [property: JsonPropertyName("lang")] string? Lang, - [property: JsonPropertyName("path")] IReadOnlyList PathPatterns, - [property: JsonPropertyName("exclude_path")] IReadOnlyList ExcludePathPatterns, - [property: JsonPropertyName("exclude_tests")] bool ExcludeTests, - [property: JsonPropertyName("include_generated")] bool IncludeGenerated, - [property: JsonPropertyName("generated_code_policy")] string GeneratedCodePolicy, - [property: JsonPropertyName("generated_file_filter_available")] bool GeneratedFileFilterAvailable); - internal sealed record CtagsExportResult( - [property: JsonPropertyName("api_version")] string ApiVersion, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("output_path")] string OutputPath, - [property: JsonPropertyName("db_path")] string DbPath, - [property: JsonPropertyName("tag_count")] long TagCount, - [property: JsonPropertyName("emitted_count")] long EmittedCount, - [property: JsonPropertyName("skipped_count")] long SkippedCount, - [property: JsonPropertyName("skip_reason_counts")] IReadOnlyDictionary SkipReasonCounts, - [property: JsonPropertyName("filters")] CtagsExportFilterResult Filters, - [property: JsonPropertyName("metadata_fields")] IReadOnlyList MetadataFields); - internal sealed record ImportResult( - [property: JsonPropertyName("api_version")] string ApiVersion, - [property: JsonPropertyName("status")] string Status, - [property: JsonPropertyName("archive_path")] string ArchivePath, - [property: JsonPropertyName("db_path")] string DbPath, - [property: JsonPropertyName("mode")] string Mode, - [property: JsonPropertyName("dry_run")] bool DryRun, - [property: JsonPropertyName("pruned_paths")] bool PrunedPaths, - [property: JsonPropertyName("pruned_project_root")] - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - string? PrunedProjectRoot, - [property: JsonPropertyName("validation_phases")] IReadOnlyList ValidationPhases, - [property: JsonPropertyName("unknown_extension_file_count")] long? UnknownExtensionFileCount = null, - [property: JsonPropertyName("unknown_extension_files")] string[]? UnknownExtensionFiles = null, - [property: JsonPropertyName("unknown_extension_files_truncated")] bool? UnknownExtensionFilesTruncated = null, - [property: JsonPropertyName("unknown_extension_file_path_limit")] int? UnknownExtensionFilePathLimit = null, - [property: JsonPropertyName("unknown_extension_file_sample_count")] int? UnknownExtensionFileSampleCount = null, - [property: JsonPropertyName("unknown_extension_file_sample_limit")] int? UnknownExtensionFileSampleLimit = null, - [property: JsonPropertyName("unknown_extension_file_sample_truncated")] bool? UnknownExtensionFileSampleTruncated = null); - - private sealed class ImportReplacementException : IOException - { - internal ImportReplacementException(string message, Exception innerException, IReadOnlyList diagnostics) - : base(message, innerException) - { - Diagnostics = diagnostics; - } - - internal IReadOnlyList Diagnostics { get; } - } } From 630d758d6b1608e39a3f5d042aeeb6f82c63d03c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 17:59:15 +0900 Subject: [PATCH 055/101] Decompose the database import pipeline --- .../Cli/ExportImportCommandRunner.cs | 378 +++++++++++------- 1 file changed, 234 insertions(+), 144 deletions(-) diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index e4f64efb0..d87e6c792 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -57,6 +57,18 @@ internal static partial class ExportImportCommandRunner private const string CtagsSkipExcludePathFilter = "exclude_path_filter"; private const string CtagsSkipOther = "other"; + private sealed record ImportArguments( + string ArchivePath, + string? DbPath, + bool WantsJson, + bool PrunePaths, + string ImportMode, + bool DryRun, + int Limit, + int Offset); + + private sealed record ImportArgumentParseResult(ImportArguments? Arguments, int ExitCode); + public static int RunExport( string[] args, JsonSerializerOptions jsonOptions, @@ -71,86 +83,20 @@ public static int RunExport( public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, CancellationToken cancellationToken = default) { - string? archivePath = null; - string? dbPath = null; - var wantsJson = Array.Exists(args, arg => arg == "--json"); - var prunePaths = false; - var importMode = "import"; - var dryRun = false; - var limit = DiffCommandRunner.DefaultDiffLimit; - var offset = 0; - var pagingOptionSpecified = false; - - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (arg == "--json") - { - wantsJson = true; - continue; - } - if (arg == "--prune-paths") - { - prunePaths = true; - continue; - } - if (arg is "--dry-run" or "--check") - { - importMode = arg == "--check" ? "check" : "dry_run"; - dryRun = true; - continue; - } - - if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) - { - if (dbError != null) - return WriteImportError(wantsJson, jsonOptions, PhaseParseArgs, "import_db_requires_value", dbError, "use `cdidx import --db `.", ImportUsage); - dbPath = dbValue; - continue; - } - - if (TryReadValueOption(args, ref i, "--limit", arg, out var limitValue, out var limitError)) - { - pagingOptionSpecified = true; - if (limitError != null - || !int.TryParse(limitValue, NumberStyles.None, CultureInfo.InvariantCulture, out limit) - || limit < 0 - || limit > DiffCommandRunner.MaxDiffLimit) - { - return WriteImportError(wantsJson, jsonOptions, PhaseParseArgs, "import_limit_invalid", $"--limit requires an integer from 0 to {DiffCommandRunner.MaxDiffLimit}.", "use `--limit 20` to bound destination delta samples.", ImportUsage); - } - continue; - } - - if (TryReadValueOption(args, ref i, "--offset", arg, out var offsetValue, out var offsetError)) - { - pagingOptionSpecified = true; - if (offsetError != null - || !int.TryParse(offsetValue, NumberStyles.None, CultureInfo.InvariantCulture, out offset) - || offset < 0 - || offset > int.MaxValue - limit) - { - return WriteImportError(wantsJson, jsonOptions, PhaseParseArgs, "import_offset_invalid", "--offset requires a non-negative integer that can be combined with --limit.", "use `--offset 0` for the first destination delta page.", ImportUsage); - } - continue; - } - - if (arg.StartsWith("-", StringComparison.Ordinal)) - return WriteImportError(wantsJson, jsonOptions, PhaseParseArgs, "import_unknown_option", $"unknown import option `{arg}`.", "use `cdidx import [--db ]`.", ImportUsage); - - if (archivePath != null) - return WriteImportError(wantsJson, jsonOptions, PhaseParseArgs, "import_extra_archive_path", $"import accepts exactly one archive path, got extra `{arg}`.", "remove the extra argument.", ImportUsage); - archivePath = arg; - } - - if (string.IsNullOrWhiteSpace(archivePath)) - return WriteImportError(wantsJson, jsonOptions, PhaseParseArgs, "import_archive_required", "import requires an archive path.", "pass an archive produced by `cdidx export `.", ImportUsage); - if (pagingOptionSpecified && !dryRun) - return WriteImportError(wantsJson, jsonOptions, PhaseParseArgs, "import_paging_requires_dry_run", "--limit and --offset are only valid with --dry-run or --check.", "add `--dry-run` to preview bounded destination deltas.", ImportUsage); - if (offset > int.MaxValue - limit) - return WriteImportError(wantsJson, jsonOptions, PhaseParseArgs, "import_offset_invalid", "--offset is too large for the requested --limit.", "choose a lower --offset.", ImportUsage); - - dbPath ??= DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; + var parseResult = ParseImportArguments(args, jsonOptions); + var importArguments = parseResult.Arguments; + if (importArguments == null) + return parseResult.ExitCode; + + var archivePath = importArguments.ArchivePath; + var wantsJson = importArguments.WantsJson; + var prunePaths = importArguments.PrunePaths; + var importMode = importArguments.ImportMode; + var dryRun = importArguments.DryRun; + var limit = importArguments.Limit; + var offset = importArguments.Offset; + var dbPath = importArguments.DbPath + ?? DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; var fullDbPath = Path.GetFullPath(DbPathResolver.NormalizeDbPath(dbPath)); var importTargetProjectRoot = ResolveImportTargetProjectRoot(fullDbPath); var dbDirectory = Path.GetDirectoryName(fullDbPath); @@ -260,75 +206,26 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, Ca destinationDelta.Message); AddImportValidationPhase(validationPhases, PhaseReplaceDb, "skipped", $"{importMode} mode does not replace the destination database"); var manifest = importedManifest ?? throw new InvalidDataException("archive manifest was not loaded"); - if (wantsJson) - { - Console.WriteLine(JsonSerializer.Serialize( - new ImportDryRunResult( - "1", - "success", - Path.GetFullPath(archivePath), - fullDbPath, - importMode, - dryRun, - prunePaths, - prunePaths ? importTargetProjectRoot : null, - ReplacementWouldBeAllowed: true, - validationPhases, - DestinationDelta: destinationDelta, - UnknownExtensionFileCount: manifest.UnknownExtensionFileCount, - UnknownExtensionFiles: manifest.UnknownExtensionFiles, - UnknownExtensionFilesTruncated: manifest.UnknownExtensionFilesTruncated, - UnknownExtensionFilePathLimit: manifest.UnknownExtensionFilePathLimit, - UnknownExtensionFileSampleCount: manifest.UnknownExtensionFileSampleCount, - UnknownExtensionFileSampleLimit: manifest.UnknownExtensionFileSampleLimit, - UnknownExtensionFileSampleTruncated: manifest.UnknownExtensionFileSampleTruncated), - CliJsonSerializerContextFactory.Create(jsonOptions).ImportDryRunResult)); - } - else - { - Console.WriteLine(FormatImportSuccessMessage( - $"Validated CodeIndex archive {Path.GetFullPath(archivePath)}; replacement would be allowed for {fullDbPath}{FormatDestinationDeltaSummary(destinationDelta)}", - prunePaths, - importTargetProjectRoot)); - } - - return CommandExitCodes.Success; + return WriteImportDryRunResult( + importArguments, + jsonOptions, + fullDbPath, + importTargetProjectRoot, + validationPhases, + destinationDelta, + manifest); } phase = PhaseReplaceDb; ReplaceImportedDatabase(tempPath, fullDbPath, cancellationToken); AddImportValidationPhase(validationPhases, PhaseReplaceDb); - if (wantsJson) - { - var manifest = importedManifest ?? throw new InvalidDataException("archive manifest was not loaded"); - Console.WriteLine(JsonSerializer.Serialize( - new ImportResult( - "1", - "success", - Path.GetFullPath(archivePath), - fullDbPath, - importMode, - DryRun: false, - prunePaths, - prunePaths ? importTargetProjectRoot : null, - validationPhases, - UnknownExtensionFileCount: manifest.UnknownExtensionFileCount, - UnknownExtensionFiles: manifest.UnknownExtensionFiles, - UnknownExtensionFilesTruncated: manifest.UnknownExtensionFilesTruncated, - UnknownExtensionFilePathLimit: manifest.UnknownExtensionFilePathLimit, - UnknownExtensionFileSampleCount: manifest.UnknownExtensionFileSampleCount, - UnknownExtensionFileSampleLimit: manifest.UnknownExtensionFileSampleLimit, - UnknownExtensionFileSampleTruncated: manifest.UnknownExtensionFileSampleTruncated), - jsonOptions)); - } - else - { - Console.WriteLine(FormatImportSuccessMessage( - $"Imported CodeIndex database to {fullDbPath}", - prunePaths, - importTargetProjectRoot)); - } - return CommandExitCodes.Success; + return WriteImportResult( + importArguments, + jsonOptions, + fullDbPath, + importTargetProjectRoot, + validationPhases, + importedManifest ?? throw new InvalidDataException("archive manifest was not loaded")); } catch (OperationCanceledException) { @@ -378,6 +275,199 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, Ca } } + private static ImportArgumentParseResult ParseImportArguments( + string[] args, + JsonSerializerOptions jsonOptions) + { + string? archivePath = null; + string? dbPath = null; + var wantsJson = Array.Exists(args, arg => arg == "--json"); + var prunePaths = false; + var importMode = "import"; + var dryRun = false; + var limit = DiffCommandRunner.DefaultDiffLimit; + var offset = 0; + var pagingOptionSpecified = false; + + ImportArgumentParseResult Fail(string errorCode, string message, string recommendedAction) + { + return new ImportArgumentParseResult( + Arguments: null, + WriteImportError( + wantsJson, + jsonOptions, + PhaseParseArgs, + errorCode, + message, + recommendedAction, + ImportUsage)); + } + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "--json") + { + wantsJson = true; + continue; + } + if (arg == "--prune-paths") + { + prunePaths = true; + continue; + } + if (arg is "--dry-run" or "--check") + { + importMode = arg == "--check" ? "check" : "dry_run"; + dryRun = true; + continue; + } + + if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) + { + if (dbError != null) + return Fail("import_db_requires_value", dbError, "use `cdidx import --db `."); + dbPath = dbValue; + continue; + } + + if (TryReadValueOption(args, ref i, "--limit", arg, out var limitValue, out var limitError)) + { + pagingOptionSpecified = true; + if (limitError != null + || !int.TryParse(limitValue, NumberStyles.None, CultureInfo.InvariantCulture, out limit) + || limit < 0 + || limit > DiffCommandRunner.MaxDiffLimit) + { + return Fail( + "import_limit_invalid", + $"--limit requires an integer from 0 to {DiffCommandRunner.MaxDiffLimit}.", + "use `--limit 20` to bound destination delta samples."); + } + continue; + } + + if (TryReadValueOption(args, ref i, "--offset", arg, out var offsetValue, out var offsetError)) + { + pagingOptionSpecified = true; + if (offsetError != null + || !int.TryParse(offsetValue, NumberStyles.None, CultureInfo.InvariantCulture, out offset) + || offset < 0 + || offset > int.MaxValue - limit) + { + return Fail( + "import_offset_invalid", + "--offset requires a non-negative integer that can be combined with --limit.", + "use `--offset 0` for the first destination delta page."); + } + continue; + } + + if (arg.StartsWith("-", StringComparison.Ordinal)) + return Fail("import_unknown_option", $"unknown import option `{arg}`.", "use `cdidx import [--db ]`."); + + if (archivePath != null) + return Fail("import_extra_archive_path", $"import accepts exactly one archive path, got extra `{arg}`.", "remove the extra argument."); + archivePath = arg; + } + + if (string.IsNullOrWhiteSpace(archivePath)) + return Fail("import_archive_required", "import requires an archive path.", "pass an archive produced by `cdidx export `."); + if (pagingOptionSpecified && !dryRun) + return Fail("import_paging_requires_dry_run", "--limit and --offset are only valid with --dry-run or --check.", "add `--dry-run` to preview bounded destination deltas."); + if (offset > int.MaxValue - limit) + return Fail("import_offset_invalid", "--offset is too large for the requested --limit.", "choose a lower --offset."); + + return new ImportArgumentParseResult( + new ImportArguments(archivePath, dbPath, wantsJson, prunePaths, importMode, dryRun, limit, offset), + CommandExitCodes.Success); + } + + private static int WriteImportDryRunResult( + ImportArguments importArguments, + JsonSerializerOptions jsonOptions, + string fullDbPath, + string importTargetProjectRoot, + IReadOnlyList validationPhases, + ImportDestinationDeltaResult destinationDelta, + ExportManifest manifest) + { + if (importArguments.WantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + new ImportDryRunResult( + "1", + "success", + Path.GetFullPath(importArguments.ArchivePath), + fullDbPath, + importArguments.ImportMode, + importArguments.DryRun, + importArguments.PrunePaths, + importArguments.PrunePaths ? importTargetProjectRoot : null, + ReplacementWouldBeAllowed: true, + validationPhases, + DestinationDelta: destinationDelta, + UnknownExtensionFileCount: manifest.UnknownExtensionFileCount, + UnknownExtensionFiles: manifest.UnknownExtensionFiles, + UnknownExtensionFilesTruncated: manifest.UnknownExtensionFilesTruncated, + UnknownExtensionFilePathLimit: manifest.UnknownExtensionFilePathLimit, + UnknownExtensionFileSampleCount: manifest.UnknownExtensionFileSampleCount, + UnknownExtensionFileSampleLimit: manifest.UnknownExtensionFileSampleLimit, + UnknownExtensionFileSampleTruncated: manifest.UnknownExtensionFileSampleTruncated), + CliJsonSerializerContextFactory.Create(jsonOptions).ImportDryRunResult)); + } + else + { + Console.WriteLine(FormatImportSuccessMessage( + $"Validated CodeIndex archive {Path.GetFullPath(importArguments.ArchivePath)}; replacement would be allowed for {fullDbPath}{FormatDestinationDeltaSummary(destinationDelta)}", + importArguments.PrunePaths, + importTargetProjectRoot)); + } + + return CommandExitCodes.Success; + } + + private static int WriteImportResult( + ImportArguments importArguments, + JsonSerializerOptions jsonOptions, + string fullDbPath, + string importTargetProjectRoot, + IReadOnlyList validationPhases, + ExportManifest manifest) + { + if (importArguments.WantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + new ImportResult( + "1", + "success", + Path.GetFullPath(importArguments.ArchivePath), + fullDbPath, + importArguments.ImportMode, + DryRun: false, + importArguments.PrunePaths, + importArguments.PrunePaths ? importTargetProjectRoot : null, + validationPhases, + UnknownExtensionFileCount: manifest.UnknownExtensionFileCount, + UnknownExtensionFiles: manifest.UnknownExtensionFiles, + UnknownExtensionFilesTruncated: manifest.UnknownExtensionFilesTruncated, + UnknownExtensionFilePathLimit: manifest.UnknownExtensionFilePathLimit, + UnknownExtensionFileSampleCount: manifest.UnknownExtensionFileSampleCount, + UnknownExtensionFileSampleLimit: manifest.UnknownExtensionFileSampleLimit, + UnknownExtensionFileSampleTruncated: manifest.UnknownExtensionFileSampleTruncated), + jsonOptions)); + } + else + { + Console.WriteLine(FormatImportSuccessMessage( + $"Imported CodeIndex database to {fullDbPath}", + importArguments.PrunePaths, + importTargetProjectRoot)); + } + + return CommandExitCodes.Success; + } + private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOptions, string appVersion, CancellationToken cancellationToken) { string? outputPath = null; From b1902128104253315449ea1684603724ae9c78d1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 18:01:00 +0900 Subject: [PATCH 056/101] Split database command responsibilities --- .../Cli/DbCommandRunner.Arguments.cs | 274 ++ .../Cli/DbCommandRunner.Checkpoints.cs | 549 ++++ .../Cli/DbCommandRunner.FileOperations.cs | 181 ++ .../Cli/DbCommandRunner.Maintenance.cs | 497 ++++ src/CodeIndex/Cli/DbCommandRunner.Restore.cs | 265 ++ .../Cli/DbCommandRunner.RestoreValidation.cs | 429 +++ src/CodeIndex/Cli/DbCommandRunner.Schema.cs | 428 +++ src/CodeIndex/Cli/DbCommandRunner.cs | 2534 +---------------- 8 files changed, 2624 insertions(+), 2533 deletions(-) create mode 100644 src/CodeIndex/Cli/DbCommandRunner.Arguments.cs create mode 100644 src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs create mode 100644 src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs create mode 100644 src/CodeIndex/Cli/DbCommandRunner.Maintenance.cs create mode 100644 src/CodeIndex/Cli/DbCommandRunner.Restore.cs create mode 100644 src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs create mode 100644 src/CodeIndex/Cli/DbCommandRunner.Schema.cs diff --git a/src/CodeIndex/Cli/DbCommandRunner.Arguments.cs b/src/CodeIndex/Cli/DbCommandRunner.Arguments.cs new file mode 100644 index 000000000..8d6a08e93 --- /dev/null +++ b/src/CodeIndex/Cli/DbCommandRunner.Arguments.cs @@ -0,0 +1,274 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class DbCommandRunner +{ + internal static DbCommandOptions ParseArgs(string[] args) + { + var dbPath = Path.Combine(".cdidx", "codeindex.db"); + var json = false; + var integrityCheck = false; + var schema = false; + var prune = false; + var pruneDryRun = false; + var pruneApply = false; + var checkpoint = false; + var listCheckpoints = false; + var restore = false; + var restoreBackups = false; + var checkpointsList = false; + var checkpointsDelete = false; + var checkpointsPrune = false; + var checkpointsKeep = DefaultRestoreBackupKeepCount; + var restoreBackupsList = false; + var restoreBackupsPrune = false; + var restoreBackupsKeep = DefaultRestoreBackupKeepCount; + var schemaSummaryOnly = false; + var schemaEntryLimit = SchemaEntryLimit; + var schemaSqlTextLimit = SchemaSqlTextLimit; + bool? schemaIncludeInternal = null; + var schemaSpecificOptionSeen = false; + string? parsedSchemaType = null; + string? parsedSchemaName = null; + string? name = null; + string? parseError = null; + + for (var i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--db" when i + 1 < args.Length: + dbPath = args[++i]; + break; + case "--db": + parseError = "--db requires a value"; + break; + case "--json": + json = true; + break; + case "--integrity-check": + integrityCheck = true; + break; + case "integrity": + integrityCheck = true; + break; + case "schema": + schema = true; + break; + case "--type" when i + 1 < args.Length: + schemaSpecificOptionSeen = true; + var schemaType = args[++i].Trim().ToLowerInvariant(); + if (!SchemaObjectTypes.Contains(schemaType, StringComparer.Ordinal)) + parseError = "--type must be one of table, index, trigger, or view"; + else + parsedSchemaType = schemaType; + break; + case "--type": + parseError = "--type requires a value"; + break; + case "--name" when i + 1 < args.Length: + schemaSpecificOptionSeen = true; + parsedSchemaName = args[++i]; + break; + case "--name": + parseError = "--name requires a value"; + break; + case "--summary-only": + schemaSpecificOptionSeen = true; + schemaSummaryOnly = true; + break; + case "--limit" when i + 1 < args.Length: + schemaSpecificOptionSeen = true; + if (!int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out schemaEntryLimit) + || schemaEntryLimit < 0 + || schemaEntryLimit > SchemaEntryLimit) + { + parseError = $"--limit must be an integer from 0 to {SchemaEntryLimit}"; + } + break; + case "--limit": + parseError = "--limit requires a value"; + break; + case "--max-sql-chars" when i + 1 < args.Length: + schemaSpecificOptionSeen = true; + if (!int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out schemaSqlTextLimit) + || schemaSqlTextLimit < 0 + || schemaSqlTextLimit > SchemaSqlTextLimit) + { + parseError = $"--max-sql-chars must be an integer from 0 to {SchemaSqlTextLimit}"; + } + break; + case "--max-sql-chars": + parseError = "--max-sql-chars requires a value"; + break; + case "--include-internal": + schemaSpecificOptionSeen = true; + if (schemaIncludeInternal == false) + parseError = "--include-internal and --exclude-internal cannot be combined"; + else + schemaIncludeInternal = true; + break; + case "--exclude-internal": + schemaSpecificOptionSeen = true; + if (schemaIncludeInternal == true) + parseError = "--include-internal and --exclude-internal cannot be combined"; + else + schemaIncludeInternal = false; + break; + case "prune": + prune = true; + break; + case "checkpoint": + checkpoint = true; + if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) + name = args[++i]; + break; + case "checkpoints": + listCheckpoints = true; + break; + case "restore": + restore = true; + if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) + name = args[++i]; + else + parseError = "restore requires a checkpoint name"; + break; + case "restore-backups": + restoreBackups = true; + break; + case "--dry-run": + pruneDryRun = true; + break; + case "--apply": + pruneApply = true; + break; + case "--prune": + if (restoreBackups) + restoreBackupsPrune = true; + else if (listCheckpoints) + checkpointsPrune = true; + else + parseError = "--prune is only valid with `cdidx db checkpoints --prune` or `cdidx db restore-backups --prune`"; + break; + case "--delete" when i + 1 < args.Length + && !args[i + 1].StartsWith("-", StringComparison.Ordinal): + if (!listCheckpoints) + { + parseError = "--delete is only valid with `cdidx db checkpoints --delete `"; + break; + } + + checkpointsDelete = true; + name = args[++i]; + break; + case "--delete": + parseError = "--delete requires a checkpoint name"; + break; + case "--keep" when i + 1 < args.Length: + if (!restoreBackups && !checkpointsPrune) + { + parseError = "--keep is only valid with checkpoint or restore-backup pruning"; + break; + } + + if (!int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var parsedKeep) + || parsedKeep < 0 + || parsedKeep > MaxRestoreBackupKeepCount) + { + parseError = $"--keep must be an integer from 0 to {MaxRestoreBackupKeepCount}"; + } + else if (restoreBackups) + { + restoreBackupsKeep = parsedKeep; + } + else + { + checkpointsKeep = parsedKeep; + } + break; + case "--keep": + parseError = "--keep requires a value"; + break; + case "--list": + if (listCheckpoints) + { + checkpointsList = true; + break; + } + if (restoreBackups) + { + restoreBackupsList = true; + break; + } + + parseError = "--list is only valid with `cdidx db checkpoints --list`"; + break; + case "--help" or "-h": + return new DbCommandOptions { ShowHelp = true, DbPath = dbPath, Json = json }; + default: + if (args[i].StartsWith('-')) + parseError = $"db does not support option: '{args[i]}'"; + else + parseError = $"unknown db command or argument: '{args[i]}'"; + break; + } + + if (parseError != null) + break; + } + + if (parseError is null && restoreBackups && pruneApply) + parseError = "--apply is not supported with `cdidx db restore-backups`; `--prune` is the explicit mutation opt-in."; + if (parseError is null && pruneDryRun && restoreBackups && !restoreBackupsPrune) + parseError = "--dry-run is only valid with `cdidx db restore-backups --prune`."; + if (parseError is null && pruneDryRun && listCheckpoints && !checkpointsDelete && !checkpointsPrune) + parseError = "--dry-run is only valid with checkpoint deletion or pruning."; + if (parseError is null && !schema && schemaSpecificOptionSeen) + parseError = "--type, --name, --summary-only, --limit, --max-sql-chars, --include-internal, and --exclude-internal are only valid with `cdidx db schema`."; + if (parseError is null && pruneDryRun && !prune && !checkpoint && !restore && !restoreBackups && !listCheckpoints) + parseError = "--dry-run is only valid with a supported preview operation."; + if (parseError is null && pruneApply && !prune) + parseError = "--apply is only valid with `cdidx db prune --apply`."; + + return new DbCommandOptions + { + DbPath = dbPath, + Json = json, + IntegrityCheck = integrityCheck, + Schema = schema, + Prune = prune, + PruneDryRun = pruneDryRun, + PruneApply = pruneApply, + Checkpoint = checkpoint, + ListCheckpoints = listCheckpoints, + CheckpointsList = checkpointsList, + CheckpointsDelete = checkpointsDelete, + CheckpointsPrune = checkpointsPrune, + CheckpointsKeep = checkpointsKeep, + CheckpointsDryRun = listCheckpoints && pruneDryRun, + Restore = restore, + RestoreDryRun = restore && pruneDryRun, + RestoreBackups = restoreBackups, + RestoreBackupsList = restoreBackupsList, + RestoreBackupsPrune = restoreBackupsPrune, + RestoreBackupsKeep = restoreBackupsKeep, + RestoreBackupsDryRun = restoreBackups && pruneDryRun, + SchemaSummaryOnly = schemaSummaryOnly, + SchemaEntryLimit = schemaEntryLimit, + SchemaSqlTextLimit = schemaSqlTextLimit, + SchemaIncludeInternal = schemaIncludeInternal ?? true, + SchemaType = parsedSchemaType, + SchemaName = parsedSchemaName, + CheckpointDryRun = checkpoint && pruneDryRun, + Name = name, + ParseError = parseError, + }; + } +} diff --git a/src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs b/src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs new file mode 100644 index 000000000..1bce8195d --- /dev/null +++ b/src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs @@ -0,0 +1,549 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class DbCommandRunner +{ + private static DbCheckpointOperationResult CreateCheckpoint(string fullDbPath, string name) + { + ValidateCheckpointName(name); + var root = GetCheckpointRoot(fullDbPath); + var checkpointPath = GetCheckpointPath(fullDbPath, name); + if (Directory.Exists(checkpointPath)) + throw new InvalidOperationException($"checkpoint already exists: {FormatCheckpointNameForDiagnostic(name)}"); + + DataDirectorySecurity.CreateSensitiveDirectory(root); + var tempPath = Path.Combine(root, ".tmp-" + name + "-" + Guid.NewGuid().ToString("N")); + DataDirectorySecurity.CreateSensitiveDirectory(tempPath); + try + { + CopyIfExists(fullDbPath, Path.Combine(tempPath, Path.GetFileName(fullDbPath)), privateDestination: true); + CopyIfExists(fullDbPath + "-wal", Path.Combine(tempPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true); + CopyIfExists(fullDbPath + "-shm", Path.Combine(tempPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true); + DataDirectorySecurity.WritePrivateText(Path.Combine(tempPath, "manifest.txt"), $"name={name}{Environment.NewLine}created_at_utc={GetUtcNow():O}{Environment.NewLine}db_file={Path.GetFileName(fullDbPath)}{Environment.NewLine}"); + AtomicFileWriter.PublishDirectory(tempPath, checkpointPath); + } + catch + { + TryDeleteTemporaryDirectory( + tempPath, + "checkpoint temporary directory", + root, + ".tmp-"); + throw; + } + + var diagnostics = new List(); + var files = EnumerateCheckpointFileNames(checkpointPath, diagnostics); + var bytes = files.Truncated + ? (Bytes: 0L, Truncated: true) + : SumCheckpointBytes(checkpointPath, diagnostics); + return new DbCheckpointOperationResult(name, checkpointPath, files.Items, files.Truncated || bytes.Truncated, diagnostics, bytes.Bytes); + } + + private static DbCheckpointOperationResult PreviewCheckpoint(string fullDbPath, string name) + { + ValidateCheckpointName(name); + var checkpointPath = GetCheckpointPath(fullDbPath, name); + var diagnostics = new List(); + if (Directory.Exists(LongPath.EnsureWindowsPrefix(checkpointPath))) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_already_exists", + "A checkpoint with this name already exists; running without --dry-run would fail.", + ConsoleUi.FormatBoundedValue(checkpointPath))); + } + + var files = ReadCheckpointSourceFiles(fullDbPath, diagnostics); + return new DbCheckpointOperationResult(name, checkpointPath, files.Files, files.Truncated, diagnostics, files.Bytes); + } + + private static (List Files, long Bytes, bool Truncated) ReadCheckpointSourceFiles( + string fullDbPath, + List diagnostics) + { + var files = new List(); + long bytes = 0; + foreach (var source in new[] { fullDbPath, fullDbPath + "-wal", fullDbPath + "-shm" }) + { + try + { + if (!TryGetRegularExistingFile(source, out var normalizedSource)) + continue; + + files.Add(Path.GetFileName(source) ?? source); + bytes += new FileInfo(normalizedSource).Length; + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(CreateCheckpointDiagnostic( + "checkpoint_source_file_stat_failed", + $"Unable to inspect checkpoint source file ({CommandErrorWriter.FormatSanitizedException(ex)}).", + source)); + return (files, bytes, Truncated: true); + } + } + + files.Sort(StringComparer.Ordinal); + return (files, bytes, Truncated: false); + } + + private static DbCheckpointListReadResult ListCheckpoints(string fullDbPath, int limit) + { + var root = GetCheckpointRoot(fullDbPath); + var diagnostics = new List(); + if (!Directory.Exists(root)) + return new DbCheckpointListReadResult([], DirectoryEnumerationTruncated: false, FileInspectionTruncated: false, diagnostics); + + var dbFileName = Path.GetFileName(fullDbPath); + var entries = new List(); + var checkpointsTruncated = false; + var directoriesInspected = 0; + var directories = EnumerateCheckpointDirectories(root, diagnostics, limit + 1); + checkpointsTruncated |= directories.Truncated; + foreach (var path in directories.Items) + { + if (directoriesInspected >= limit) + { + checkpointsTruncated = true; + break; + } + + directoriesInspected++; + if (Path.GetFileName(path).StartsWith(".tmp-", StringComparison.Ordinal)) + continue; + if (!File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(path, dbFileName)))) + continue; + + DirectoryInfo info; + DateTime createdAtUtc; + try + { + info = new DirectoryInfo(path); + createdAtUtc = info.CreationTimeUtc; + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_directory_stat_failed", "Unable to inspect checkpoint directory metadata.", path)); + checkpointsTruncated = true; + continue; + } + + var bytes = SumCheckpointBytes(path, diagnostics); + entries.Add(new DbCheckpointListEntryJsonResult( + info.Name, + path, + createdAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + bytes.Bytes, + bytes.Truncated)); + } + + entries.Sort((left, right) => + { + var createdCompare = string.Compare(right.CreatedAtUtc, left.CreatedAtUtc, StringComparison.Ordinal); + return createdCompare != 0 + ? createdCompare + : string.Compare(left.Name, right.Name, StringComparison.Ordinal); + }); + return new DbCheckpointListReadResult( + entries, + checkpointsTruncated, + entries.Any(entry => entry.FilesTruncated), + diagnostics); + } + + private static DbRestoreBackupReadResult ListRestoreBackups(string fullDbPath, int limit) + { + var parent = Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."); + var diagnostics = new List(); + if (!Directory.Exists(parent)) + return new DbRestoreBackupReadResult([], DirectoryEnumerationTruncated: false, FileInspectionTruncated: false, diagnostics); + + var dbFileName = Path.GetFileName(fullDbPath); + var prefix = GetRestoreBackupDirectoryPrefix(fullDbPath); + var entries = new List(); + var backupsTruncated = false; + var directoriesInspected = 0; + var directories = EnumerateRestoreBackupDirectories(parent, prefix, diagnostics, limit + 1); + backupsTruncated |= directories.Truncated; + foreach (var path in directories.Items) + { + if (directoriesInspected >= limit) + { + backupsTruncated = true; + break; + } + + directoriesInspected++; + if (!File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(path, dbFileName)))) + continue; + + DirectoryInfo info; + DateTime createdAtUtc; + try + { + info = new DirectoryInfo(path); + createdAtUtc = info.CreationTimeUtc; + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(CreateCheckpointDiagnostic("restore_backup_directory_stat_failed", "Unable to inspect restore backup directory metadata.", path)); + backupsTruncated = true; + continue; + } + + var bytes = SumCheckpointBytes(path, diagnostics); + entries.Add(new DbRestoreBackupEntryJsonResult( + info.Name, + path, + createdAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + bytes.Bytes, + bytes.Truncated)); + } + + entries.Sort((left, right) => + { + var createdCompare = string.Compare(right.CreatedAtUtc, left.CreatedAtUtc, StringComparison.Ordinal); + return createdCompare != 0 + ? createdCompare + : string.Compare(right.Name, left.Name, StringComparison.Ordinal); + }); + return new DbRestoreBackupReadResult(entries, backupsTruncated, entries.Any(entry => entry.FilesTruncated), diagnostics); + } + + private static DbRestoreBackupPruneResult PruneRestoreBackups(string fullDbPath, int keep, bool dryRun) + { + var result = ListRestoreBackups(fullDbPath, RestoreBackupPruneScanLimit); + var diagnostics = result.Diagnostics; + if (result.DirectoryEnumerationTruncated) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_prune_truncated", + "Restore backup pruning was skipped because backup enumeration reached the scan limit.", + ConsoleUi.FormatBoundedValue(fullDbPath))); + return new DbRestoreBackupPruneResult( + Deleted: 0, + Retained: result.Entries.Count, + DeletedPaths: [], + RetainedPaths: result.Entries.Select(entry => entry.BackupPath).ToList(), + Truncated: true, + diagnostics); + } + + var retainedPaths = result.Entries + .Take(keep) + .Select(entry => entry.BackupPath) + .ToList(); + var deletedPaths = new List(); + var parent = Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."); + var prefix = GetRestoreBackupDirectoryPrefix(fullDbPath); + foreach (var entry in result.Entries.Skip(keep)) + { + if (dryRun) + { + if (TryValidateTemporaryDirectoryCleanupTarget(entry.BackupPath, parent, prefix, out _, out var validationFailure)) + deletedPaths.Add(entry.BackupPath); + else + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_delete_skipped", + $"Restore backup deletion would be skipped: {validationFailure}.", + ConsoleUi.FormatBoundedValue(entry.BackupPath))); + retainedPaths.Add(entry.BackupPath); + } + } + else if (TryDeleteRestoreBackupDirectory(fullDbPath, entry.BackupPath, diagnostics)) + { + deletedPaths.Add(entry.BackupPath); + } + else + { + retainedPaths.Add(entry.BackupPath); + } + } + + return new DbRestoreBackupPruneResult( + deletedPaths.Count, + retainedPaths.Count, + deletedPaths, + retainedPaths, + result.Truncated, + diagnostics); + } + + private static (List Items, bool Truncated) EnumerateRestoreBackupDirectories( + string parent, + string prefix, + List diagnostics, + int limit) + { + var directories = new List(); + try + { + foreach (var directory in CodeIndex.FileSystemTraversalPolicy.EnumerateDirectories(parent, prefix + "*")) + { + if (directories.Count >= limit) + return (directories, Truncated: true); + if (Path.GetFileName(directory).StartsWith(prefix, StringComparison.Ordinal)) + directories.Add(directory); + } + + return (directories, Truncated: false); + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(CreateCheckpointDiagnostic("restore_backup_directory_enumeration_failed", "Unable to enumerate every restore backup directory.", parent)); + return (directories, Truncated: true); + } + } + + private static bool TryDeleteRestoreBackupDirectory( + string fullDbPath, + string backupPath, + List diagnostics) + { + var parent = Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."); + var prefix = GetRestoreBackupDirectoryPrefix(fullDbPath); + if (!TryValidateTemporaryDirectoryCleanupTarget(backupPath, parent, prefix, out var fullPath, out var validationFailure)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_delete_skipped", + $"Skipped deleting restore backup directory: {validationFailure}.", + ConsoleUi.FormatBoundedValue(backupPath))); + return false; + } + + try + { + if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath))) + return false; + + Directory.Delete(LongPath.EnsureWindowsPrefix(fullPath), recursive: true); + return true; + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_delete_failed", + $"Unable to delete restore backup directory ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(fullPath))); + return false; + } + } + + private static bool TryDeleteCheckpointDirectory( + string fullDbPath, + string checkpointPath, + List diagnostics) + { + if (!TryValidateCheckpointDirectoryTarget( + fullDbPath, + checkpointPath, + out var fullPath, + out var validationFailure)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_delete_skipped", + $"Skipped deleting checkpoint directory: {validationFailure}.", + ConsoleUi.FormatBoundedValue(checkpointPath))); + return false; + } + + try + { + if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath))) + return false; + if (!TryValidateCheckpointDirectoryTarget( + fullDbPath, + fullPath, + out fullPath, + out validationFailure)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_delete_skipped", + $"Skipped deleting checkpoint directory after revalidation: {validationFailure}.", + ConsoleUi.FormatBoundedValue(checkpointPath))); + return false; + } + + Directory.Delete(LongPath.EnsureWindowsPrefix(fullPath), recursive: true); + return true; + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_delete_failed", + $"Unable to delete checkpoint directory ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(fullPath))); + return false; + } + } + + private static bool TryValidateCheckpointDirectoryTarget( + string fullDbPath, + string checkpointPath, + out string fullPath, + out string failureReason) + { + var checkpointRoot = GetCheckpointRoot(fullDbPath); + var rootStatus = FileSystemBoundary.TryGetAttributes(checkpointRoot, out var rootAttributes); + if (rootStatus != FileSystemBoundaryProbeStatus.Found) + { + fullPath = string.Empty; + failureReason = "checkpoint root is unavailable"; + return false; + } + if ((rootAttributes & FileAttributes.Directory) == 0 + || FileSystemBoundary.IsSymlinkOrReparsePoint(rootAttributes) + || FileSystemBoundary.IsDevice(rootAttributes)) + { + fullPath = string.Empty; + failureReason = "checkpoint root is not a regular directory"; + return false; + } + + var options = new DirectoryCleanupBoundaryOptions( + ExpectedNamePrefix: string.Empty, + OutsideRootReason: "target is outside the checkpoint root", + PrefixMismatchReason: "target name is not a checkpoint name", + UnsafeDirectoryReason: "target is not a regular checkpoint directory"); + return FileSystemBoundary.TryValidateDirectoryCleanupTarget( + checkpointPath, + checkpointRoot, + options, + out fullPath, + out failureReason); + } + + private static (List Items, bool Truncated) EnumerateCheckpointDirectories( + string root, + List diagnostics, + int limit) + { + var directories = new List(); + try + { + foreach (var directory in CodeIndex.FileSystemTraversalPolicy.EnumerateDirectories(root)) + { + if (directories.Count >= limit) + return (directories, Truncated: true); + directories.Add(directory); + } + + return (directories, Truncated: false); + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_directory_enumeration_failed", "Unable to enumerate every checkpoint directory.", root)); + return (directories, Truncated: true); + } + } + + private static (List Items, bool Truncated) EnumerateCheckpointFileNames( + string checkpointPath, + List diagnostics) + { + var files = new List(); + var truncated = false; + try + { + if (EnumerateCheckpointFileNamesForTesting != null) + { + foreach (var name in EnumerateCheckpointFileNamesForTesting(checkpointPath)) + { + if (files.Count >= CheckpointFileInspectLimit) + { + truncated = true; + break; + } + + if (name is not null) + files.Add(name); + } + } + else + { + var listedFiles = EnumerateCheckpointFiles(checkpointPath, diagnostics, CheckpointFileInspectLimit + 1); + foreach (var file in listedFiles.Items) + { + if (files.Count >= CheckpointFileInspectLimit) + { + truncated = true; + break; + } + + var name = Path.GetFileName(file); + if (name is not null) + files.Add(name); + } + + truncated = listedFiles.Truncated || listedFiles.Items.Count > CheckpointFileInspectLimit; + } + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_file_enumeration_failed", "Unable to enumerate every checkpoint file.", checkpointPath)); + truncated = true; + } + + files.Sort(StringComparer.Ordinal); + return (files, truncated); + } + + private static (long Bytes, bool Truncated) SumCheckpointBytes(string checkpointPath, List diagnostics) + { + long bytes = 0; + var filesSeen = 0; + var files = EnumerateCheckpointFiles(checkpointPath, diagnostics, CheckpointFileInspectLimit + 1); + foreach (var file in files.Items) + { + if (filesSeen >= CheckpointFileInspectLimit) + return (bytes, Truncated: true); + + try + { + bytes += new FileInfo(file).Length; + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_file_stat_failed", "Unable to inspect every checkpoint file.", file)); + return (bytes, Truncated: true); + } + + filesSeen++; + } + + return (bytes, files.Truncated); + } + + private static (List Items, bool Truncated) EnumerateCheckpointFiles( + string checkpointPath, + List diagnostics, + int limit) + { + var files = new List(); + try + { + foreach (var file in EnumerateCheckpointFilesForTesting?.Invoke(checkpointPath) ?? CodeIndex.FileSystemTraversalPolicy.EnumerateFiles(checkpointPath)) + { + if (files.Count >= limit) + return (files, Truncated: true); + files.Add(file); + } + + return (files, Truncated: false); + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_file_enumeration_failed", "Unable to enumerate every checkpoint file.", checkpointPath)); + return (files, Truncated: true); + } + } + +} diff --git a/src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs b/src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs new file mode 100644 index 000000000..6dbf43f43 --- /dev/null +++ b/src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs @@ -0,0 +1,181 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class DbCommandRunner +{ + + private static void ValidateCheckpointName(string name) + { + if (string.IsNullOrWhiteSpace(name) + || name is "." or ".." + || name.IndexOfAny(InvalidCheckpointNameChars) >= 0 + || name.Contains(Path.DirectorySeparatorChar) + || (Path.AltDirectorySeparatorChar != '\0' && name.Contains(Path.AltDirectorySeparatorChar))) + throw new ArgumentException($"invalid checkpoint name: {FormatCheckpointNameForDiagnostic(name)}"); + + if (name.Length > MaxCheckpointNameLength) + throw new ArgumentException($"checkpoint name is too long ({name.Length} characters; max {MaxCheckpointNameLength}): {FormatCheckpointNameForDiagnostic(name)}"); + } + + private static string FormatCheckpointNameForDiagnostic(string name) + => ConsoleUi.FormatBoundedValue(name, CheckpointNameDiagnosticTextLimit); + + private static string MakeTimestampCheckpointName() + => GetUtcNow().ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture) + + "-" + + Guid.NewGuid().ToString("N"); + + private static string MakeRestorePathSuffix() + => GetUtcNow().ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture) + + "-" + + Guid.NewGuid().ToString("N"); + + private static DateTimeOffset GetUtcNow() + => UtcNowForTesting?.Invoke() ?? DateTimeOffset.UtcNow; + + private static string GetCheckpointRoot(string fullDbPath) + => fullDbPath + CheckpointsDirectorySuffix; + + private static string GetRestoreBackupDirectoryPrefix(string fullDbPath) + => Path.GetFileName(fullDbPath) + ".restore-backup-"; + + private static string GetCheckpointPath(string fullDbPath, string name) + { + ValidateCheckpointName(name); + return Path.Combine(GetCheckpointRoot(fullDbPath), name); + } + + private static void CopyIfExists(string source, string destination, bool privateDestination = false) + { + if (!TryGetRegularExistingFile(source, out var normalizedSource)) + return; + + if (!privateDestination || OperatingSystem.IsWindows()) + { + File.Copy(normalizedSource, LongPath.EnsureWindowsPrefix(destination), overwrite: false); + if (privateDestination) + DataDirectorySecurity.ApplyPrivateFileMode(destination); + return; + } + + using (var input = new FileStream(normalizedSource, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var output = new FileStream( + LongPath.EnsureWindowsPrefix(destination), + new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + UnixCreateMode = DataDirectorySecurity.PrivateFileMode, + })) + { + input.CopyTo(output); + output.Flush(flushToDisk: true); + } + + DataDirectorySecurity.ApplyPrivateFileMode(destination); + } + + private static void MoveIfExists(string source, string destination, bool privateDestination = false, bool overwrite = false) + { + if (!TryGetRegularExistingFile(source, out var normalizedSource)) + return; + + AtomicFileWriter.MoveFile( + normalizedSource, + destination, + overwrite, + privateDestination ? DataDirectorySecurity.ApplyPrivateFileMode : null); + } + + private static bool TryGetRegularExistingFile(string path, out string normalizedPath) + { + normalizedPath = LongPath.EnsureWindowsPrefix(path); + FileAttributes attributes; + try + { + attributes = File.GetAttributes(normalizedPath); + } + catch (FileNotFoundException) + { + return false; + } + catch (DirectoryNotFoundException) + { + return false; + } + + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) + throw new InvalidOperationException($"checkpoint file is not a regular file: {ConsoleUi.FormatBoundedValue(path)}"); + + return true; + } + + private static void RestoreBackedUpFiles(string fullDbPath, string backupPath) + { + if (!Directory.Exists(backupPath)) + return; + + MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath)), fullDbPath, privateDestination: true, overwrite: true); + MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal", privateDestination: true, overwrite: true); + MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"), fullDbPath + "-shm", privateDestination: true, overwrite: true); + } + + internal static void TryDeleteTemporaryDirectory(string path, string cleanupDescription, string safeRoot, string expectedNamePrefix) + { + try + { + if (!TryValidateTemporaryDirectoryCleanupTarget(path, safeRoot, expectedNamePrefix, out var fullPath, out var validationFailure)) + { + CommandErrorWriter.WriteStderr($"Warning: skipped deleting {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({validationFailure})."); + return; + } + + if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath))) + return; + + if (!TryValidateTemporaryDirectoryCleanupTarget(fullPath, safeRoot, expectedNamePrefix, out fullPath, out validationFailure)) + { + CommandErrorWriter.WriteStderr($"Warning: skipped deleting {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({validationFailure})."); + return; + } + + if (DeleteTemporaryDirectoryForTesting != null) + DeleteTemporaryDirectoryForTesting(fullPath); + else + Directory.Delete(LongPath.EnsureWindowsPrefix(fullPath), recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) + { + CommandErrorWriter.WriteWarning($"failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + } + } + + private static bool TryValidateTemporaryDirectoryCleanupTarget( + string path, + string safeRoot, + string expectedNamePrefix, + out string fullPath, + out string failureReason) + { + var options = new DirectoryCleanupBoundaryOptions( + expectedNamePrefix, + "target is outside the expected cleanup root", + "target name does not match the expected temporary-directory prefix", + "target is not a regular temporary directory"); + return FileSystemBoundary.TryValidateDirectoryCleanupTarget( + path, + safeRoot, + options, + out fullPath, + out failureReason); + } + +} diff --git a/src/CodeIndex/Cli/DbCommandRunner.Maintenance.cs b/src/CodeIndex/Cli/DbCommandRunner.Maintenance.cs new file mode 100644 index 000000000..3d0c4a5f5 --- /dev/null +++ b/src/CodeIndex/Cli/DbCommandRunner.Maintenance.cs @@ -0,0 +1,497 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class DbCommandRunner +{ + private static int RunPrune(DbCommandOptions options, JsonSerializerOptions jsonOptions, string dbPath, bool isUri, CancellationToken cancellationToken) + { + if (!options.PruneApply && !options.PruneDryRun) + return WriteCommandError( + options.Json, + jsonOptions, + "db prune requires --dry-run or --apply", + CommandExitCodes.UsageError, + "Use `cdidx db prune --dry-run` to inspect stale rows, then `cdidx db prune --apply` to delete them.", + CommandErrorCodes.UsageError); + + if (options.PruneApply && options.PruneDryRun) + return WriteCommandError( + options.Json, + jsonOptions, + "db prune accepts only one of --dry-run or --apply", + CommandExitCodes.UsageError, + "Choose `--dry-run` or `--apply`.", + CommandErrorCodes.UsageError); + + if (isUri && DbPathResolver.UriRequestsReadOnly(dbPath)) + return WriteCommandError( + options.Json, + jsonOptions, + $"database must be writable for prune: {dbPath}", + CommandExitCodes.DatabaseError, + "Point `--db` at a writable filesystem path, or omit read-only URI parameters such as `immutable=1` / `mode=ro`.", + CommandErrorCodes.DbNotWritable); + + try + { + ReportMaintenanceProgress("prune", "start", dbPath); + var result = PruneOrphans(dbPath, apply: options.PruneApply, cancellationToken); + ReportMaintenanceProgress("prune", "complete", dbPath); + var fullPath = DbPathResolver.FormatDbPathForDisplay(dbPath); + if (options.Json) + { + var jsonContext = CliJsonSerializerContextFactory.Create(jsonOptions); + Console.WriteLine(JsonSerializer.Serialize( + new DbPruneJsonResult( + "success", + fullPath, + options.PruneDryRun, + result.OrphanSymbolReferences, + result.OrphanReferenceLines, + result.OrphanSymbols, + result.Total, + result.Warnings), + jsonContext.DbPruneJsonResult)); + } + else + { + Console.WriteLine(options.PruneApply ? "Pruned database stale rows." : "Database prune dry run."); + Console.WriteLine($" database : {fullPath}"); + Console.WriteLine($" orphan symbol_references : {result.OrphanSymbolReferences:N0}"); + Console.WriteLine($" orphan reference_lines : {result.OrphanReferenceLines:N0}"); + Console.WriteLine($" orphan symbols : {result.OrphanSymbols:N0}"); + Console.WriteLine($" total : {result.Total:N0}"); + foreach (var warning in result.Warnings) + CommandErrorWriter.WriteStderr($"Warning [{warning.Code}]: {warning.Message}"); + } + + return CommandExitCodes.Success; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + if (JsonOutputFailure.TryHandle(ex, out var exitCode)) + return exitCode; + + return WriteCommandError( + options.Json, + jsonOptions, + $"failed to prune database: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}", + CommandExitCodes.DatabaseError, + "Ensure no other writer is holding the database lock, then retry `cdidx db prune --dry-run`.", + CommandErrorCodes.DbError); + } + } + + private static int RunCheckpoint(DbCommandOptions options, JsonSerializerOptions jsonOptions) + { + if (!ValidateWritableFileDb(options, jsonOptions, "checkpoint", out var fullDbPath, out var validationExitCode)) + return validationExitCode; + + try + { + if (options.CheckpointDryRun) + { + var preview = PreviewCheckpoint(fullDbPath, options.Name ?? MakeTimestampCheckpointName()); + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new DbCheckpointJsonResult( + "dry_run", + fullDbPath, + preview.Name, + preview.CheckpointPath, + preview.Files, + preview.FilesTruncated, + CheckpointFileInspectLimit, + preview.Diagnostics, + DryRun: true, + Bytes: preview.Bytes), + CliJsonSerializerContextFactory.Create(jsonOptions).DbCheckpointJsonResult)); + } + else + { + Console.WriteLine("Database checkpoint dry run."); + Console.WriteLine($" database : {fullDbPath}"); + Console.WriteLine($" name : {preview.Name}"); + Console.WriteLine($" checkpoint: {preview.CheckpointPath}"); + Console.WriteLine($" side effect: none (run without --dry-run to copy DB/WAL/SHM files)"); + Console.WriteLine($" files : {ConsoleUi.Counted(preview.Files.Count, "file")}{(preview.FilesTruncated ? " (truncated)" : string.Empty)}"); + Console.WriteLine($" bytes : {preview.Bytes:N0}"); + } + + foreach (var diagnostic in preview.Diagnostics) + CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); + + return CommandExitCodes.Success; + } + + var result = CreateCheckpoint(fullDbPath, options.Name ?? MakeTimestampCheckpointName()); + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new DbCheckpointJsonResult( + "success", + fullDbPath, + result.Name, + result.CheckpointPath, + result.Files, + result.FilesTruncated, + CheckpointFileInspectLimit, + result.Diagnostics, + Bytes: result.Bytes), + CliJsonSerializerContextFactory.Create(jsonOptions).DbCheckpointJsonResult)); + } + else + { + Console.WriteLine("Created database checkpoint."); + Console.WriteLine($" database : {fullDbPath}"); + Console.WriteLine($" name : {result.Name}"); + Console.WriteLine($" checkpoint: {result.CheckpointPath}"); + Console.WriteLine($" files : {ConsoleUi.Counted(result.Files.Count, "file")}{(result.FilesTruncated ? " (truncated)" : string.Empty)}"); + Console.WriteLine($" bytes : {result.Bytes:N0}"); + } + + foreach (var diagnostic in result.Diagnostics) + CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); + + return CommandExitCodes.Success; + } + catch (Exception ex) + { + var isInputError = ex is ArgumentException; + var safeMessage = isInputError + ? CommandErrorWriter.FormatSanitizedExceptionMessage(ex) + : $"failed to create database checkpoint: {CommandErrorWriter.FormatSanitizedException(ex)}"; + return WriteCommandError( + options.Json, + jsonOptions, + safeMessage, + isInputError ? CommandExitCodes.UsageError : CommandExitCodes.DatabaseError, + isInputError + ? $"Use a non-blank single file name of at most {MaxCheckpointNameLength} characters; do not use `.` or `..`, directory separators, or characters invalid in file names on this operating system." + : "Ensure the database and checkpoint directory are writable, then retry `cdidx db checkpoint`.", + isInputError ? CommandErrorCodes.UsageError : CommandErrorCodes.DbError, + category: isInputError ? null : DiagnosticRedactor.ClassifyException(ex)); + } + } + + private static int RunCheckpoints(DbCommandOptions options, JsonSerializerOptions jsonOptions) + { + var actionCount = (options.CheckpointsList ? 1 : 0) + + (options.CheckpointsDelete ? 1 : 0) + + (options.CheckpointsPrune ? 1 : 0); + if (actionCount == 0) + return WriteCommandError( + options.Json, + jsonOptions, + "checkpoints requires --list, --delete , or --prune --keep ", + CommandExitCodes.UsageError, + "Use `cdidx db checkpoints --list`, `cdidx db checkpoints --delete [--dry-run]`, or `cdidx db checkpoints --prune --keep [--dry-run]`.", + CommandErrorCodes.UsageError); + if (actionCount > 1) + return WriteCommandError( + options.Json, + jsonOptions, + "checkpoints accepts exactly one of --list, --delete, or --prune", + CommandExitCodes.UsageError, + "Choose one checkpoint maintenance action.", + CommandErrorCodes.UsageError); + + if (!TryResolveFileDb(options.DbPath, out var fullDbPath, out var error)) + return WriteCommandError(options.Json, jsonOptions, error, CommandExitCodes.DatabaseError, "Use a filesystem database path, not a SQLite URI.", CommandErrorCodes.DbError); + + if (options.CheckpointsDelete) + return RunDeleteCheckpoint(options, jsonOptions, fullDbPath); + if (options.CheckpointsPrune) + return RunPruneCheckpoints(options, jsonOptions, fullDbPath); + + var result = ListCheckpoints(fullDbPath, CheckpointListEntryLimit); + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new DbCheckpointListJsonResult( + fullDbPath, + result.Entries, + result.Truncated, + CheckpointListEntryLimit, + CheckpointFileInspectLimit, + result.Diagnostics), + CliJsonSerializerContextFactory.Create(jsonOptions).DbCheckpointListJsonResult)); + } + else + { + Console.WriteLine("Database checkpoints"); + Console.WriteLine($" database: {fullDbPath}"); + if (result.Truncated) + Console.WriteLine($" truncated: yes (checkpoint directory limit {CheckpointListEntryLimit:N0}, file limit {CheckpointFileInspectLimit:N0} per checkpoint)"); + if (result.Entries.Count == 0) + { + Console.WriteLine(" checkpoints: none"); + } + else + { + foreach (var entry in result.Entries) + Console.WriteLine($" {entry.Name} {entry.CreatedAtUtc} {entry.Bytes:N0} bytes{(entry.FilesTruncated ? " (files truncated)" : string.Empty)}"); + } + + foreach (var diagnostic in result.Diagnostics) + CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); + } + + return CommandExitCodes.Success; + } + + private static int RunDeleteCheckpoint( + DbCommandOptions options, + JsonSerializerOptions jsonOptions, + string fullDbPath) + { + if (string.IsNullOrWhiteSpace(options.Name)) + return WriteCommandError( + options.Json, + jsonOptions, + "checkpoint deletion requires a checkpoint name", + CommandExitCodes.UsageError, + "Use `cdidx db checkpoints --delete [--dry-run]`.", + CommandErrorCodes.UsageError); + + string checkpointPath; + try + { + checkpointPath = GetCheckpointPath(fullDbPath, options.Name); + } + catch (ArgumentException ex) + { + return WriteCommandError( + options.Json, + jsonOptions, + CommandErrorWriter.FormatSanitizedExceptionMessage(ex), + CommandExitCodes.UsageError, + $"Use a non-blank single file name of at most {MaxCheckpointNameLength} characters.", + CommandErrorCodes.UsageError); + } + + if (!Directory.Exists(LongPath.EnsureWindowsPrefix(checkpointPath))) + return WriteCommandError( + options.Json, + jsonOptions, + $"checkpoint not found: {FormatCheckpointNameForDiagnostic(options.Name)}", + CommandExitCodes.NotFound, + "Run `cdidx db checkpoints --list` to see available checkpoints.", + CommandErrorCodes.CheckpointNotFound); + + var diagnostics = new List(); + var deletedPaths = new List(); + var retainedPaths = new List(); + if (options.CheckpointsDryRun) + { + if (TryValidateCheckpointDirectoryTarget(fullDbPath, checkpointPath, out _, out var validationFailure)) + deletedPaths.Add(checkpointPath); + else + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_delete_skipped", + $"Checkpoint deletion would be skipped: {validationFailure}.", + ConsoleUi.FormatBoundedValue(checkpointPath))); + retainedPaths.Add(checkpointPath); + } + } + else if (TryDeleteCheckpointDirectory(fullDbPath, checkpointPath, diagnostics)) + { + deletedPaths.Add(checkpointPath); + } + else + { + retainedPaths.Add(checkpointPath); + } + + var failed = retainedPaths.Count > 0; + var result = new DbCheckpointCleanupResult( + deletedPaths.Count, + retainedPaths.Count, + deletedPaths, + retainedPaths, + Truncated: false, + diagnostics); + WriteCheckpointCleanupResult( + options, + jsonOptions, + fullDbPath, + "delete", + options.Name, + keep: null, + result, + status: failed ? "error" : options.CheckpointsDryRun ? "dry_run" : "success"); + return failed ? CommandExitCodes.DatabaseError : CommandExitCodes.Success; + } + + private static int RunPruneCheckpoints( + DbCommandOptions options, + JsonSerializerOptions jsonOptions, + string fullDbPath) + { + var listed = ListCheckpoints(fullDbPath, CheckpointPruneScanLimit); + var diagnostics = listed.Diagnostics; + if (listed.DirectoryEnumerationTruncated) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_prune_truncated", + "Checkpoint pruning was skipped because checkpoint enumeration reached the scan limit.", + ConsoleUi.FormatBoundedValue(GetCheckpointRoot(fullDbPath)))); + var skipped = new DbCheckpointCleanupResult( + Deleted: 0, + Retained: listed.Entries.Count, + DeletedPaths: [], + RetainedPaths: listed.Entries.Select(entry => entry.CheckpointPath).ToList(), + Truncated: true, + diagnostics); + WriteCheckpointCleanupResult( + options, + jsonOptions, + fullDbPath, + "prune", + name: null, + options.CheckpointsKeep, + skipped, + status: options.CheckpointsDryRun ? "dry_run" : "success"); + return CommandExitCodes.Success; + } + + var retainableEntries = listed.Entries + .Select(entry => new + { + Entry = entry, + Retainable = TryGetCheckpointRetentionTimestamp( + fullDbPath, + entry.Name, + entry.CheckpointPath, + diagnostics, + out var createdAtUtc), + CreatedAtUtc = createdAtUtc, + }) + .Where(candidate => candidate.Retainable) + .OrderByDescending(candidate => candidate.CreatedAtUtc) + .ThenBy(candidate => candidate.Entry.Name, StringComparer.Ordinal) + .Select(candidate => candidate.Entry) + .ToList(); + var retainedPaths = retainableEntries + .Take(options.CheckpointsKeep) + .Select(entry => entry.CheckpointPath) + .ToList(); + var retainedPathSet = retainedPaths.ToHashSet(StringComparer.Ordinal); + var candidatePaths = listed.Entries + .Where(entry => !retainedPathSet.Contains(entry.CheckpointPath)) + .Select(entry => entry.CheckpointPath) + .ToList(); + var deletedPaths = new List(); + if (options.CheckpointsDryRun) + { + foreach (var path in candidatePaths) + { + if (TryValidateCheckpointDirectoryTarget(fullDbPath, path, out _, out var validationFailure)) + deletedPaths.Add(path); + else + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_delete_skipped", + $"Checkpoint deletion would be skipped: {validationFailure}.", + ConsoleUi.FormatBoundedValue(path))); + retainedPaths.Add(path); + } + } + } + else + { + foreach (var path in candidatePaths) + { + if (TryDeleteCheckpointDirectory(fullDbPath, path, diagnostics)) + deletedPaths.Add(path); + else + retainedPaths.Add(path); + } + } + + var result = new DbCheckpointCleanupResult( + deletedPaths.Count, + retainedPaths.Count, + deletedPaths, + retainedPaths, + listed.Truncated, + diagnostics); + WriteCheckpointCleanupResult( + options, + jsonOptions, + fullDbPath, + "prune", + name: null, + options.CheckpointsKeep, + result, + status: options.CheckpointsDryRun ? "dry_run" : "success"); + return CommandExitCodes.Success; + } + + private static void WriteCheckpointCleanupResult( + DbCommandOptions options, + JsonSerializerOptions jsonOptions, + string fullDbPath, + string operation, + string? name, + int? keep, + DbCheckpointCleanupResult result, + string status) + { + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new DbCheckpointCleanupJsonResult( + status, + fullDbPath, + operation, + name, + keep, + options.CheckpointsDryRun, + result.Deleted, + result.Retained, + result.DeletedPaths, + result.RetainedPaths, + result.Truncated, + CheckpointPruneScanLimit, + result.Diagnostics), + CliJsonSerializerContextFactory.Create(jsonOptions).DbCheckpointCleanupJsonResult)); + return; + } + + Console.WriteLine(options.CheckpointsDryRun + ? "Database checkpoint cleanup dry run." + : "Database checkpoint cleanup complete."); + Console.WriteLine($" database : {fullDbPath}"); + Console.WriteLine($" operation: {operation}"); + if (name is not null) + Console.WriteLine($" name : {name}"); + if (keep is not null) + Console.WriteLine($" keep : {keep.Value:N0}"); + Console.WriteLine($" side effect: {(options.CheckpointsDryRun ? "none" : "requested checkpoint directories removed")}"); + Console.WriteLine($" {(options.CheckpointsDryRun ? "would delete" : "deleted"),-12}: {result.Deleted:N0}"); + foreach (var path in result.DeletedPaths) + Console.WriteLine($" {path}"); + Console.WriteLine($" retained : {result.Retained:N0}"); + foreach (var path in result.RetainedPaths) + Console.WriteLine($" {path}"); + if (result.Truncated) + Console.WriteLine($" truncated: yes (checkpoint scan limit {CheckpointPruneScanLimit:N0})"); + foreach (var diagnostic in result.Diagnostics) + CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); + } + +} diff --git a/src/CodeIndex/Cli/DbCommandRunner.Restore.cs b/src/CodeIndex/Cli/DbCommandRunner.Restore.cs new file mode 100644 index 000000000..8c486821e --- /dev/null +++ b/src/CodeIndex/Cli/DbCommandRunner.Restore.cs @@ -0,0 +1,265 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class DbCommandRunner +{ + private static int RunRestore(DbCommandOptions options, JsonSerializerOptions jsonOptions) + { + if (string.IsNullOrWhiteSpace(options.Name)) + return WriteCommandError(options.Json, jsonOptions, "restore requires a checkpoint name", CommandExitCodes.UsageError, "Use `cdidx db restore --db `.", CommandErrorCodes.UsageError); + if (!ValidateWritableFileDb(options, jsonOptions, "restore", out var fullDbPath, out var validationExitCode)) + return validationExitCode; + + var checkpointPath = string.Empty; + try + { + checkpointPath = GetCheckpointPath(fullDbPath, options.Name); + if (!Directory.Exists(checkpointPath)) + return WriteCommandError(options.Json, jsonOptions, $"checkpoint not found: {FormatCheckpointNameForDiagnostic(options.Name)}", CommandExitCodes.NotFound, "Run `cdidx db checkpoints --list` to see available checkpoints.", CommandErrorCodes.CheckpointNotFound); + + var preview = PreviewRestoreCheckpoint(fullDbPath, options.Name, checkpointPath); + if (options.RestoreDryRun) + return WriteRestoreDryRunResult(options, jsonOptions, fullDbPath, options.Name, checkpointPath, preview); + if (!preview.Ready) + throw new InvalidOperationException("checkpoint validation failed"); + + var backupPath = RestoreCheckpoint(fullDbPath, options.Name, checkpointPath); + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new DbRestoreJsonResult("success", fullDbPath, options.Name, checkpointPath, backupPath), + CliJsonSerializerContextFactory.Create(jsonOptions).DbRestoreJsonResult)); + } + else + { + Console.WriteLine("Restored database checkpoint."); + Console.WriteLine($" database : {fullDbPath}"); + Console.WriteLine($" checkpoint: {options.Name}"); + Console.WriteLine($" backup : {backupPath}"); + } + + return CommandExitCodes.Success; + } + catch (DbRestoreOperationException ex) + { + return WriteRestoreError(options, jsonOptions, fullDbPath, options.Name, checkpointPath, ex); + } + catch (Exception ex) + { + return WriteCommandError( + options.Json, + jsonOptions, + $"failed to restore database checkpoint: {CommandErrorWriter.FormatSanitizedException(ex)}", + CommandExitCodes.DatabaseError, + "Ensure no cdidx writer is running, then retry `cdidx db restore `.", + CommandErrorCodes.DbError, + category: DiagnosticRedactor.ClassifyException(ex)); + } + } + + private static int WriteRestoreDryRunResult( + DbCommandOptions options, + JsonSerializerOptions jsonOptions, + string fullDbPath, + string name, + string checkpointPath, + DbRestorePreviewResult preview) + { + const string hint = "Fix the reported checkpoint, path, or free-space diagnostics before running without --dry-run."; + var message = preview.Ready + ? null + : "database restore dry run found blocking validation failures"; + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new DbRestoreDryRunJsonResult( + preview.Ready ? "dry_run" : "invalid", + fullDbPath, + name, + checkpointPath, + DryRun: true, + preview.Ready, + preview.ManifestValid, + preview.PathsValid, + preview.SpaceCheckAvailable, + preview.SpaceSufficient, + preview.RequiredSpaceBytes, + preview.AvailableSpaceBytes, + preview.Files, + preview.Bytes, + preview.Diagnostics, + message, + preview.Ready ? null : CommandErrorCodes.DbError, + preview.Ready ? null : hint), + CliJsonSerializerContextFactory.Create(jsonOptions).DbRestoreDryRunJsonResult)); + } + else + { + Console.WriteLine("Database restore dry run."); + Console.WriteLine($" database : {fullDbPath}"); + Console.WriteLine($" checkpoint: {checkpointPath}"); + Console.WriteLine(" side effect: none (run without --dry-run to replace the DB)"); + Console.WriteLine($" manifest : {(preview.ManifestValid ? "valid" : "invalid")}"); + Console.WriteLine($" paths : {(preview.PathsValid ? "valid" : "invalid")}"); + Console.WriteLine($" bytes : {preview.Bytes:N0}"); + Console.WriteLine($" available : {(preview.AvailableSpaceBytes is long available ? available.ToString("N0", System.Globalization.CultureInfo.CurrentCulture) : "unknown")}"); + Console.WriteLine($" space : {(preview.SpaceSufficient is true ? "sufficient" : preview.SpaceSufficient is false ? "insufficient" : "unknown")}"); + Console.WriteLine($" ready : {(preview.Ready ? "yes" : "no")}"); + foreach (var diagnostic in preview.Diagnostics) + CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); + if (!preview.Ready) + CommandErrorWriter.WriteStderr($"Error: {message}. Hint: {hint}"); + } + + return preview.Ready ? CommandExitCodes.Success : CommandExitCodes.DatabaseError; + } + + private static int WriteRestoreError( + DbCommandOptions options, + JsonSerializerOptions jsonOptions, + string fullDbPath, + string name, + string checkpointPath, + DbRestoreOperationException ex) + { + var primary = ex.InnerException ?? ex; + var message = $"failed to restore database checkpoint: {CommandErrorWriter.FormatSanitizedException(primary)}"; + const string hint = "Ensure no cdidx writer is running, then retry `cdidx db restore `."; + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new DbRestoreJsonResult( + "error", + fullDbPath, + name, + string.IsNullOrWhiteSpace(ex.CheckpointPath) ? checkpointPath : ex.CheckpointPath, + ex.BackupPath, + message, + CommandErrorCodes.DbError, + hint, + ex.RollbackFailure is not null, + ex.RollbackFailure), + CliJsonSerializerContextFactory.Create(jsonOptions).DbRestoreJsonResult)); + return CommandExitCodes.DatabaseError; + } + + return WriteCommandError( + false, + jsonOptions, + message, + CommandExitCodes.DatabaseError, + hint, + CommandErrorCodes.DbError, + category: DiagnosticRedactor.ClassifyException(primary)); + } + + private static int RunRestoreBackups(DbCommandOptions options, JsonSerializerOptions jsonOptions) + { + if (!options.RestoreBackupsList && !options.RestoreBackupsPrune) + return WriteCommandError( + options.Json, + jsonOptions, + "restore-backups requires --list or --prune", + CommandExitCodes.UsageError, + "Use `cdidx db restore-backups --list` or `cdidx db restore-backups --prune --keep `.", + CommandErrorCodes.UsageError); + + if (options.RestoreBackupsList && options.RestoreBackupsPrune) + return WriteCommandError( + options.Json, + jsonOptions, + "restore-backups accepts only one of --list or --prune", + CommandExitCodes.UsageError, + "Choose `--list` or `--prune`.", + CommandErrorCodes.UsageError); + + if (!TryResolveFileDb(options.DbPath, out var fullDbPath, out var error)) + return WriteCommandError(options.Json, jsonOptions, error, CommandExitCodes.DatabaseError, "Use a filesystem database path, not a SQLite URI.", CommandErrorCodes.DbError); + + if (options.RestoreBackupsList) + { + var result = ListRestoreBackups(fullDbPath, RestoreBackupListEntryLimit); + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new DbRestoreBackupListJsonResult( + fullDbPath, + result.Entries, + result.Truncated, + RestoreBackupListEntryLimit, + CheckpointFileInspectLimit, + result.Diagnostics), + CliJsonSerializerContextFactory.Create(jsonOptions).DbRestoreBackupListJsonResult)); + } + else + { + Console.WriteLine("Database restore backups"); + Console.WriteLine($" database: {fullDbPath}"); + if (result.Truncated) + Console.WriteLine($" truncated: yes (restore backup limit {RestoreBackupListEntryLimit:N0}, file limit {CheckpointFileInspectLimit:N0} per backup)"); + if (result.Entries.Count == 0) + { + Console.WriteLine(" backups: none"); + } + else + { + foreach (var entry in result.Entries) + Console.WriteLine($" {entry.Name} {entry.CreatedAtUtc} {entry.Bytes:N0} bytes{(entry.FilesTruncated ? " (files truncated)" : string.Empty)}"); + } + + foreach (var diagnostic in result.Diagnostics) + Console.Error.WriteLine($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); + } + + return CommandExitCodes.Success; + } + + var pruneResult = PruneRestoreBackups(fullDbPath, options.RestoreBackupsKeep, options.RestoreBackupsDryRun); + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new DbRestoreBackupPruneJsonResult( + options.RestoreBackupsDryRun ? "dry_run" : "success", + fullDbPath, + options.RestoreBackupsKeep, + options.RestoreBackupsDryRun, + pruneResult.Deleted, + pruneResult.Retained, + pruneResult.DeletedPaths, + pruneResult.RetainedPaths, + pruneResult.Truncated, + RestoreBackupPruneScanLimit, + pruneResult.Diagnostics), + CliJsonSerializerContextFactory.Create(jsonOptions).DbRestoreBackupPruneJsonResult)); + } + else + { + Console.WriteLine(options.RestoreBackupsDryRun + ? "Database restore backup prune dry run." + : "Pruned database restore backups."); + Console.WriteLine($" database: {fullDbPath}"); + Console.WriteLine($" keep : {options.RestoreBackupsKeep:N0}"); + Console.WriteLine($" side effect: {(options.RestoreBackupsDryRun ? "none" : "older restore backup directories removed")}"); + Console.WriteLine($" {(options.RestoreBackupsDryRun ? "would delete" : "deleted"),-12}: {pruneResult.Deleted:N0}"); + foreach (var path in pruneResult.DeletedPaths) + Console.WriteLine($" {path}"); + Console.WriteLine($" retained: {pruneResult.Retained:N0}"); + foreach (var path in pruneResult.RetainedPaths) + Console.WriteLine($" {path}"); + if (pruneResult.Truncated) + Console.WriteLine($" truncated: yes (restore backup scan limit {RestoreBackupPruneScanLimit:N0})"); + foreach (var diagnostic in pruneResult.Diagnostics) + Console.Error.WriteLine($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); + } + + return CommandExitCodes.Success; + } + +} diff --git a/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs b/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs new file mode 100644 index 000000000..7ac2cad3b --- /dev/null +++ b/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs @@ -0,0 +1,429 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class DbCommandRunner +{ + private static DbRestorePreviewResult PreviewRestoreCheckpoint( + string fullDbPath, + string name, + string checkpointPath) + { + ValidateCheckpointName(name); + var diagnostics = new List(); + var pathsValid = TryValidateCheckpointDirectoryTarget( + fullDbPath, + checkpointPath, + out var validatedCheckpointPath, + out var checkpointPathFailure); + if (!pathsValid) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_path_invalid", + $"Checkpoint directory failed path validation: {checkpointPathFailure}.", + ConsoleUi.FormatBoundedValue(checkpointPath))); + } + + var manifestValid = false; + var payload = new DbCheckpointPayloadValidationResult( + PathsValid: false, + Files: [], + Bytes: 0); + if (pathsValid) + { + manifestValid = TryValidateCheckpointManifest( + fullDbPath, + name, + validatedCheckpointPath, + diagnostics, + out _); + payload = ValidateCheckpointPayload( + fullDbPath, + validatedCheckpointPath, + diagnostics); + pathsValid = payload.PathsValid; + } + + var availableSpace = TryGetAvailableFreeSpace(fullDbPath, diagnostics); + bool? spaceSufficient = availableSpace is long available ? available >= payload.Bytes : null; + if (spaceSufficient == false) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_space_insufficient", + "The destination filesystem does not have enough free space to stage the checkpoint payload.", + ConsoleUi.FormatBoundedValue(Path.GetDirectoryName(fullDbPath) ?? fullDbPath))); + } + + var ready = manifestValid && pathsValid && spaceSufficient == true; + return new DbRestorePreviewResult( + ready, + manifestValid, + pathsValid, + availableSpace.HasValue, + spaceSufficient, + payload.Bytes, + availableSpace, + payload.Files, + payload.Bytes, + diagnostics); + } + + private static bool TryGetCheckpointRetentionTimestamp( + string fullDbPath, + string name, + string checkpointPath, + List diagnostics, + out DateTimeOffset createdAtUtc) + { + createdAtUtc = default; + if (!TryValidateCheckpointDirectoryTarget( + fullDbPath, + checkpointPath, + out var validatedCheckpointPath, + out var checkpointPathFailure)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_retention_invalid", + $"Checkpoint cannot occupy a retention slot because its directory is unsafe: {checkpointPathFailure}.", + ConsoleUi.FormatBoundedValue(checkpointPath))); + return false; + } + + var manifestValid = TryValidateCheckpointManifest( + fullDbPath, + name, + validatedCheckpointPath, + diagnostics, + out createdAtUtc); + var payload = ValidateCheckpointPayload( + fullDbPath, + validatedCheckpointPath, + diagnostics); + if (manifestValid && payload.PathsValid) + return true; + + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_retention_invalid", + "Checkpoint cannot occupy a retention slot because restore validation failed.", + ConsoleUi.FormatBoundedValue(checkpointPath))); + return false; + } + + private static DbCheckpointPayloadValidationResult ValidateCheckpointPayload( + string fullDbPath, + string checkpointPath, + List diagnostics) + { + var pathsValid = true; + var files = new List(); + long bytes = 0; + var dbFileName = Path.GetFileName(fullDbPath); + foreach (var fileName in new[] { dbFileName, dbFileName + "-wal", dbFileName + "-shm" }) + { + var path = Path.Combine(checkpointPath, fileName); + try + { + if (!TryGetRegularExistingFile(path, out var normalizedPath)) + { + if (string.Equals(fileName, dbFileName, StringComparison.Ordinal)) + { + pathsValid = false; + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_payload_missing", + "Checkpoint database payload is missing.", + ConsoleUi.FormatBoundedValue(path))); + } + + continue; + } + + files.Add(fileName); + bytes = checked(bytes + new FileInfo(normalizedPath).Length); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException or OverflowException) + { + pathsValid = false; + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_payload_invalid", + $"Checkpoint payload failed regular-file validation ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(path))); + } + } + + files.Sort(StringComparer.Ordinal); + return new DbCheckpointPayloadValidationResult(pathsValid, files, bytes); + } + + private static bool TryValidateCheckpointManifest( + string fullDbPath, + string name, + string checkpointPath, + List diagnostics, + out DateTimeOffset createdAtUtc) + { + createdAtUtc = default; + var manifestPath = Path.Combine(checkpointPath, "manifest.txt"); + try + { + if (!TryGetRegularExistingFile(manifestPath, out var normalizedManifestPath)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_manifest_missing", + "Checkpoint manifest is missing.", + ConsoleUi.FormatBoundedValue(manifestPath))); + return false; + } + + var length = new FileInfo(normalizedManifestPath).Length; + if (length > CheckpointManifestByteLimit) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_manifest_too_large", + $"Checkpoint manifest exceeds the {CheckpointManifestByteLimit:N0}-byte validation limit.", + ConsoleUi.FormatBoundedValue(manifestPath))); + return false; + } + + var values = new Dictionary(StringComparer.Ordinal); + using var reader = new StringReader(File.ReadAllText(normalizedManifestPath)); + while (reader.ReadLine() is { } line) + { + if (line.Length == 0) + continue; + var separator = line.IndexOf('='); + if (separator <= 0 || !values.TryAdd(line[..separator], line[(separator + 1)..])) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_manifest_invalid", + "Checkpoint manifest contains a malformed or duplicate field.", + ConsoleUi.FormatBoundedValue(manifestPath))); + return false; + } + } + + var expectedDbFile = Path.GetFileName(fullDbPath); + var valid = values.TryGetValue("name", out var manifestName) + && string.Equals(manifestName, name, StringComparison.Ordinal) + && values.TryGetValue("db_file", out var manifestDbFile) + && string.Equals(manifestDbFile, expectedDbFile, StringComparison.Ordinal) + && string.Equals(Path.GetFileName(manifestDbFile), manifestDbFile, StringComparison.Ordinal) + && values.TryGetValue("created_at_utc", out var createdAt) + && DateTimeOffset.TryParse( + createdAt, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, + out createdAtUtc); + if (valid) + return true; + + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_manifest_invalid", + "Checkpoint manifest name, database file, or UTC timestamp does not match the requested restore.", + ConsoleUi.FormatBoundedValue(manifestPath))); + return false; + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex) || ex is InvalidOperationException) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_manifest_invalid", + $"Checkpoint manifest could not be validated ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(manifestPath))); + return false; + } + } + + private static long? TryGetAvailableFreeSpace( + string fullDbPath, + List diagnostics) + { + try + { + var destinationDirectory = Path.GetDirectoryName(fullDbPath); + if (string.IsNullOrWhiteSpace(destinationDirectory)) + throw new IOException("destination filesystem directory is unavailable"); + var resolvedDestinationDirectory = ResolveDestinationDirectoryForSpaceProbe(destinationDirectory); + + if (AvailableFreeSpaceForTesting is not null) + return AvailableFreeSpaceForTesting(resolvedDestinationDirectory); + + if (OperatingSystem.IsWindows()) + { + if (!GetDiskFreeSpaceEx( + resolvedDestinationDirectory, + out var availableBytes, + out _, + out _)) + { + throw new IOException( + "destination filesystem volume is unavailable", + new System.ComponentModel.Win32Exception(Marshal.GetLastPInvokeError())); + } + + return availableBytes > long.MaxValue + ? long.MaxValue + : (long)availableBytes; + } + + DriveInfo? destinationDrive = null; + var destinationRootLength = -1; + foreach (var drive in DriveInfo.GetDrives()) + { + try + { + if (!drive.IsReady) + continue; + var driveRoot = drive.RootDirectory.FullName; + if (driveRoot.Length <= destinationRootLength + || !IsPathWithinDriveRoot(driveRoot, resolvedDestinationDirectory)) + { + continue; + } + + destinationDrive = drive; + destinationRootLength = driveRoot.Length; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + // Ignore an unreadable unrelated mount and keep looking for the + // longest ready mount that contains the destination directory. + } + } + + if (destinationDrive is null) + throw new IOException("destination filesystem volume is unavailable"); + return destinationDrive.AvailableFreeSpace; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "checkpoint_space_unavailable", + $"Available destination space could not be determined ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(Path.GetDirectoryName(fullDbPath) ?? fullDbPath))); + return null; + } + } + + private static bool IsPathWithinDriveRoot(string driveRoot, string path) + { + var normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(driveRoot)); + var normalizedPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (string.Equals(normalizedRoot, normalizedPath, comparison)) + return true; + + var rootWithSeparator = normalizedRoot.EndsWith(Path.DirectorySeparatorChar) + || normalizedRoot.EndsWith(Path.AltDirectorySeparatorChar) + ? normalizedRoot + : normalizedRoot + Path.DirectorySeparatorChar; + return normalizedPath.StartsWith(rootWithSeparator, comparison); + } + + private static string ResolveDestinationDirectoryForSpaceProbe(string destinationDirectory) + { + var fullPath = Path.GetFullPath(destinationDirectory); + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrWhiteSpace(root)) + throw new IOException("destination filesystem root is unavailable"); + + var current = root; + var relativePath = fullPath[root.Length..]; + foreach (var segment in relativePath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + var target = new DirectoryInfo(current).ResolveLinkTarget(returnFinalTarget: true); + if (target is not null) + current = target.FullName; + } + + return Path.GetFullPath(current); + } + + [DllImport("kernel32.dll", EntryPoint = "GetDiskFreeSpaceExW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetDiskFreeSpaceEx( + string directoryName, + out ulong freeBytesAvailable, + out ulong totalNumberOfBytes, + out ulong totalNumberOfFreeBytes); + + private static string RestoreCheckpoint(string fullDbPath, string name, string checkpointPath) + { + ValidateCheckpointName(name); + if (!TryValidateCheckpointDirectoryTarget( + fullDbPath, + checkpointPath, + out checkpointPath, + out var checkpointPathFailure)) + { + throw new InvalidOperationException( + $"checkpoint path validation failed: {checkpointPathFailure}"); + } + + SqliteConnection.ClearAllPools(); + var checkpointDbPath = Path.Combine(checkpointPath, Path.GetFileName(fullDbPath)); + if (!File.Exists(LongPath.EnsureWindowsPrefix(checkpointDbPath))) + throw new InvalidOperationException($"checkpoint is incomplete: {FormatCheckpointNameForDiagnostic(name)}"); + + var restorePathSuffix = MakeRestorePathSuffix(); + var restoreTempPath = fullDbPath + ".restore-tmp-" + restorePathSuffix; + var backupPath = fullDbPath + ".restore-backup-" + restorePathSuffix; + DataDirectorySecurity.CreateSensitiveDirectory(restoreTempPath); + try + { + CopyIfExists(checkpointDbPath, Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)), privateDestination: true); + CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-wal"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true); + CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-shm"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true); + if (!File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath))))) + throw new InvalidOperationException($"checkpoint staging failed: {FormatCheckpointNameForDiagnostic(name)}"); + + DataDirectorySecurity.CreateSensitiveDirectory(backupPath); + MoveIfExists(fullDbPath, Path.Combine(backupPath, Path.GetFileName(fullDbPath)), privateDestination: true); + MoveIfExists(fullDbPath + "-wal", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true); + MoveIfExists(fullDbPath + "-shm", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true); + + RestoreFailureAfterBackupForTesting?.Invoke(); + + MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)), fullDbPath, privateDestination: true); + MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal", privateDestination: true); + MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm"), fullDbPath + "-shm", privateDestination: true); + } + catch (Exception primaryEx) + { + DbDiagnosticJsonResult? rollbackFailure = null; + try + { + RestoreBackedUpFiles(fullDbPath, backupPath); + } + catch (Exception rollbackEx) when (IsRecoverableRestoreException(rollbackEx)) + { + rollbackFailure = new DbDiagnosticJsonResult( + "restore_rollback_failed", + $"Failed to roll back database restore from backup ({CommandErrorWriter.FormatSanitizedException(rollbackEx)}).", + ConsoleUi.FormatBoundedValue(backupPath)); + CommandErrorWriter.WriteStderr($"Warning [{rollbackFailure.Code}]: {rollbackFailure.Message} Backup: {rollbackFailure.Path}"); + } + + throw new DbRestoreOperationException(primaryEx, checkpointPath, backupPath, rollbackFailure); + } + finally + { + TryDeleteTemporaryDirectory( + restoreTempPath, + "restore temporary directory", + Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."), + Path.GetFileName(fullDbPath) + ".restore-tmp-"); + } + + return backupPath; + } +} diff --git a/src/CodeIndex/Cli/DbCommandRunner.Schema.cs b/src/CodeIndex/Cli/DbCommandRunner.Schema.cs new file mode 100644 index 000000000..bdf6143ec --- /dev/null +++ b/src/CodeIndex/Cli/DbCommandRunner.Schema.cs @@ -0,0 +1,428 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class DbCommandRunner +{ + // PRAGMA integrity_check returns a single row `"ok"` when the file passes every consistency + // probe, otherwise it returns up to N rows of corruption findings. The pragma itself only + // reads the database, so a read-only connection is sufficient and avoids the WAL-mode + // pragma side effects of the normal DbContext open path. + // PRAGMA integrity_check は問題が無ければ 1 行の `"ok"` を、破損があれば最大 N 行の検出結果を返す。 + // 読み取りのみのため read-only 接続で十分で、DbContext の WAL モード設定副作用を避けられる。 + private static DbIntegrityCheckReadResult RunIntegrityCheckPragma(string dbPath, CancellationToken cancellationToken) + { + if (IntegrityCheckRowsForTesting != null) + return BoundIntegrityRows(IntegrityCheckRowsForTesting(), cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + using var connection = DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( + dbPath, + pooling: false, + out _, + out _); + ReportMaintenanceProgress("integrity_check", "open_connection", dbPath); + connection.Open(); + ApplyBusyTimeout(connection, cancellationToken); + using var cmd = SqliteConnectionPolicy.CreateCommand(connection, $"PRAGMA integrity_check({IntegrityCheckRowLimit + 1})"); + ReportMaintenanceProgress("integrity_check", "read_rows", dbPath); + cancellationToken.ThrowIfCancellationRequested(); + using var reader = cmd.ExecuteReader(); + var rows = new List(); + var rowsTruncated = false; + var textTruncated = false; + while (reader.Read()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (rows.Count >= IntegrityCheckRowLimit) + { + rowsTruncated = true; + break; + } + + var raw = reader.IsDBNull(0) ? string.Empty : reader.GetString(0); + var bounded = TruncateDiagnosticText(raw, IntegrityCheckTextLimit); + textTruncated |= bounded.Truncated; + rows.Add(bounded.Text); + } + return new DbIntegrityCheckReadResult(rows.Count > 0 ? rows : new List { "ok" }, rowsTruncated, textTruncated); + } + + private static DbSchemaReadResult ReadSchema(string dbPath, DbCommandOptions options, CancellationToken cancellationToken) + { + using var connection = OpenConnection(dbPath, writable: false, cancellationToken); + ReportMaintenanceProgress("schema", "read_version", dbPath); + cancellationToken.ThrowIfCancellationRequested(); + using var versionCmd = connection.CreateCommand(); + versionCmd.CommandText = "PRAGMA user_version"; + var rawVersion = versionCmd.ExecuteScalar(); + var userVersion = rawVersion is long l ? (int)l : (rawVersion is int i ? i : 0); + cancellationToken.ThrowIfCancellationRequested(); + ReportMaintenanceProgress("schema", "count_objects", dbPath); + var objectTypeCounts = ReadSchemaObjectTypeCounts(connection, options); + + if (options.SchemaSummaryOnly) + { + return new DbSchemaReadResult( + userVersion, + [], + objectTypeCounts, + objectTypeCounts.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal), + EntriesTruncated: false, + SqlTruncated: false); + } + + using var cmd = SqliteConnectionPolicy.CreateCommand(connection); + var whereSql = BuildSchemaWhereSql(options); + cmd.CommandText = $@" + SELECT type, name, tbl_name, substr(sql, 1, @sql_limit) + FROM sqlite_master + WHERE {whereSql} + ORDER BY type, name + LIMIT @entry_limit"; + AddSchemaFilterParameters(cmd, options); + SqliteCommandPolicy.AddLimit(cmd, "@sql_limit", options.SchemaSqlTextLimit + 1); + SqliteCommandPolicy.AddLimit(cmd, "@entry_limit", options.SchemaEntryLimit + 1); + ReportMaintenanceProgress("schema", "read_entries", dbPath); + cancellationToken.ThrowIfCancellationRequested(); + using var reader = cmd.ExecuteReader(); + var entries = new List(); + var entriesTruncated = false; + var sqlTruncated = false; + while (reader.Read()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (entries.Count >= options.SchemaEntryLimit) + { + entriesTruncated = true; + break; + } + + var rawSql = reader.IsDBNull(3) ? null : reader.GetString(3); + var boundedSql = rawSql is null ? (Text: (string?)null, Truncated: false) : TruncateDiagnosticText(rawSql, options.SchemaSqlTextLimit); + sqlTruncated |= boundedSql.Truncated; + entries.Add(new DbSchemaEntryJsonResult( + reader.GetString(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + boundedSql.Text)); + } + + var emittedTypeCounts = entries + .GroupBy(entry => entry.Type, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + var omittedTypeCounts = objectTypeCounts.ToDictionary( + kv => kv.Key, + kv => Math.Max(0, kv.Value - (emittedTypeCounts.TryGetValue(kv.Key, out var emitted) ? emitted : 0)), + StringComparer.Ordinal); + + return new DbSchemaReadResult(userVersion, entries, objectTypeCounts, omittedTypeCounts, entriesTruncated, sqlTruncated); + } + + private static Dictionary ReadSchemaObjectTypeCounts(SqliteConnection connection, DbCommandOptions options) + { + var counts = new Dictionary(StringComparer.Ordinal) + { + ["table"] = 0, + ["index"] = 0, + ["trigger"] = 0, + ["view"] = 0, + }; + + using var cmd = SqliteConnectionPolicy.CreateCommand(connection); + var whereSql = BuildSchemaWhereSql(options); + cmd.CommandText = $@" + SELECT type, COUNT(*) + FROM sqlite_master + WHERE {whereSql} + GROUP BY type"; + AddSchemaFilterParameters(cmd, options); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var type = reader.GetString(0); + if (counts.ContainsKey(type)) + counts[type] = SqliteCommandPolicy.ToInt32Scalar(reader.GetInt64(1), "schema object type count"); + } + + return counts; + } + + private static string BuildSchemaWhereSql(DbCommandOptions options) + { + var clauses = new List { "type IN ('table', 'index', 'trigger', 'view')" }; + if (options.SchemaType is not null) + clauses.Add("type = @schema_type"); + if (options.SchemaName is not null) + clauses.Add("name = @schema_name"); + if (!options.SchemaIncludeInternal) + clauses.Add("name NOT LIKE 'sqlite!_%' ESCAPE '!'"); + return string.Join(" AND ", clauses); + } + + private static void AddSchemaFilterParameters(SqliteCommand cmd, DbCommandOptions options) + { + if (options.SchemaType is not null) + SqliteCommandPolicy.AddText(cmd, "@schema_type", options.SchemaType); + if (options.SchemaName is not null) + SqliteCommandPolicy.AddText(cmd, "@schema_name", options.SchemaName); + } + + private static DbIntegrityCheckReadResult BoundIntegrityRows(IEnumerable rawRows, CancellationToken cancellationToken) + { + var rows = new List(); + var rowsTruncated = false; + var textTruncated = false; + foreach (var raw in rawRows) + { + cancellationToken.ThrowIfCancellationRequested(); + if (rows.Count >= IntegrityCheckRowLimit) + { + rowsTruncated = true; + break; + } + + var bounded = TruncateDiagnosticText(raw, IntegrityCheckTextLimit); + textTruncated |= bounded.Truncated; + rows.Add(bounded.Text); + } + + return new DbIntegrityCheckReadResult(rows.Count > 0 ? rows : new List { "ok" }, rowsTruncated, textTruncated); + } + + private static (string Text, bool Truncated) TruncateDiagnosticText(string text, int limit) + { + if (text.Length <= limit) + return (text, false); + return (text[..limit] + " [truncated]", true); + } + + private static (int OrphanSymbolReferences, int OrphanReferenceLines, int OrphanSymbols, int Total, List Warnings) PruneOrphans(string dbPath, bool apply, CancellationToken cancellationToken) + { + using var connection = OpenConnection(dbPath, writable: apply, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + using var transaction = apply ? connection.BeginTransaction() : null; + var warnings = new List(); + + ReportMaintenanceProgress("prune", "count_symbol_references", dbPath); + var orphanSymbolReferences = Count(connection, transaction, @" + SELECT COUNT(*) + FROM symbol_references sr + LEFT JOIN files f ON f.id = sr.file_id + LEFT JOIN reference_lines rl ON rl.id = sr.reference_line_id + LEFT JOIN files rlf ON rlf.id = rl.file_id + WHERE f.id IS NULL + OR (sr.reference_line_id IS NOT NULL AND (rl.id IS NULL OR rlf.id IS NULL))", cancellationToken); + ReportMaintenanceProgress("prune", "count_reference_lines", dbPath); + var orphanReferenceLines = Count(connection, transaction, @" + SELECT COUNT(*) + FROM reference_lines rl + LEFT JOIN files f ON f.id = rl.file_id + WHERE f.id IS NULL", cancellationToken); + ReportMaintenanceProgress("prune", "count_symbols", dbPath); + var orphanSymbols = Count(connection, transaction, @" + SELECT COUNT(*) + FROM symbols s + LEFT JOIN files f ON f.id = s.file_id + WHERE f.id IS NULL", cancellationToken); + + if (apply) + { + if (orphanSymbolReferences > 0 || orphanSymbols > 0) + { + Execute( + connection, + transaction, + $"DELETE FROM codeindex_meta WHERE key = '{DbContext.ReferenceIdentityContractVersionMetaKey}'", + cancellationToken); + } + if (orphanSymbolReferences > 0) + { + var userVersion = Count(connection, transaction, "PRAGMA user_version", cancellationToken); + var nextUserVersion = userVersion & ~DbContext.HotspotReferenceAggregateReadyFlag; + if (nextUserVersion != userVersion) + { + Execute( + connection, + transaction, + $"PRAGMA user_version = {nextUserVersion}", + cancellationToken); + } + } + ReportMaintenanceProgress("prune", "delete_symbol_references", dbPath); + Execute(connection, transaction, @" + DELETE FROM symbol_references + WHERE file_id NOT IN (SELECT id FROM files) + OR (reference_line_id IS NOT NULL AND reference_line_id NOT IN ( + SELECT rl.id + FROM reference_lines rl + INNER JOIN files f ON f.id = rl.file_id + ))", cancellationToken); + ReportMaintenanceProgress("prune", "delete_reference_lines", dbPath); + Execute(connection, transaction, "DELETE FROM reference_lines WHERE file_id NOT IN (SELECT id FROM files)", cancellationToken); + ReportMaintenanceProgress("prune", "delete_symbols", dbPath); + Execute(connection, transaction, "DELETE FROM symbols WHERE file_id NOT IN (SELECT id FROM files)", cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + ReportMaintenanceProgress("prune", "commit", dbPath); + transaction!.Commit(); + ReportMaintenanceProgress("prune", "optimize", dbPath); + Execute(connection, null, "PRAGMA optimize", cancellationToken); + var walWarning = RunWalCheckpointTruncate(connection, cancellationToken); + if (walWarning is not null) + warnings.Add(walWarning); + } + + var total = orphanSymbolReferences + orphanReferenceLines + orphanSymbols; + return (orphanSymbolReferences, orphanReferenceLines, orphanSymbols, total, warnings); + } + + private static SqliteConnection OpenConnection(string dbPath, bool writable, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var connection = writable + ? new SqliteConnection(DbPathResolver.BuildSqliteConnectionString(dbPath, SqliteOpenMode.ReadWrite)) + : DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( + dbPath, + pooling: false, + out _, + out _); + try + { + connection.Open(); + ApplyBusyTimeout(connection, cancellationToken); + return connection; + } + catch + { + connection.Dispose(); + throw; + } + } + + private static void ApplyBusyTimeout(SqliteConnection connection, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var cmd = SqliteConnectionPolicy.CreateCommand(connection); + cmd.CommandText = DbPragmaPolicy.ReadBusyTimeoutPragmaSql(DbContext.BusyTimeoutEnvironmentVariable); + cmd.ExecuteNonQuery(); + } + + private static int Count(SqliteConnection connection, SqliteTransaction? transaction, string sql, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var cmd = SqliteConnectionPolicy.CreateCommand(connection); + cmd.Transaction = transaction; + cmd.CommandText = sql; + var result = SqliteCommandPolicy.ReadInt32Scalar(cmd, "db maintenance row count"); + cancellationToken.ThrowIfCancellationRequested(); + return result; + } + + private static void Execute(SqliteConnection connection, SqliteTransaction? transaction, string sql, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + cancellationToken.ThrowIfCancellationRequested(); + } + + private static DbDiagnosticJsonResult? RunWalCheckpointTruncate(SqliteConnection connection, CancellationToken cancellationToken) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + ReportMaintenanceProgress("prune", "wal_checkpoint_truncate", connection.DataSource); + using var cmd = SqliteConnectionPolicy.CreateCommand(connection, "PRAGMA wal_checkpoint(TRUNCATE)"); + DbContext.WalCheckpointTruncateExecutedForTesting?.Invoke(connection.DataSource); + cmd.ExecuteNonQuery(); + cancellationToken.ThrowIfCancellationRequested(); + return null; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + return new DbDiagnosticJsonResult( + "wal_checkpoint_truncate_failed", + "WAL checkpoint truncation failed after database prune committed.", + ConsoleUi.FormatBoundedValue(connection.DataSource)); + } + } + + private static DbDiagnosticJsonResult CreateCheckpointDiagnostic(string code, string message, string path) + => new(code, message, ConsoleUi.FormatBoundedValue(path)); + + private static bool IsRecoverableFilesystemException(Exception ex) + => ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or PathTooLongException; + + private static bool IsRecoverableRestoreException(Exception ex) + => IsRecoverableFilesystemException(ex) || ex is InvalidOperationException; + + private static bool ValidateWritableFileDb(DbCommandOptions options, JsonSerializerOptions jsonOptions, string command, out string fullDbPath, out int exitCode) + { + exitCode = CommandExitCodes.Success; + if (!TryResolveFileDb(options.DbPath, out fullDbPath, out var error)) + { + WriteCommandError(options.Json, jsonOptions, error, CommandExitCodes.DatabaseError, "Use a filesystem database path, not a SQLite URI.", CommandErrorCodes.DbError); + exitCode = CommandExitCodes.DatabaseError; + return false; + } + + if (!File.Exists(LongPath.EnsureWindowsPrefix(fullDbPath))) + { + WriteCommandError( + options.Json, + jsonOptions, + $"database not found: {fullDbPath}", + CommandExitCodes.NotFound, + "Point `--db` at an existing `codeindex.db`, or run `cdidx index ` first to create one.", + CommandErrorCodes.DbNotFound); + exitCode = CommandExitCodes.NotFound; + return false; + } + + if (DbPathResolver.UriRequestsReadOnly(options.DbPath)) + { + WriteCommandError( + options.Json, + jsonOptions, + $"database must be writable for {command}: {options.DbPath}", + CommandExitCodes.DatabaseError, + "Point `--db` at a writable filesystem path.", + CommandErrorCodes.DbNotWritable); + exitCode = CommandExitCodes.DatabaseError; + return false; + } + + return true; + } + + private static bool TryResolveFileDb(string dbPath, out string fullDbPath, out string error) + { + fullDbPath = string.Empty; + error = string.Empty; + if (dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + error = $"database command requires a filesystem path: {dbPath}"; + return false; + } + + fullDbPath = Path.GetFullPath(DbPathResolver.NormalizeDbPath(dbPath)); + return true; + } + +} diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index e4fa02d1d..41d893865 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -12,7 +12,7 @@ namespace CodeIndex.Cli; /// Runs `db` subcommands that operate directly on the SQLite file (integrity check, schema, prune). /// SQLite ファイル本体に対する `db` サブコマンド(整合性チェック、schema、prune)を実行する。 /// -public static class DbCommandRunner +public static partial class DbCommandRunner { private const string CheckpointsDirectorySuffix = ".checkpoints"; private const string AutoCheckpointPrefix = "auto-"; @@ -337,2538 +337,6 @@ private static void AddDbSchemaOmissionMetadata(JsonObject payload, DbSchemaRead payload["omitted_by"] = omittedBy; } - private static int RunPrune(DbCommandOptions options, JsonSerializerOptions jsonOptions, string dbPath, bool isUri, CancellationToken cancellationToken) - { - if (!options.PruneApply && !options.PruneDryRun) - return WriteCommandError( - options.Json, - jsonOptions, - "db prune requires --dry-run or --apply", - CommandExitCodes.UsageError, - "Use `cdidx db prune --dry-run` to inspect stale rows, then `cdidx db prune --apply` to delete them.", - CommandErrorCodes.UsageError); - - if (options.PruneApply && options.PruneDryRun) - return WriteCommandError( - options.Json, - jsonOptions, - "db prune accepts only one of --dry-run or --apply", - CommandExitCodes.UsageError, - "Choose `--dry-run` or `--apply`.", - CommandErrorCodes.UsageError); - - if (isUri && DbPathResolver.UriRequestsReadOnly(dbPath)) - return WriteCommandError( - options.Json, - jsonOptions, - $"database must be writable for prune: {dbPath}", - CommandExitCodes.DatabaseError, - "Point `--db` at a writable filesystem path, or omit read-only URI parameters such as `immutable=1` / `mode=ro`.", - CommandErrorCodes.DbNotWritable); - - try - { - ReportMaintenanceProgress("prune", "start", dbPath); - var result = PruneOrphans(dbPath, apply: options.PruneApply, cancellationToken); - ReportMaintenanceProgress("prune", "complete", dbPath); - var fullPath = DbPathResolver.FormatDbPathForDisplay(dbPath); - if (options.Json) - { - var jsonContext = CliJsonSerializerContextFactory.Create(jsonOptions); - Console.WriteLine(JsonSerializer.Serialize( - new DbPruneJsonResult( - "success", - fullPath, - options.PruneDryRun, - result.OrphanSymbolReferences, - result.OrphanReferenceLines, - result.OrphanSymbols, - result.Total, - result.Warnings), - jsonContext.DbPruneJsonResult)); - } - else - { - Console.WriteLine(options.PruneApply ? "Pruned database stale rows." : "Database prune dry run."); - Console.WriteLine($" database : {fullPath}"); - Console.WriteLine($" orphan symbol_references : {result.OrphanSymbolReferences:N0}"); - Console.WriteLine($" orphan reference_lines : {result.OrphanReferenceLines:N0}"); - Console.WriteLine($" orphan symbols : {result.OrphanSymbols:N0}"); - Console.WriteLine($" total : {result.Total:N0}"); - foreach (var warning in result.Warnings) - CommandErrorWriter.WriteStderr($"Warning [{warning.Code}]: {warning.Message}"); - } - - return CommandExitCodes.Success; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - if (JsonOutputFailure.TryHandle(ex, out var exitCode)) - return exitCode; - - return WriteCommandError( - options.Json, - jsonOptions, - $"failed to prune database: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}", - CommandExitCodes.DatabaseError, - "Ensure no other writer is holding the database lock, then retry `cdidx db prune --dry-run`.", - CommandErrorCodes.DbError); - } - } - - private static int RunCheckpoint(DbCommandOptions options, JsonSerializerOptions jsonOptions) - { - if (!ValidateWritableFileDb(options, jsonOptions, "checkpoint", out var fullDbPath, out var validationExitCode)) - return validationExitCode; - - try - { - if (options.CheckpointDryRun) - { - var preview = PreviewCheckpoint(fullDbPath, options.Name ?? MakeTimestampCheckpointName()); - if (options.Json) - { - Console.WriteLine(JsonSerializer.Serialize( - new DbCheckpointJsonResult( - "dry_run", - fullDbPath, - preview.Name, - preview.CheckpointPath, - preview.Files, - preview.FilesTruncated, - CheckpointFileInspectLimit, - preview.Diagnostics, - DryRun: true, - Bytes: preview.Bytes), - CliJsonSerializerContextFactory.Create(jsonOptions).DbCheckpointJsonResult)); - } - else - { - Console.WriteLine("Database checkpoint dry run."); - Console.WriteLine($" database : {fullDbPath}"); - Console.WriteLine($" name : {preview.Name}"); - Console.WriteLine($" checkpoint: {preview.CheckpointPath}"); - Console.WriteLine($" side effect: none (run without --dry-run to copy DB/WAL/SHM files)"); - Console.WriteLine($" files : {ConsoleUi.Counted(preview.Files.Count, "file")}{(preview.FilesTruncated ? " (truncated)" : string.Empty)}"); - Console.WriteLine($" bytes : {preview.Bytes:N0}"); - } - - foreach (var diagnostic in preview.Diagnostics) - CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); - - return CommandExitCodes.Success; - } - - var result = CreateCheckpoint(fullDbPath, options.Name ?? MakeTimestampCheckpointName()); - if (options.Json) - { - Console.WriteLine(JsonSerializer.Serialize( - new DbCheckpointJsonResult( - "success", - fullDbPath, - result.Name, - result.CheckpointPath, - result.Files, - result.FilesTruncated, - CheckpointFileInspectLimit, - result.Diagnostics, - Bytes: result.Bytes), - CliJsonSerializerContextFactory.Create(jsonOptions).DbCheckpointJsonResult)); - } - else - { - Console.WriteLine("Created database checkpoint."); - Console.WriteLine($" database : {fullDbPath}"); - Console.WriteLine($" name : {result.Name}"); - Console.WriteLine($" checkpoint: {result.CheckpointPath}"); - Console.WriteLine($" files : {ConsoleUi.Counted(result.Files.Count, "file")}{(result.FilesTruncated ? " (truncated)" : string.Empty)}"); - Console.WriteLine($" bytes : {result.Bytes:N0}"); - } - - foreach (var diagnostic in result.Diagnostics) - CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); - - return CommandExitCodes.Success; - } - catch (Exception ex) - { - var isInputError = ex is ArgumentException; - var safeMessage = isInputError - ? CommandErrorWriter.FormatSanitizedExceptionMessage(ex) - : $"failed to create database checkpoint: {CommandErrorWriter.FormatSanitizedException(ex)}"; - return WriteCommandError( - options.Json, - jsonOptions, - safeMessage, - isInputError ? CommandExitCodes.UsageError : CommandExitCodes.DatabaseError, - isInputError - ? $"Use a non-blank single file name of at most {MaxCheckpointNameLength} characters; do not use `.` or `..`, directory separators, or characters invalid in file names on this operating system." - : "Ensure the database and checkpoint directory are writable, then retry `cdidx db checkpoint`.", - isInputError ? CommandErrorCodes.UsageError : CommandErrorCodes.DbError, - category: isInputError ? null : DiagnosticRedactor.ClassifyException(ex)); - } - } - - private static int RunCheckpoints(DbCommandOptions options, JsonSerializerOptions jsonOptions) - { - var actionCount = (options.CheckpointsList ? 1 : 0) - + (options.CheckpointsDelete ? 1 : 0) - + (options.CheckpointsPrune ? 1 : 0); - if (actionCount == 0) - return WriteCommandError( - options.Json, - jsonOptions, - "checkpoints requires --list, --delete , or --prune --keep ", - CommandExitCodes.UsageError, - "Use `cdidx db checkpoints --list`, `cdidx db checkpoints --delete [--dry-run]`, or `cdidx db checkpoints --prune --keep [--dry-run]`.", - CommandErrorCodes.UsageError); - if (actionCount > 1) - return WriteCommandError( - options.Json, - jsonOptions, - "checkpoints accepts exactly one of --list, --delete, or --prune", - CommandExitCodes.UsageError, - "Choose one checkpoint maintenance action.", - CommandErrorCodes.UsageError); - - if (!TryResolveFileDb(options.DbPath, out var fullDbPath, out var error)) - return WriteCommandError(options.Json, jsonOptions, error, CommandExitCodes.DatabaseError, "Use a filesystem database path, not a SQLite URI.", CommandErrorCodes.DbError); - - if (options.CheckpointsDelete) - return RunDeleteCheckpoint(options, jsonOptions, fullDbPath); - if (options.CheckpointsPrune) - return RunPruneCheckpoints(options, jsonOptions, fullDbPath); - - var result = ListCheckpoints(fullDbPath, CheckpointListEntryLimit); - if (options.Json) - { - Console.WriteLine(JsonSerializer.Serialize( - new DbCheckpointListJsonResult( - fullDbPath, - result.Entries, - result.Truncated, - CheckpointListEntryLimit, - CheckpointFileInspectLimit, - result.Diagnostics), - CliJsonSerializerContextFactory.Create(jsonOptions).DbCheckpointListJsonResult)); - } - else - { - Console.WriteLine("Database checkpoints"); - Console.WriteLine($" database: {fullDbPath}"); - if (result.Truncated) - Console.WriteLine($" truncated: yes (checkpoint directory limit {CheckpointListEntryLimit:N0}, file limit {CheckpointFileInspectLimit:N0} per checkpoint)"); - if (result.Entries.Count == 0) - { - Console.WriteLine(" checkpoints: none"); - } - else - { - foreach (var entry in result.Entries) - Console.WriteLine($" {entry.Name} {entry.CreatedAtUtc} {entry.Bytes:N0} bytes{(entry.FilesTruncated ? " (files truncated)" : string.Empty)}"); - } - - foreach (var diagnostic in result.Diagnostics) - CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); - } - - return CommandExitCodes.Success; - } - - private static int RunDeleteCheckpoint( - DbCommandOptions options, - JsonSerializerOptions jsonOptions, - string fullDbPath) - { - if (string.IsNullOrWhiteSpace(options.Name)) - return WriteCommandError( - options.Json, - jsonOptions, - "checkpoint deletion requires a checkpoint name", - CommandExitCodes.UsageError, - "Use `cdidx db checkpoints --delete [--dry-run]`.", - CommandErrorCodes.UsageError); - - string checkpointPath; - try - { - checkpointPath = GetCheckpointPath(fullDbPath, options.Name); - } - catch (ArgumentException ex) - { - return WriteCommandError( - options.Json, - jsonOptions, - CommandErrorWriter.FormatSanitizedExceptionMessage(ex), - CommandExitCodes.UsageError, - $"Use a non-blank single file name of at most {MaxCheckpointNameLength} characters.", - CommandErrorCodes.UsageError); - } - - if (!Directory.Exists(LongPath.EnsureWindowsPrefix(checkpointPath))) - return WriteCommandError( - options.Json, - jsonOptions, - $"checkpoint not found: {FormatCheckpointNameForDiagnostic(options.Name)}", - CommandExitCodes.NotFound, - "Run `cdidx db checkpoints --list` to see available checkpoints.", - CommandErrorCodes.CheckpointNotFound); - - var diagnostics = new List(); - var deletedPaths = new List(); - var retainedPaths = new List(); - if (options.CheckpointsDryRun) - { - if (TryValidateCheckpointDirectoryTarget(fullDbPath, checkpointPath, out _, out var validationFailure)) - deletedPaths.Add(checkpointPath); - else - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_delete_skipped", - $"Checkpoint deletion would be skipped: {validationFailure}.", - ConsoleUi.FormatBoundedValue(checkpointPath))); - retainedPaths.Add(checkpointPath); - } - } - else if (TryDeleteCheckpointDirectory(fullDbPath, checkpointPath, diagnostics)) - { - deletedPaths.Add(checkpointPath); - } - else - { - retainedPaths.Add(checkpointPath); - } - - var failed = retainedPaths.Count > 0; - var result = new DbCheckpointCleanupResult( - deletedPaths.Count, - retainedPaths.Count, - deletedPaths, - retainedPaths, - Truncated: false, - diagnostics); - WriteCheckpointCleanupResult( - options, - jsonOptions, - fullDbPath, - "delete", - options.Name, - keep: null, - result, - status: failed ? "error" : options.CheckpointsDryRun ? "dry_run" : "success"); - return failed ? CommandExitCodes.DatabaseError : CommandExitCodes.Success; - } - - private static int RunPruneCheckpoints( - DbCommandOptions options, - JsonSerializerOptions jsonOptions, - string fullDbPath) - { - var listed = ListCheckpoints(fullDbPath, CheckpointPruneScanLimit); - var diagnostics = listed.Diagnostics; - if (listed.DirectoryEnumerationTruncated) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_prune_truncated", - "Checkpoint pruning was skipped because checkpoint enumeration reached the scan limit.", - ConsoleUi.FormatBoundedValue(GetCheckpointRoot(fullDbPath)))); - var skipped = new DbCheckpointCleanupResult( - Deleted: 0, - Retained: listed.Entries.Count, - DeletedPaths: [], - RetainedPaths: listed.Entries.Select(entry => entry.CheckpointPath).ToList(), - Truncated: true, - diagnostics); - WriteCheckpointCleanupResult( - options, - jsonOptions, - fullDbPath, - "prune", - name: null, - options.CheckpointsKeep, - skipped, - status: options.CheckpointsDryRun ? "dry_run" : "success"); - return CommandExitCodes.Success; - } - - var retainableEntries = listed.Entries - .Select(entry => new - { - Entry = entry, - Retainable = TryGetCheckpointRetentionTimestamp( - fullDbPath, - entry.Name, - entry.CheckpointPath, - diagnostics, - out var createdAtUtc), - CreatedAtUtc = createdAtUtc, - }) - .Where(candidate => candidate.Retainable) - .OrderByDescending(candidate => candidate.CreatedAtUtc) - .ThenBy(candidate => candidate.Entry.Name, StringComparer.Ordinal) - .Select(candidate => candidate.Entry) - .ToList(); - var retainedPaths = retainableEntries - .Take(options.CheckpointsKeep) - .Select(entry => entry.CheckpointPath) - .ToList(); - var retainedPathSet = retainedPaths.ToHashSet(StringComparer.Ordinal); - var candidatePaths = listed.Entries - .Where(entry => !retainedPathSet.Contains(entry.CheckpointPath)) - .Select(entry => entry.CheckpointPath) - .ToList(); - var deletedPaths = new List(); - if (options.CheckpointsDryRun) - { - foreach (var path in candidatePaths) - { - if (TryValidateCheckpointDirectoryTarget(fullDbPath, path, out _, out var validationFailure)) - deletedPaths.Add(path); - else - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_delete_skipped", - $"Checkpoint deletion would be skipped: {validationFailure}.", - ConsoleUi.FormatBoundedValue(path))); - retainedPaths.Add(path); - } - } - } - else - { - foreach (var path in candidatePaths) - { - if (TryDeleteCheckpointDirectory(fullDbPath, path, diagnostics)) - deletedPaths.Add(path); - else - retainedPaths.Add(path); - } - } - - var result = new DbCheckpointCleanupResult( - deletedPaths.Count, - retainedPaths.Count, - deletedPaths, - retainedPaths, - listed.Truncated, - diagnostics); - WriteCheckpointCleanupResult( - options, - jsonOptions, - fullDbPath, - "prune", - name: null, - options.CheckpointsKeep, - result, - status: options.CheckpointsDryRun ? "dry_run" : "success"); - return CommandExitCodes.Success; - } - - private static void WriteCheckpointCleanupResult( - DbCommandOptions options, - JsonSerializerOptions jsonOptions, - string fullDbPath, - string operation, - string? name, - int? keep, - DbCheckpointCleanupResult result, - string status) - { - if (options.Json) - { - Console.WriteLine(JsonSerializer.Serialize( - new DbCheckpointCleanupJsonResult( - status, - fullDbPath, - operation, - name, - keep, - options.CheckpointsDryRun, - result.Deleted, - result.Retained, - result.DeletedPaths, - result.RetainedPaths, - result.Truncated, - CheckpointPruneScanLimit, - result.Diagnostics), - CliJsonSerializerContextFactory.Create(jsonOptions).DbCheckpointCleanupJsonResult)); - return; - } - - Console.WriteLine(options.CheckpointsDryRun - ? "Database checkpoint cleanup dry run." - : "Database checkpoint cleanup complete."); - Console.WriteLine($" database : {fullDbPath}"); - Console.WriteLine($" operation: {operation}"); - if (name is not null) - Console.WriteLine($" name : {name}"); - if (keep is not null) - Console.WriteLine($" keep : {keep.Value:N0}"); - Console.WriteLine($" side effect: {(options.CheckpointsDryRun ? "none" : "requested checkpoint directories removed")}"); - Console.WriteLine($" {(options.CheckpointsDryRun ? "would delete" : "deleted"),-12}: {result.Deleted:N0}"); - foreach (var path in result.DeletedPaths) - Console.WriteLine($" {path}"); - Console.WriteLine($" retained : {result.Retained:N0}"); - foreach (var path in result.RetainedPaths) - Console.WriteLine($" {path}"); - if (result.Truncated) - Console.WriteLine($" truncated: yes (checkpoint scan limit {CheckpointPruneScanLimit:N0})"); - foreach (var diagnostic in result.Diagnostics) - CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); - } - - private static int RunRestore(DbCommandOptions options, JsonSerializerOptions jsonOptions) - { - if (string.IsNullOrWhiteSpace(options.Name)) - return WriteCommandError(options.Json, jsonOptions, "restore requires a checkpoint name", CommandExitCodes.UsageError, "Use `cdidx db restore --db `.", CommandErrorCodes.UsageError); - if (!ValidateWritableFileDb(options, jsonOptions, "restore", out var fullDbPath, out var validationExitCode)) - return validationExitCode; - - var checkpointPath = string.Empty; - try - { - checkpointPath = GetCheckpointPath(fullDbPath, options.Name); - if (!Directory.Exists(checkpointPath)) - return WriteCommandError(options.Json, jsonOptions, $"checkpoint not found: {FormatCheckpointNameForDiagnostic(options.Name)}", CommandExitCodes.NotFound, "Run `cdidx db checkpoints --list` to see available checkpoints.", CommandErrorCodes.CheckpointNotFound); - - var preview = PreviewRestoreCheckpoint(fullDbPath, options.Name, checkpointPath); - if (options.RestoreDryRun) - return WriteRestoreDryRunResult(options, jsonOptions, fullDbPath, options.Name, checkpointPath, preview); - if (!preview.Ready) - throw new InvalidOperationException("checkpoint validation failed"); - - var backupPath = RestoreCheckpoint(fullDbPath, options.Name, checkpointPath); - if (options.Json) - { - Console.WriteLine(JsonSerializer.Serialize( - new DbRestoreJsonResult("success", fullDbPath, options.Name, checkpointPath, backupPath), - CliJsonSerializerContextFactory.Create(jsonOptions).DbRestoreJsonResult)); - } - else - { - Console.WriteLine("Restored database checkpoint."); - Console.WriteLine($" database : {fullDbPath}"); - Console.WriteLine($" checkpoint: {options.Name}"); - Console.WriteLine($" backup : {backupPath}"); - } - - return CommandExitCodes.Success; - } - catch (DbRestoreOperationException ex) - { - return WriteRestoreError(options, jsonOptions, fullDbPath, options.Name, checkpointPath, ex); - } - catch (Exception ex) - { - return WriteCommandError( - options.Json, - jsonOptions, - $"failed to restore database checkpoint: {CommandErrorWriter.FormatSanitizedException(ex)}", - CommandExitCodes.DatabaseError, - "Ensure no cdidx writer is running, then retry `cdidx db restore `.", - CommandErrorCodes.DbError, - category: DiagnosticRedactor.ClassifyException(ex)); - } - } - - private static int WriteRestoreDryRunResult( - DbCommandOptions options, - JsonSerializerOptions jsonOptions, - string fullDbPath, - string name, - string checkpointPath, - DbRestorePreviewResult preview) - { - const string hint = "Fix the reported checkpoint, path, or free-space diagnostics before running without --dry-run."; - var message = preview.Ready - ? null - : "database restore dry run found blocking validation failures"; - if (options.Json) - { - Console.WriteLine(JsonSerializer.Serialize( - new DbRestoreDryRunJsonResult( - preview.Ready ? "dry_run" : "invalid", - fullDbPath, - name, - checkpointPath, - DryRun: true, - preview.Ready, - preview.ManifestValid, - preview.PathsValid, - preview.SpaceCheckAvailable, - preview.SpaceSufficient, - preview.RequiredSpaceBytes, - preview.AvailableSpaceBytes, - preview.Files, - preview.Bytes, - preview.Diagnostics, - message, - preview.Ready ? null : CommandErrorCodes.DbError, - preview.Ready ? null : hint), - CliJsonSerializerContextFactory.Create(jsonOptions).DbRestoreDryRunJsonResult)); - } - else - { - Console.WriteLine("Database restore dry run."); - Console.WriteLine($" database : {fullDbPath}"); - Console.WriteLine($" checkpoint: {checkpointPath}"); - Console.WriteLine(" side effect: none (run without --dry-run to replace the DB)"); - Console.WriteLine($" manifest : {(preview.ManifestValid ? "valid" : "invalid")}"); - Console.WriteLine($" paths : {(preview.PathsValid ? "valid" : "invalid")}"); - Console.WriteLine($" bytes : {preview.Bytes:N0}"); - Console.WriteLine($" available : {(preview.AvailableSpaceBytes is long available ? available.ToString("N0", System.Globalization.CultureInfo.CurrentCulture) : "unknown")}"); - Console.WriteLine($" space : {(preview.SpaceSufficient is true ? "sufficient" : preview.SpaceSufficient is false ? "insufficient" : "unknown")}"); - Console.WriteLine($" ready : {(preview.Ready ? "yes" : "no")}"); - foreach (var diagnostic in preview.Diagnostics) - CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); - if (!preview.Ready) - CommandErrorWriter.WriteStderr($"Error: {message}. Hint: {hint}"); - } - - return preview.Ready ? CommandExitCodes.Success : CommandExitCodes.DatabaseError; - } - - private static int WriteRestoreError( - DbCommandOptions options, - JsonSerializerOptions jsonOptions, - string fullDbPath, - string name, - string checkpointPath, - DbRestoreOperationException ex) - { - var primary = ex.InnerException ?? ex; - var message = $"failed to restore database checkpoint: {CommandErrorWriter.FormatSanitizedException(primary)}"; - const string hint = "Ensure no cdidx writer is running, then retry `cdidx db restore `."; - if (options.Json) - { - Console.WriteLine(JsonSerializer.Serialize( - new DbRestoreJsonResult( - "error", - fullDbPath, - name, - string.IsNullOrWhiteSpace(ex.CheckpointPath) ? checkpointPath : ex.CheckpointPath, - ex.BackupPath, - message, - CommandErrorCodes.DbError, - hint, - ex.RollbackFailure is not null, - ex.RollbackFailure), - CliJsonSerializerContextFactory.Create(jsonOptions).DbRestoreJsonResult)); - return CommandExitCodes.DatabaseError; - } - - return WriteCommandError( - false, - jsonOptions, - message, - CommandExitCodes.DatabaseError, - hint, - CommandErrorCodes.DbError, - category: DiagnosticRedactor.ClassifyException(primary)); - } - - private static int RunRestoreBackups(DbCommandOptions options, JsonSerializerOptions jsonOptions) - { - if (!options.RestoreBackupsList && !options.RestoreBackupsPrune) - return WriteCommandError( - options.Json, - jsonOptions, - "restore-backups requires --list or --prune", - CommandExitCodes.UsageError, - "Use `cdidx db restore-backups --list` or `cdidx db restore-backups --prune --keep `.", - CommandErrorCodes.UsageError); - - if (options.RestoreBackupsList && options.RestoreBackupsPrune) - return WriteCommandError( - options.Json, - jsonOptions, - "restore-backups accepts only one of --list or --prune", - CommandExitCodes.UsageError, - "Choose `--list` or `--prune`.", - CommandErrorCodes.UsageError); - - if (!TryResolveFileDb(options.DbPath, out var fullDbPath, out var error)) - return WriteCommandError(options.Json, jsonOptions, error, CommandExitCodes.DatabaseError, "Use a filesystem database path, not a SQLite URI.", CommandErrorCodes.DbError); - - if (options.RestoreBackupsList) - { - var result = ListRestoreBackups(fullDbPath, RestoreBackupListEntryLimit); - if (options.Json) - { - Console.WriteLine(JsonSerializer.Serialize( - new DbRestoreBackupListJsonResult( - fullDbPath, - result.Entries, - result.Truncated, - RestoreBackupListEntryLimit, - CheckpointFileInspectLimit, - result.Diagnostics), - CliJsonSerializerContextFactory.Create(jsonOptions).DbRestoreBackupListJsonResult)); - } - else - { - Console.WriteLine("Database restore backups"); - Console.WriteLine($" database: {fullDbPath}"); - if (result.Truncated) - Console.WriteLine($" truncated: yes (restore backup limit {RestoreBackupListEntryLimit:N0}, file limit {CheckpointFileInspectLimit:N0} per backup)"); - if (result.Entries.Count == 0) - { - Console.WriteLine(" backups: none"); - } - else - { - foreach (var entry in result.Entries) - Console.WriteLine($" {entry.Name} {entry.CreatedAtUtc} {entry.Bytes:N0} bytes{(entry.FilesTruncated ? " (files truncated)" : string.Empty)}"); - } - - foreach (var diagnostic in result.Diagnostics) - Console.Error.WriteLine($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); - } - - return CommandExitCodes.Success; - } - - var pruneResult = PruneRestoreBackups(fullDbPath, options.RestoreBackupsKeep, options.RestoreBackupsDryRun); - if (options.Json) - { - Console.WriteLine(JsonSerializer.Serialize( - new DbRestoreBackupPruneJsonResult( - options.RestoreBackupsDryRun ? "dry_run" : "success", - fullDbPath, - options.RestoreBackupsKeep, - options.RestoreBackupsDryRun, - pruneResult.Deleted, - pruneResult.Retained, - pruneResult.DeletedPaths, - pruneResult.RetainedPaths, - pruneResult.Truncated, - RestoreBackupPruneScanLimit, - pruneResult.Diagnostics), - CliJsonSerializerContextFactory.Create(jsonOptions).DbRestoreBackupPruneJsonResult)); - } - else - { - Console.WriteLine(options.RestoreBackupsDryRun - ? "Database restore backup prune dry run." - : "Pruned database restore backups."); - Console.WriteLine($" database: {fullDbPath}"); - Console.WriteLine($" keep : {options.RestoreBackupsKeep:N0}"); - Console.WriteLine($" side effect: {(options.RestoreBackupsDryRun ? "none" : "older restore backup directories removed")}"); - Console.WriteLine($" {(options.RestoreBackupsDryRun ? "would delete" : "deleted"),-12}: {pruneResult.Deleted:N0}"); - foreach (var path in pruneResult.DeletedPaths) - Console.WriteLine($" {path}"); - Console.WriteLine($" retained: {pruneResult.Retained:N0}"); - foreach (var path in pruneResult.RetainedPaths) - Console.WriteLine($" {path}"); - if (pruneResult.Truncated) - Console.WriteLine($" truncated: yes (restore backup scan limit {RestoreBackupPruneScanLimit:N0})"); - foreach (var diagnostic in pruneResult.Diagnostics) - Console.Error.WriteLine($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); - } - - return CommandExitCodes.Success; - } - - // PRAGMA integrity_check returns a single row `"ok"` when the file passes every consistency - // probe, otherwise it returns up to N rows of corruption findings. The pragma itself only - // reads the database, so a read-only connection is sufficient and avoids the WAL-mode - // pragma side effects of the normal DbContext open path. - // PRAGMA integrity_check は問題が無ければ 1 行の `"ok"` を、破損があれば最大 N 行の検出結果を返す。 - // 読み取りのみのため read-only 接続で十分で、DbContext の WAL モード設定副作用を避けられる。 - private static DbIntegrityCheckReadResult RunIntegrityCheckPragma(string dbPath, CancellationToken cancellationToken) - { - if (IntegrityCheckRowsForTesting != null) - return BoundIntegrityRows(IntegrityCheckRowsForTesting(), cancellationToken); - - cancellationToken.ThrowIfCancellationRequested(); - using var connection = DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( - dbPath, - pooling: false, - out _, - out _); - ReportMaintenanceProgress("integrity_check", "open_connection", dbPath); - connection.Open(); - ApplyBusyTimeout(connection, cancellationToken); - using var cmd = SqliteConnectionPolicy.CreateCommand(connection, $"PRAGMA integrity_check({IntegrityCheckRowLimit + 1})"); - ReportMaintenanceProgress("integrity_check", "read_rows", dbPath); - cancellationToken.ThrowIfCancellationRequested(); - using var reader = cmd.ExecuteReader(); - var rows = new List(); - var rowsTruncated = false; - var textTruncated = false; - while (reader.Read()) - { - cancellationToken.ThrowIfCancellationRequested(); - if (rows.Count >= IntegrityCheckRowLimit) - { - rowsTruncated = true; - break; - } - - var raw = reader.IsDBNull(0) ? string.Empty : reader.GetString(0); - var bounded = TruncateDiagnosticText(raw, IntegrityCheckTextLimit); - textTruncated |= bounded.Truncated; - rows.Add(bounded.Text); - } - return new DbIntegrityCheckReadResult(rows.Count > 0 ? rows : new List { "ok" }, rowsTruncated, textTruncated); - } - - private static DbSchemaReadResult ReadSchema(string dbPath, DbCommandOptions options, CancellationToken cancellationToken) - { - using var connection = OpenConnection(dbPath, writable: false, cancellationToken); - ReportMaintenanceProgress("schema", "read_version", dbPath); - cancellationToken.ThrowIfCancellationRequested(); - using var versionCmd = connection.CreateCommand(); - versionCmd.CommandText = "PRAGMA user_version"; - var rawVersion = versionCmd.ExecuteScalar(); - var userVersion = rawVersion is long l ? (int)l : (rawVersion is int i ? i : 0); - cancellationToken.ThrowIfCancellationRequested(); - ReportMaintenanceProgress("schema", "count_objects", dbPath); - var objectTypeCounts = ReadSchemaObjectTypeCounts(connection, options); - - if (options.SchemaSummaryOnly) - { - return new DbSchemaReadResult( - userVersion, - [], - objectTypeCounts, - objectTypeCounts.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal), - EntriesTruncated: false, - SqlTruncated: false); - } - - using var cmd = SqliteConnectionPolicy.CreateCommand(connection); - var whereSql = BuildSchemaWhereSql(options); - cmd.CommandText = $@" - SELECT type, name, tbl_name, substr(sql, 1, @sql_limit) - FROM sqlite_master - WHERE {whereSql} - ORDER BY type, name - LIMIT @entry_limit"; - AddSchemaFilterParameters(cmd, options); - SqliteCommandPolicy.AddLimit(cmd, "@sql_limit", options.SchemaSqlTextLimit + 1); - SqliteCommandPolicy.AddLimit(cmd, "@entry_limit", options.SchemaEntryLimit + 1); - ReportMaintenanceProgress("schema", "read_entries", dbPath); - cancellationToken.ThrowIfCancellationRequested(); - using var reader = cmd.ExecuteReader(); - var entries = new List(); - var entriesTruncated = false; - var sqlTruncated = false; - while (reader.Read()) - { - cancellationToken.ThrowIfCancellationRequested(); - if (entries.Count >= options.SchemaEntryLimit) - { - entriesTruncated = true; - break; - } - - var rawSql = reader.IsDBNull(3) ? null : reader.GetString(3); - var boundedSql = rawSql is null ? (Text: (string?)null, Truncated: false) : TruncateDiagnosticText(rawSql, options.SchemaSqlTextLimit); - sqlTruncated |= boundedSql.Truncated; - entries.Add(new DbSchemaEntryJsonResult( - reader.GetString(0), - reader.GetString(1), - reader.IsDBNull(2) ? null : reader.GetString(2), - boundedSql.Text)); - } - - var emittedTypeCounts = entries - .GroupBy(entry => entry.Type, StringComparer.Ordinal) - .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); - var omittedTypeCounts = objectTypeCounts.ToDictionary( - kv => kv.Key, - kv => Math.Max(0, kv.Value - (emittedTypeCounts.TryGetValue(kv.Key, out var emitted) ? emitted : 0)), - StringComparer.Ordinal); - - return new DbSchemaReadResult(userVersion, entries, objectTypeCounts, omittedTypeCounts, entriesTruncated, sqlTruncated); - } - - private static Dictionary ReadSchemaObjectTypeCounts(SqliteConnection connection, DbCommandOptions options) - { - var counts = new Dictionary(StringComparer.Ordinal) - { - ["table"] = 0, - ["index"] = 0, - ["trigger"] = 0, - ["view"] = 0, - }; - - using var cmd = SqliteConnectionPolicy.CreateCommand(connection); - var whereSql = BuildSchemaWhereSql(options); - cmd.CommandText = $@" - SELECT type, COUNT(*) - FROM sqlite_master - WHERE {whereSql} - GROUP BY type"; - AddSchemaFilterParameters(cmd, options); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) - { - var type = reader.GetString(0); - if (counts.ContainsKey(type)) - counts[type] = SqliteCommandPolicy.ToInt32Scalar(reader.GetInt64(1), "schema object type count"); - } - - return counts; - } - - private static string BuildSchemaWhereSql(DbCommandOptions options) - { - var clauses = new List { "type IN ('table', 'index', 'trigger', 'view')" }; - if (options.SchemaType is not null) - clauses.Add("type = @schema_type"); - if (options.SchemaName is not null) - clauses.Add("name = @schema_name"); - if (!options.SchemaIncludeInternal) - clauses.Add("name NOT LIKE 'sqlite!_%' ESCAPE '!'"); - return string.Join(" AND ", clauses); - } - - private static void AddSchemaFilterParameters(SqliteCommand cmd, DbCommandOptions options) - { - if (options.SchemaType is not null) - SqliteCommandPolicy.AddText(cmd, "@schema_type", options.SchemaType); - if (options.SchemaName is not null) - SqliteCommandPolicy.AddText(cmd, "@schema_name", options.SchemaName); - } - - private static DbIntegrityCheckReadResult BoundIntegrityRows(IEnumerable rawRows, CancellationToken cancellationToken) - { - var rows = new List(); - var rowsTruncated = false; - var textTruncated = false; - foreach (var raw in rawRows) - { - cancellationToken.ThrowIfCancellationRequested(); - if (rows.Count >= IntegrityCheckRowLimit) - { - rowsTruncated = true; - break; - } - - var bounded = TruncateDiagnosticText(raw, IntegrityCheckTextLimit); - textTruncated |= bounded.Truncated; - rows.Add(bounded.Text); - } - - return new DbIntegrityCheckReadResult(rows.Count > 0 ? rows : new List { "ok" }, rowsTruncated, textTruncated); - } - - private static (string Text, bool Truncated) TruncateDiagnosticText(string text, int limit) - { - if (text.Length <= limit) - return (text, false); - return (text[..limit] + " [truncated]", true); - } - - private static (int OrphanSymbolReferences, int OrphanReferenceLines, int OrphanSymbols, int Total, List Warnings) PruneOrphans(string dbPath, bool apply, CancellationToken cancellationToken) - { - using var connection = OpenConnection(dbPath, writable: apply, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - using var transaction = apply ? connection.BeginTransaction() : null; - var warnings = new List(); - - ReportMaintenanceProgress("prune", "count_symbol_references", dbPath); - var orphanSymbolReferences = Count(connection, transaction, @" - SELECT COUNT(*) - FROM symbol_references sr - LEFT JOIN files f ON f.id = sr.file_id - LEFT JOIN reference_lines rl ON rl.id = sr.reference_line_id - LEFT JOIN files rlf ON rlf.id = rl.file_id - WHERE f.id IS NULL - OR (sr.reference_line_id IS NOT NULL AND (rl.id IS NULL OR rlf.id IS NULL))", cancellationToken); - ReportMaintenanceProgress("prune", "count_reference_lines", dbPath); - var orphanReferenceLines = Count(connection, transaction, @" - SELECT COUNT(*) - FROM reference_lines rl - LEFT JOIN files f ON f.id = rl.file_id - WHERE f.id IS NULL", cancellationToken); - ReportMaintenanceProgress("prune", "count_symbols", dbPath); - var orphanSymbols = Count(connection, transaction, @" - SELECT COUNT(*) - FROM symbols s - LEFT JOIN files f ON f.id = s.file_id - WHERE f.id IS NULL", cancellationToken); - - if (apply) - { - if (orphanSymbolReferences > 0 || orphanSymbols > 0) - { - Execute( - connection, - transaction, - $"DELETE FROM codeindex_meta WHERE key = '{DbContext.ReferenceIdentityContractVersionMetaKey}'", - cancellationToken); - } - if (orphanSymbolReferences > 0) - { - var userVersion = Count(connection, transaction, "PRAGMA user_version", cancellationToken); - var nextUserVersion = userVersion & ~DbContext.HotspotReferenceAggregateReadyFlag; - if (nextUserVersion != userVersion) - { - Execute( - connection, - transaction, - $"PRAGMA user_version = {nextUserVersion}", - cancellationToken); - } - } - ReportMaintenanceProgress("prune", "delete_symbol_references", dbPath); - Execute(connection, transaction, @" - DELETE FROM symbol_references - WHERE file_id NOT IN (SELECT id FROM files) - OR (reference_line_id IS NOT NULL AND reference_line_id NOT IN ( - SELECT rl.id - FROM reference_lines rl - INNER JOIN files f ON f.id = rl.file_id - ))", cancellationToken); - ReportMaintenanceProgress("prune", "delete_reference_lines", dbPath); - Execute(connection, transaction, "DELETE FROM reference_lines WHERE file_id NOT IN (SELECT id FROM files)", cancellationToken); - ReportMaintenanceProgress("prune", "delete_symbols", dbPath); - Execute(connection, transaction, "DELETE FROM symbols WHERE file_id NOT IN (SELECT id FROM files)", cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - ReportMaintenanceProgress("prune", "commit", dbPath); - transaction!.Commit(); - ReportMaintenanceProgress("prune", "optimize", dbPath); - Execute(connection, null, "PRAGMA optimize", cancellationToken); - var walWarning = RunWalCheckpointTruncate(connection, cancellationToken); - if (walWarning is not null) - warnings.Add(walWarning); - } - - var total = orphanSymbolReferences + orphanReferenceLines + orphanSymbols; - return (orphanSymbolReferences, orphanReferenceLines, orphanSymbols, total, warnings); - } - - private static SqliteConnection OpenConnection(string dbPath, bool writable, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - var connection = writable - ? new SqliteConnection(DbPathResolver.BuildSqliteConnectionString(dbPath, SqliteOpenMode.ReadWrite)) - : DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( - dbPath, - pooling: false, - out _, - out _); - try - { - connection.Open(); - ApplyBusyTimeout(connection, cancellationToken); - return connection; - } - catch - { - connection.Dispose(); - throw; - } - } - - private static void ApplyBusyTimeout(SqliteConnection connection, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - using var cmd = SqliteConnectionPolicy.CreateCommand(connection); - cmd.CommandText = DbPragmaPolicy.ReadBusyTimeoutPragmaSql(DbContext.BusyTimeoutEnvironmentVariable); - cmd.ExecuteNonQuery(); - } - - private static int Count(SqliteConnection connection, SqliteTransaction? transaction, string sql, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - using var cmd = SqliteConnectionPolicy.CreateCommand(connection); - cmd.Transaction = transaction; - cmd.CommandText = sql; - var result = SqliteCommandPolicy.ReadInt32Scalar(cmd, "db maintenance row count"); - cancellationToken.ThrowIfCancellationRequested(); - return result; - } - - private static void Execute(SqliteConnection connection, SqliteTransaction? transaction, string sql, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - using var cmd = connection.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = sql; - cmd.ExecuteNonQuery(); - cancellationToken.ThrowIfCancellationRequested(); - } - - private static DbDiagnosticJsonResult? RunWalCheckpointTruncate(SqliteConnection connection, CancellationToken cancellationToken) - { - try - { - cancellationToken.ThrowIfCancellationRequested(); - ReportMaintenanceProgress("prune", "wal_checkpoint_truncate", connection.DataSource); - using var cmd = SqliteConnectionPolicy.CreateCommand(connection, "PRAGMA wal_checkpoint(TRUNCATE)"); - DbContext.WalCheckpointTruncateExecutedForTesting?.Invoke(connection.DataSource); - cmd.ExecuteNonQuery(); - cancellationToken.ThrowIfCancellationRequested(); - return null; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception) - { - return new DbDiagnosticJsonResult( - "wal_checkpoint_truncate_failed", - "WAL checkpoint truncation failed after database prune committed.", - ConsoleUi.FormatBoundedValue(connection.DataSource)); - } - } - - private static DbDiagnosticJsonResult CreateCheckpointDiagnostic(string code, string message, string path) - => new(code, message, ConsoleUi.FormatBoundedValue(path)); - - private static bool IsRecoverableFilesystemException(Exception ex) - => ex is IOException - or UnauthorizedAccessException - or ArgumentException - or NotSupportedException - or PathTooLongException; - - private static bool IsRecoverableRestoreException(Exception ex) - => IsRecoverableFilesystemException(ex) || ex is InvalidOperationException; - - private static bool ValidateWritableFileDb(DbCommandOptions options, JsonSerializerOptions jsonOptions, string command, out string fullDbPath, out int exitCode) - { - exitCode = CommandExitCodes.Success; - if (!TryResolveFileDb(options.DbPath, out fullDbPath, out var error)) - { - WriteCommandError(options.Json, jsonOptions, error, CommandExitCodes.DatabaseError, "Use a filesystem database path, not a SQLite URI.", CommandErrorCodes.DbError); - exitCode = CommandExitCodes.DatabaseError; - return false; - } - - if (!File.Exists(LongPath.EnsureWindowsPrefix(fullDbPath))) - { - WriteCommandError( - options.Json, - jsonOptions, - $"database not found: {fullDbPath}", - CommandExitCodes.NotFound, - "Point `--db` at an existing `codeindex.db`, or run `cdidx index ` first to create one.", - CommandErrorCodes.DbNotFound); - exitCode = CommandExitCodes.NotFound; - return false; - } - - if (DbPathResolver.UriRequestsReadOnly(options.DbPath)) - { - WriteCommandError( - options.Json, - jsonOptions, - $"database must be writable for {command}: {options.DbPath}", - CommandExitCodes.DatabaseError, - "Point `--db` at a writable filesystem path.", - CommandErrorCodes.DbNotWritable); - exitCode = CommandExitCodes.DatabaseError; - return false; - } - - return true; - } - - private static bool TryResolveFileDb(string dbPath, out string fullDbPath, out string error) - { - fullDbPath = string.Empty; - error = string.Empty; - if (dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - { - error = $"database command requires a filesystem path: {dbPath}"; - return false; - } - - fullDbPath = Path.GetFullPath(DbPathResolver.NormalizeDbPath(dbPath)); - return true; - } - - private static DbCheckpointOperationResult CreateCheckpoint(string fullDbPath, string name) - { - ValidateCheckpointName(name); - var root = GetCheckpointRoot(fullDbPath); - var checkpointPath = GetCheckpointPath(fullDbPath, name); - if (Directory.Exists(checkpointPath)) - throw new InvalidOperationException($"checkpoint already exists: {FormatCheckpointNameForDiagnostic(name)}"); - - DataDirectorySecurity.CreateSensitiveDirectory(root); - var tempPath = Path.Combine(root, ".tmp-" + name + "-" + Guid.NewGuid().ToString("N")); - DataDirectorySecurity.CreateSensitiveDirectory(tempPath); - try - { - CopyIfExists(fullDbPath, Path.Combine(tempPath, Path.GetFileName(fullDbPath)), privateDestination: true); - CopyIfExists(fullDbPath + "-wal", Path.Combine(tempPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true); - CopyIfExists(fullDbPath + "-shm", Path.Combine(tempPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true); - DataDirectorySecurity.WritePrivateText(Path.Combine(tempPath, "manifest.txt"), $"name={name}{Environment.NewLine}created_at_utc={GetUtcNow():O}{Environment.NewLine}db_file={Path.GetFileName(fullDbPath)}{Environment.NewLine}"); - AtomicFileWriter.PublishDirectory(tempPath, checkpointPath); - } - catch - { - TryDeleteTemporaryDirectory( - tempPath, - "checkpoint temporary directory", - root, - ".tmp-"); - throw; - } - - var diagnostics = new List(); - var files = EnumerateCheckpointFileNames(checkpointPath, diagnostics); - var bytes = files.Truncated - ? (Bytes: 0L, Truncated: true) - : SumCheckpointBytes(checkpointPath, diagnostics); - return new DbCheckpointOperationResult(name, checkpointPath, files.Items, files.Truncated || bytes.Truncated, diagnostics, bytes.Bytes); - } - - private static DbCheckpointOperationResult PreviewCheckpoint(string fullDbPath, string name) - { - ValidateCheckpointName(name); - var checkpointPath = GetCheckpointPath(fullDbPath, name); - var diagnostics = new List(); - if (Directory.Exists(LongPath.EnsureWindowsPrefix(checkpointPath))) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_already_exists", - "A checkpoint with this name already exists; running without --dry-run would fail.", - ConsoleUi.FormatBoundedValue(checkpointPath))); - } - - var files = ReadCheckpointSourceFiles(fullDbPath, diagnostics); - return new DbCheckpointOperationResult(name, checkpointPath, files.Files, files.Truncated, diagnostics, files.Bytes); - } - - private static (List Files, long Bytes, bool Truncated) ReadCheckpointSourceFiles( - string fullDbPath, - List diagnostics) - { - var files = new List(); - long bytes = 0; - foreach (var source in new[] { fullDbPath, fullDbPath + "-wal", fullDbPath + "-shm" }) - { - try - { - if (!TryGetRegularExistingFile(source, out var normalizedSource)) - continue; - - files.Add(Path.GetFileName(source) ?? source); - bytes += new FileInfo(normalizedSource).Length; - } - catch (Exception ex) when (IsRecoverableFilesystemException(ex)) - { - diagnostics.Add(CreateCheckpointDiagnostic( - "checkpoint_source_file_stat_failed", - $"Unable to inspect checkpoint source file ({CommandErrorWriter.FormatSanitizedException(ex)}).", - source)); - return (files, bytes, Truncated: true); - } - } - - files.Sort(StringComparer.Ordinal); - return (files, bytes, Truncated: false); - } - - private static DbCheckpointListReadResult ListCheckpoints(string fullDbPath, int limit) - { - var root = GetCheckpointRoot(fullDbPath); - var diagnostics = new List(); - if (!Directory.Exists(root)) - return new DbCheckpointListReadResult([], DirectoryEnumerationTruncated: false, FileInspectionTruncated: false, diagnostics); - - var dbFileName = Path.GetFileName(fullDbPath); - var entries = new List(); - var checkpointsTruncated = false; - var directoriesInspected = 0; - var directories = EnumerateCheckpointDirectories(root, diagnostics, limit + 1); - checkpointsTruncated |= directories.Truncated; - foreach (var path in directories.Items) - { - if (directoriesInspected >= limit) - { - checkpointsTruncated = true; - break; - } - - directoriesInspected++; - if (Path.GetFileName(path).StartsWith(".tmp-", StringComparison.Ordinal)) - continue; - if (!File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(path, dbFileName)))) - continue; - - DirectoryInfo info; - DateTime createdAtUtc; - try - { - info = new DirectoryInfo(path); - createdAtUtc = info.CreationTimeUtc; - } - catch (Exception ex) when (IsRecoverableFilesystemException(ex)) - { - diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_directory_stat_failed", "Unable to inspect checkpoint directory metadata.", path)); - checkpointsTruncated = true; - continue; - } - - var bytes = SumCheckpointBytes(path, diagnostics); - entries.Add(new DbCheckpointListEntryJsonResult( - info.Name, - path, - createdAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture), - bytes.Bytes, - bytes.Truncated)); - } - - entries.Sort((left, right) => - { - var createdCompare = string.Compare(right.CreatedAtUtc, left.CreatedAtUtc, StringComparison.Ordinal); - return createdCompare != 0 - ? createdCompare - : string.Compare(left.Name, right.Name, StringComparison.Ordinal); - }); - return new DbCheckpointListReadResult( - entries, - checkpointsTruncated, - entries.Any(entry => entry.FilesTruncated), - diagnostics); - } - - private static DbRestoreBackupReadResult ListRestoreBackups(string fullDbPath, int limit) - { - var parent = Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."); - var diagnostics = new List(); - if (!Directory.Exists(parent)) - return new DbRestoreBackupReadResult([], DirectoryEnumerationTruncated: false, FileInspectionTruncated: false, diagnostics); - - var dbFileName = Path.GetFileName(fullDbPath); - var prefix = GetRestoreBackupDirectoryPrefix(fullDbPath); - var entries = new List(); - var backupsTruncated = false; - var directoriesInspected = 0; - var directories = EnumerateRestoreBackupDirectories(parent, prefix, diagnostics, limit + 1); - backupsTruncated |= directories.Truncated; - foreach (var path in directories.Items) - { - if (directoriesInspected >= limit) - { - backupsTruncated = true; - break; - } - - directoriesInspected++; - if (!File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(path, dbFileName)))) - continue; - - DirectoryInfo info; - DateTime createdAtUtc; - try - { - info = new DirectoryInfo(path); - createdAtUtc = info.CreationTimeUtc; - } - catch (Exception ex) when (IsRecoverableFilesystemException(ex)) - { - diagnostics.Add(CreateCheckpointDiagnostic("restore_backup_directory_stat_failed", "Unable to inspect restore backup directory metadata.", path)); - backupsTruncated = true; - continue; - } - - var bytes = SumCheckpointBytes(path, diagnostics); - entries.Add(new DbRestoreBackupEntryJsonResult( - info.Name, - path, - createdAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture), - bytes.Bytes, - bytes.Truncated)); - } - - entries.Sort((left, right) => - { - var createdCompare = string.Compare(right.CreatedAtUtc, left.CreatedAtUtc, StringComparison.Ordinal); - return createdCompare != 0 - ? createdCompare - : string.Compare(right.Name, left.Name, StringComparison.Ordinal); - }); - return new DbRestoreBackupReadResult(entries, backupsTruncated, entries.Any(entry => entry.FilesTruncated), diagnostics); - } - - private static DbRestoreBackupPruneResult PruneRestoreBackups(string fullDbPath, int keep, bool dryRun) - { - var result = ListRestoreBackups(fullDbPath, RestoreBackupPruneScanLimit); - var diagnostics = result.Diagnostics; - if (result.DirectoryEnumerationTruncated) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "restore_backup_prune_truncated", - "Restore backup pruning was skipped because backup enumeration reached the scan limit.", - ConsoleUi.FormatBoundedValue(fullDbPath))); - return new DbRestoreBackupPruneResult( - Deleted: 0, - Retained: result.Entries.Count, - DeletedPaths: [], - RetainedPaths: result.Entries.Select(entry => entry.BackupPath).ToList(), - Truncated: true, - diagnostics); - } - - var retainedPaths = result.Entries - .Take(keep) - .Select(entry => entry.BackupPath) - .ToList(); - var deletedPaths = new List(); - var parent = Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."); - var prefix = GetRestoreBackupDirectoryPrefix(fullDbPath); - foreach (var entry in result.Entries.Skip(keep)) - { - if (dryRun) - { - if (TryValidateTemporaryDirectoryCleanupTarget(entry.BackupPath, parent, prefix, out _, out var validationFailure)) - deletedPaths.Add(entry.BackupPath); - else - { - diagnostics.Add(new DbDiagnosticJsonResult( - "restore_backup_delete_skipped", - $"Restore backup deletion would be skipped: {validationFailure}.", - ConsoleUi.FormatBoundedValue(entry.BackupPath))); - retainedPaths.Add(entry.BackupPath); - } - } - else if (TryDeleteRestoreBackupDirectory(fullDbPath, entry.BackupPath, diagnostics)) - { - deletedPaths.Add(entry.BackupPath); - } - else - { - retainedPaths.Add(entry.BackupPath); - } - } - - return new DbRestoreBackupPruneResult( - deletedPaths.Count, - retainedPaths.Count, - deletedPaths, - retainedPaths, - result.Truncated, - diagnostics); - } - - private static (List Items, bool Truncated) EnumerateRestoreBackupDirectories( - string parent, - string prefix, - List diagnostics, - int limit) - { - var directories = new List(); - try - { - foreach (var directory in CodeIndex.FileSystemTraversalPolicy.EnumerateDirectories(parent, prefix + "*")) - { - if (directories.Count >= limit) - return (directories, Truncated: true); - if (Path.GetFileName(directory).StartsWith(prefix, StringComparison.Ordinal)) - directories.Add(directory); - } - - return (directories, Truncated: false); - } - catch (Exception ex) when (IsRecoverableFilesystemException(ex)) - { - diagnostics.Add(CreateCheckpointDiagnostic("restore_backup_directory_enumeration_failed", "Unable to enumerate every restore backup directory.", parent)); - return (directories, Truncated: true); - } - } - - private static bool TryDeleteRestoreBackupDirectory( - string fullDbPath, - string backupPath, - List diagnostics) - { - var parent = Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."); - var prefix = GetRestoreBackupDirectoryPrefix(fullDbPath); - if (!TryValidateTemporaryDirectoryCleanupTarget(backupPath, parent, prefix, out var fullPath, out var validationFailure)) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "restore_backup_delete_skipped", - $"Skipped deleting restore backup directory: {validationFailure}.", - ConsoleUi.FormatBoundedValue(backupPath))); - return false; - } - - try - { - if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath))) - return false; - - Directory.Delete(LongPath.EnsureWindowsPrefix(fullPath), recursive: true); - return true; - } - catch (Exception ex) when (IsRecoverableFilesystemException(ex)) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "restore_backup_delete_failed", - $"Unable to delete restore backup directory ({CommandErrorWriter.FormatSanitizedException(ex)}).", - ConsoleUi.FormatBoundedValue(fullPath))); - return false; - } - } - - private static bool TryDeleteCheckpointDirectory( - string fullDbPath, - string checkpointPath, - List diagnostics) - { - if (!TryValidateCheckpointDirectoryTarget( - fullDbPath, - checkpointPath, - out var fullPath, - out var validationFailure)) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_delete_skipped", - $"Skipped deleting checkpoint directory: {validationFailure}.", - ConsoleUi.FormatBoundedValue(checkpointPath))); - return false; - } - - try - { - if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath))) - return false; - if (!TryValidateCheckpointDirectoryTarget( - fullDbPath, - fullPath, - out fullPath, - out validationFailure)) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_delete_skipped", - $"Skipped deleting checkpoint directory after revalidation: {validationFailure}.", - ConsoleUi.FormatBoundedValue(checkpointPath))); - return false; - } - - Directory.Delete(LongPath.EnsureWindowsPrefix(fullPath), recursive: true); - return true; - } - catch (Exception ex) when (IsRecoverableFilesystemException(ex)) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_delete_failed", - $"Unable to delete checkpoint directory ({CommandErrorWriter.FormatSanitizedException(ex)}).", - ConsoleUi.FormatBoundedValue(fullPath))); - return false; - } - } - - private static bool TryValidateCheckpointDirectoryTarget( - string fullDbPath, - string checkpointPath, - out string fullPath, - out string failureReason) - { - var checkpointRoot = GetCheckpointRoot(fullDbPath); - var rootStatus = FileSystemBoundary.TryGetAttributes(checkpointRoot, out var rootAttributes); - if (rootStatus != FileSystemBoundaryProbeStatus.Found) - { - fullPath = string.Empty; - failureReason = "checkpoint root is unavailable"; - return false; - } - if ((rootAttributes & FileAttributes.Directory) == 0 - || FileSystemBoundary.IsSymlinkOrReparsePoint(rootAttributes) - || FileSystemBoundary.IsDevice(rootAttributes)) - { - fullPath = string.Empty; - failureReason = "checkpoint root is not a regular directory"; - return false; - } - - var options = new DirectoryCleanupBoundaryOptions( - ExpectedNamePrefix: string.Empty, - OutsideRootReason: "target is outside the checkpoint root", - PrefixMismatchReason: "target name is not a checkpoint name", - UnsafeDirectoryReason: "target is not a regular checkpoint directory"); - return FileSystemBoundary.TryValidateDirectoryCleanupTarget( - checkpointPath, - checkpointRoot, - options, - out fullPath, - out failureReason); - } - - private static (List Items, bool Truncated) EnumerateCheckpointDirectories( - string root, - List diagnostics, - int limit) - { - var directories = new List(); - try - { - foreach (var directory in CodeIndex.FileSystemTraversalPolicy.EnumerateDirectories(root)) - { - if (directories.Count >= limit) - return (directories, Truncated: true); - directories.Add(directory); - } - - return (directories, Truncated: false); - } - catch (Exception ex) when (IsRecoverableFilesystemException(ex)) - { - diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_directory_enumeration_failed", "Unable to enumerate every checkpoint directory.", root)); - return (directories, Truncated: true); - } - } - - private static (List Items, bool Truncated) EnumerateCheckpointFileNames( - string checkpointPath, - List diagnostics) - { - var files = new List(); - var truncated = false; - try - { - if (EnumerateCheckpointFileNamesForTesting != null) - { - foreach (var name in EnumerateCheckpointFileNamesForTesting(checkpointPath)) - { - if (files.Count >= CheckpointFileInspectLimit) - { - truncated = true; - break; - } - - if (name is not null) - files.Add(name); - } - } - else - { - var listedFiles = EnumerateCheckpointFiles(checkpointPath, diagnostics, CheckpointFileInspectLimit + 1); - foreach (var file in listedFiles.Items) - { - if (files.Count >= CheckpointFileInspectLimit) - { - truncated = true; - break; - } - - var name = Path.GetFileName(file); - if (name is not null) - files.Add(name); - } - - truncated = listedFiles.Truncated || listedFiles.Items.Count > CheckpointFileInspectLimit; - } - } - catch (Exception ex) when (IsRecoverableFilesystemException(ex)) - { - diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_file_enumeration_failed", "Unable to enumerate every checkpoint file.", checkpointPath)); - truncated = true; - } - - files.Sort(StringComparer.Ordinal); - return (files, truncated); - } - - private static (long Bytes, bool Truncated) SumCheckpointBytes(string checkpointPath, List diagnostics) - { - long bytes = 0; - var filesSeen = 0; - var files = EnumerateCheckpointFiles(checkpointPath, diagnostics, CheckpointFileInspectLimit + 1); - foreach (var file in files.Items) - { - if (filesSeen >= CheckpointFileInspectLimit) - return (bytes, Truncated: true); - - try - { - bytes += new FileInfo(file).Length; - } - catch (Exception ex) when (IsRecoverableFilesystemException(ex)) - { - diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_file_stat_failed", "Unable to inspect every checkpoint file.", file)); - return (bytes, Truncated: true); - } - - filesSeen++; - } - - return (bytes, files.Truncated); - } - - private static (List Items, bool Truncated) EnumerateCheckpointFiles( - string checkpointPath, - List diagnostics, - int limit) - { - var files = new List(); - try - { - foreach (var file in EnumerateCheckpointFilesForTesting?.Invoke(checkpointPath) ?? CodeIndex.FileSystemTraversalPolicy.EnumerateFiles(checkpointPath)) - { - if (files.Count >= limit) - return (files, Truncated: true); - files.Add(file); - } - - return (files, Truncated: false); - } - catch (Exception ex) when (IsRecoverableFilesystemException(ex)) - { - diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_file_enumeration_failed", "Unable to enumerate every checkpoint file.", checkpointPath)); - return (files, Truncated: true); - } - } - - private static DbRestorePreviewResult PreviewRestoreCheckpoint( - string fullDbPath, - string name, - string checkpointPath) - { - ValidateCheckpointName(name); - var diagnostics = new List(); - var pathsValid = TryValidateCheckpointDirectoryTarget( - fullDbPath, - checkpointPath, - out var validatedCheckpointPath, - out var checkpointPathFailure); - if (!pathsValid) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_path_invalid", - $"Checkpoint directory failed path validation: {checkpointPathFailure}.", - ConsoleUi.FormatBoundedValue(checkpointPath))); - } - - var manifestValid = false; - var payload = new DbCheckpointPayloadValidationResult( - PathsValid: false, - Files: [], - Bytes: 0); - if (pathsValid) - { - manifestValid = TryValidateCheckpointManifest( - fullDbPath, - name, - validatedCheckpointPath, - diagnostics, - out _); - payload = ValidateCheckpointPayload( - fullDbPath, - validatedCheckpointPath, - diagnostics); - pathsValid = payload.PathsValid; - } - - var availableSpace = TryGetAvailableFreeSpace(fullDbPath, diagnostics); - bool? spaceSufficient = availableSpace is long available ? available >= payload.Bytes : null; - if (spaceSufficient == false) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_space_insufficient", - "The destination filesystem does not have enough free space to stage the checkpoint payload.", - ConsoleUi.FormatBoundedValue(Path.GetDirectoryName(fullDbPath) ?? fullDbPath))); - } - - var ready = manifestValid && pathsValid && spaceSufficient == true; - return new DbRestorePreviewResult( - ready, - manifestValid, - pathsValid, - availableSpace.HasValue, - spaceSufficient, - payload.Bytes, - availableSpace, - payload.Files, - payload.Bytes, - diagnostics); - } - - private static bool TryGetCheckpointRetentionTimestamp( - string fullDbPath, - string name, - string checkpointPath, - List diagnostics, - out DateTimeOffset createdAtUtc) - { - createdAtUtc = default; - if (!TryValidateCheckpointDirectoryTarget( - fullDbPath, - checkpointPath, - out var validatedCheckpointPath, - out var checkpointPathFailure)) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_retention_invalid", - $"Checkpoint cannot occupy a retention slot because its directory is unsafe: {checkpointPathFailure}.", - ConsoleUi.FormatBoundedValue(checkpointPath))); - return false; - } - - var manifestValid = TryValidateCheckpointManifest( - fullDbPath, - name, - validatedCheckpointPath, - diagnostics, - out createdAtUtc); - var payload = ValidateCheckpointPayload( - fullDbPath, - validatedCheckpointPath, - diagnostics); - if (manifestValid && payload.PathsValid) - return true; - - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_retention_invalid", - "Checkpoint cannot occupy a retention slot because restore validation failed.", - ConsoleUi.FormatBoundedValue(checkpointPath))); - return false; - } - - private static DbCheckpointPayloadValidationResult ValidateCheckpointPayload( - string fullDbPath, - string checkpointPath, - List diagnostics) - { - var pathsValid = true; - var files = new List(); - long bytes = 0; - var dbFileName = Path.GetFileName(fullDbPath); - foreach (var fileName in new[] { dbFileName, dbFileName + "-wal", dbFileName + "-shm" }) - { - var path = Path.Combine(checkpointPath, fileName); - try - { - if (!TryGetRegularExistingFile(path, out var normalizedPath)) - { - if (string.Equals(fileName, dbFileName, StringComparison.Ordinal)) - { - pathsValid = false; - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_payload_missing", - "Checkpoint database payload is missing.", - ConsoleUi.FormatBoundedValue(path))); - } - - continue; - } - - files.Add(fileName); - bytes = checked(bytes + new FileInfo(normalizedPath).Length); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException or OverflowException) - { - pathsValid = false; - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_payload_invalid", - $"Checkpoint payload failed regular-file validation ({CommandErrorWriter.FormatSanitizedException(ex)}).", - ConsoleUi.FormatBoundedValue(path))); - } - } - - files.Sort(StringComparer.Ordinal); - return new DbCheckpointPayloadValidationResult(pathsValid, files, bytes); - } - - private static bool TryValidateCheckpointManifest( - string fullDbPath, - string name, - string checkpointPath, - List diagnostics, - out DateTimeOffset createdAtUtc) - { - createdAtUtc = default; - var manifestPath = Path.Combine(checkpointPath, "manifest.txt"); - try - { - if (!TryGetRegularExistingFile(manifestPath, out var normalizedManifestPath)) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_manifest_missing", - "Checkpoint manifest is missing.", - ConsoleUi.FormatBoundedValue(manifestPath))); - return false; - } - - var length = new FileInfo(normalizedManifestPath).Length; - if (length > CheckpointManifestByteLimit) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_manifest_too_large", - $"Checkpoint manifest exceeds the {CheckpointManifestByteLimit:N0}-byte validation limit.", - ConsoleUi.FormatBoundedValue(manifestPath))); - return false; - } - - var values = new Dictionary(StringComparer.Ordinal); - using var reader = new StringReader(File.ReadAllText(normalizedManifestPath)); - while (reader.ReadLine() is { } line) - { - if (line.Length == 0) - continue; - var separator = line.IndexOf('='); - if (separator <= 0 || !values.TryAdd(line[..separator], line[(separator + 1)..])) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_manifest_invalid", - "Checkpoint manifest contains a malformed or duplicate field.", - ConsoleUi.FormatBoundedValue(manifestPath))); - return false; - } - } - - var expectedDbFile = Path.GetFileName(fullDbPath); - var valid = values.TryGetValue("name", out var manifestName) - && string.Equals(manifestName, name, StringComparison.Ordinal) - && values.TryGetValue("db_file", out var manifestDbFile) - && string.Equals(manifestDbFile, expectedDbFile, StringComparison.Ordinal) - && string.Equals(Path.GetFileName(manifestDbFile), manifestDbFile, StringComparison.Ordinal) - && values.TryGetValue("created_at_utc", out var createdAt) - && DateTimeOffset.TryParse( - createdAt, - System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.RoundtripKind, - out createdAtUtc); - if (valid) - return true; - - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_manifest_invalid", - "Checkpoint manifest name, database file, or UTC timestamp does not match the requested restore.", - ConsoleUi.FormatBoundedValue(manifestPath))); - return false; - } - catch (Exception ex) when (IsRecoverableFilesystemException(ex) || ex is InvalidOperationException) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_manifest_invalid", - $"Checkpoint manifest could not be validated ({CommandErrorWriter.FormatSanitizedException(ex)}).", - ConsoleUi.FormatBoundedValue(manifestPath))); - return false; - } - } - - private static long? TryGetAvailableFreeSpace( - string fullDbPath, - List diagnostics) - { - try - { - var destinationDirectory = Path.GetDirectoryName(fullDbPath); - if (string.IsNullOrWhiteSpace(destinationDirectory)) - throw new IOException("destination filesystem directory is unavailable"); - var resolvedDestinationDirectory = ResolveDestinationDirectoryForSpaceProbe(destinationDirectory); - - if (AvailableFreeSpaceForTesting is not null) - return AvailableFreeSpaceForTesting(resolvedDestinationDirectory); - - if (OperatingSystem.IsWindows()) - { - if (!GetDiskFreeSpaceEx( - resolvedDestinationDirectory, - out var availableBytes, - out _, - out _)) - { - throw new IOException( - "destination filesystem volume is unavailable", - new System.ComponentModel.Win32Exception(Marshal.GetLastPInvokeError())); - } - - return availableBytes > long.MaxValue - ? long.MaxValue - : (long)availableBytes; - } - - DriveInfo? destinationDrive = null; - var destinationRootLength = -1; - foreach (var drive in DriveInfo.GetDrives()) - { - try - { - if (!drive.IsReady) - continue; - var driveRoot = drive.RootDirectory.FullName; - if (driveRoot.Length <= destinationRootLength - || !IsPathWithinDriveRoot(driveRoot, resolvedDestinationDirectory)) - { - continue; - } - - destinationDrive = drive; - destinationRootLength = driveRoot.Length; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) - { - // Ignore an unreadable unrelated mount and keep looking for the - // longest ready mount that contains the destination directory. - } - } - - if (destinationDrive is null) - throw new IOException("destination filesystem volume is unavailable"); - return destinationDrive.AvailableFreeSpace; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) - { - diagnostics.Add(new DbDiagnosticJsonResult( - "checkpoint_space_unavailable", - $"Available destination space could not be determined ({CommandErrorWriter.FormatSanitizedException(ex)}).", - ConsoleUi.FormatBoundedValue(Path.GetDirectoryName(fullDbPath) ?? fullDbPath))); - return null; - } - } - - private static bool IsPathWithinDriveRoot(string driveRoot, string path) - { - var normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(driveRoot)); - var normalizedPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); - var comparison = OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - if (string.Equals(normalizedRoot, normalizedPath, comparison)) - return true; - - var rootWithSeparator = normalizedRoot.EndsWith(Path.DirectorySeparatorChar) - || normalizedRoot.EndsWith(Path.AltDirectorySeparatorChar) - ? normalizedRoot - : normalizedRoot + Path.DirectorySeparatorChar; - return normalizedPath.StartsWith(rootWithSeparator, comparison); - } - - private static string ResolveDestinationDirectoryForSpaceProbe(string destinationDirectory) - { - var fullPath = Path.GetFullPath(destinationDirectory); - var root = Path.GetPathRoot(fullPath); - if (string.IsNullOrWhiteSpace(root)) - throw new IOException("destination filesystem root is unavailable"); - - var current = root; - var relativePath = fullPath[root.Length..]; - foreach (var segment in relativePath.Split( - [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], - StringSplitOptions.RemoveEmptyEntries)) - { - current = Path.Combine(current, segment); - var target = new DirectoryInfo(current).ResolveLinkTarget(returnFinalTarget: true); - if (target is not null) - current = target.FullName; - } - - return Path.GetFullPath(current); - } - - [DllImport("kernel32.dll", EntryPoint = "GetDiskFreeSpaceExW", CharSet = CharSet.Unicode, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GetDiskFreeSpaceEx( - string directoryName, - out ulong freeBytesAvailable, - out ulong totalNumberOfBytes, - out ulong totalNumberOfFreeBytes); - - private static string RestoreCheckpoint(string fullDbPath, string name, string checkpointPath) - { - ValidateCheckpointName(name); - if (!TryValidateCheckpointDirectoryTarget( - fullDbPath, - checkpointPath, - out checkpointPath, - out var checkpointPathFailure)) - { - throw new InvalidOperationException( - $"checkpoint path validation failed: {checkpointPathFailure}"); - } - - SqliteConnection.ClearAllPools(); - var checkpointDbPath = Path.Combine(checkpointPath, Path.GetFileName(fullDbPath)); - if (!File.Exists(LongPath.EnsureWindowsPrefix(checkpointDbPath))) - throw new InvalidOperationException($"checkpoint is incomplete: {FormatCheckpointNameForDiagnostic(name)}"); - - var restorePathSuffix = MakeRestorePathSuffix(); - var restoreTempPath = fullDbPath + ".restore-tmp-" + restorePathSuffix; - var backupPath = fullDbPath + ".restore-backup-" + restorePathSuffix; - DataDirectorySecurity.CreateSensitiveDirectory(restoreTempPath); - try - { - CopyIfExists(checkpointDbPath, Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)), privateDestination: true); - CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-wal"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true); - CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-shm"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true); - if (!File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath))))) - throw new InvalidOperationException($"checkpoint staging failed: {FormatCheckpointNameForDiagnostic(name)}"); - - DataDirectorySecurity.CreateSensitiveDirectory(backupPath); - MoveIfExists(fullDbPath, Path.Combine(backupPath, Path.GetFileName(fullDbPath)), privateDestination: true); - MoveIfExists(fullDbPath + "-wal", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true); - MoveIfExists(fullDbPath + "-shm", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true); - - RestoreFailureAfterBackupForTesting?.Invoke(); - - MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)), fullDbPath, privateDestination: true); - MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal", privateDestination: true); - MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm"), fullDbPath + "-shm", privateDestination: true); - } - catch (Exception primaryEx) - { - DbDiagnosticJsonResult? rollbackFailure = null; - try - { - RestoreBackedUpFiles(fullDbPath, backupPath); - } - catch (Exception rollbackEx) when (IsRecoverableRestoreException(rollbackEx)) - { - rollbackFailure = new DbDiagnosticJsonResult( - "restore_rollback_failed", - $"Failed to roll back database restore from backup ({CommandErrorWriter.FormatSanitizedException(rollbackEx)}).", - ConsoleUi.FormatBoundedValue(backupPath)); - CommandErrorWriter.WriteStderr($"Warning [{rollbackFailure.Code}]: {rollbackFailure.Message} Backup: {rollbackFailure.Path}"); - } - - throw new DbRestoreOperationException(primaryEx, checkpointPath, backupPath, rollbackFailure); - } - finally - { - TryDeleteTemporaryDirectory( - restoreTempPath, - "restore temporary directory", - Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."), - Path.GetFileName(fullDbPath) + ".restore-tmp-"); - } - - return backupPath; - } - - private static void ValidateCheckpointName(string name) - { - if (string.IsNullOrWhiteSpace(name) - || name is "." or ".." - || name.IndexOfAny(InvalidCheckpointNameChars) >= 0 - || name.Contains(Path.DirectorySeparatorChar) - || (Path.AltDirectorySeparatorChar != '\0' && name.Contains(Path.AltDirectorySeparatorChar))) - throw new ArgumentException($"invalid checkpoint name: {FormatCheckpointNameForDiagnostic(name)}"); - - if (name.Length > MaxCheckpointNameLength) - throw new ArgumentException($"checkpoint name is too long ({name.Length} characters; max {MaxCheckpointNameLength}): {FormatCheckpointNameForDiagnostic(name)}"); - } - - private static string FormatCheckpointNameForDiagnostic(string name) - => ConsoleUi.FormatBoundedValue(name, CheckpointNameDiagnosticTextLimit); - - private static string MakeTimestampCheckpointName() - => GetUtcNow().ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture) - + "-" - + Guid.NewGuid().ToString("N"); - - private static string MakeRestorePathSuffix() - => GetUtcNow().ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture) - + "-" - + Guid.NewGuid().ToString("N"); - - private static DateTimeOffset GetUtcNow() - => UtcNowForTesting?.Invoke() ?? DateTimeOffset.UtcNow; - - private static string GetCheckpointRoot(string fullDbPath) - => fullDbPath + CheckpointsDirectorySuffix; - - private static string GetRestoreBackupDirectoryPrefix(string fullDbPath) - => Path.GetFileName(fullDbPath) + ".restore-backup-"; - - private static string GetCheckpointPath(string fullDbPath, string name) - { - ValidateCheckpointName(name); - return Path.Combine(GetCheckpointRoot(fullDbPath), name); - } - - private static void CopyIfExists(string source, string destination, bool privateDestination = false) - { - if (!TryGetRegularExistingFile(source, out var normalizedSource)) - return; - - if (!privateDestination || OperatingSystem.IsWindows()) - { - File.Copy(normalizedSource, LongPath.EnsureWindowsPrefix(destination), overwrite: false); - if (privateDestination) - DataDirectorySecurity.ApplyPrivateFileMode(destination); - return; - } - - using (var input = new FileStream(normalizedSource, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var output = new FileStream( - LongPath.EnsureWindowsPrefix(destination), - new FileStreamOptions - { - Mode = FileMode.CreateNew, - Access = FileAccess.Write, - Share = FileShare.None, - UnixCreateMode = DataDirectorySecurity.PrivateFileMode, - })) - { - input.CopyTo(output); - output.Flush(flushToDisk: true); - } - - DataDirectorySecurity.ApplyPrivateFileMode(destination); - } - - private static void MoveIfExists(string source, string destination, bool privateDestination = false, bool overwrite = false) - { - if (!TryGetRegularExistingFile(source, out var normalizedSource)) - return; - - AtomicFileWriter.MoveFile( - normalizedSource, - destination, - overwrite, - privateDestination ? DataDirectorySecurity.ApplyPrivateFileMode : null); - } - - private static bool TryGetRegularExistingFile(string path, out string normalizedPath) - { - normalizedPath = LongPath.EnsureWindowsPrefix(path); - FileAttributes attributes; - try - { - attributes = File.GetAttributes(normalizedPath); - } - catch (FileNotFoundException) - { - return false; - } - catch (DirectoryNotFoundException) - { - return false; - } - - if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) - throw new InvalidOperationException($"checkpoint file is not a regular file: {ConsoleUi.FormatBoundedValue(path)}"); - - return true; - } - - private static void RestoreBackedUpFiles(string fullDbPath, string backupPath) - { - if (!Directory.Exists(backupPath)) - return; - - MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath)), fullDbPath, privateDestination: true, overwrite: true); - MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal", privateDestination: true, overwrite: true); - MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"), fullDbPath + "-shm", privateDestination: true, overwrite: true); - } - - internal static void TryDeleteTemporaryDirectory(string path, string cleanupDescription, string safeRoot, string expectedNamePrefix) - { - try - { - if (!TryValidateTemporaryDirectoryCleanupTarget(path, safeRoot, expectedNamePrefix, out var fullPath, out var validationFailure)) - { - CommandErrorWriter.WriteStderr($"Warning: skipped deleting {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({validationFailure})."); - return; - } - - if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath))) - return; - - if (!TryValidateTemporaryDirectoryCleanupTarget(fullPath, safeRoot, expectedNamePrefix, out fullPath, out validationFailure)) - { - CommandErrorWriter.WriteStderr($"Warning: skipped deleting {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({validationFailure})."); - return; - } - - if (DeleteTemporaryDirectoryForTesting != null) - DeleteTemporaryDirectoryForTesting(fullPath); - else - Directory.Delete(LongPath.EnsureWindowsPrefix(fullPath), recursive: true); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) - { - CommandErrorWriter.WriteWarning($"failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); - } - } - - private static bool TryValidateTemporaryDirectoryCleanupTarget( - string path, - string safeRoot, - string expectedNamePrefix, - out string fullPath, - out string failureReason) - { - var options = new DirectoryCleanupBoundaryOptions( - expectedNamePrefix, - "target is outside the expected cleanup root", - "target name does not match the expected temporary-directory prefix", - "target is not a regular temporary directory"); - return FileSystemBoundary.TryValidateDirectoryCleanupTarget( - path, - safeRoot, - options, - out fullPath, - out failureReason); - } - - internal static DbCommandOptions ParseArgs(string[] args) - { - var dbPath = Path.Combine(".cdidx", "codeindex.db"); - var json = false; - var integrityCheck = false; - var schema = false; - var prune = false; - var pruneDryRun = false; - var pruneApply = false; - var checkpoint = false; - var listCheckpoints = false; - var restore = false; - var restoreBackups = false; - var checkpointsList = false; - var checkpointsDelete = false; - var checkpointsPrune = false; - var checkpointsKeep = DefaultRestoreBackupKeepCount; - var restoreBackupsList = false; - var restoreBackupsPrune = false; - var restoreBackupsKeep = DefaultRestoreBackupKeepCount; - var schemaSummaryOnly = false; - var schemaEntryLimit = SchemaEntryLimit; - var schemaSqlTextLimit = SchemaSqlTextLimit; - bool? schemaIncludeInternal = null; - var schemaSpecificOptionSeen = false; - string? parsedSchemaType = null; - string? parsedSchemaName = null; - string? name = null; - string? parseError = null; - - for (var i = 0; i < args.Length; i++) - { - switch (args[i]) - { - case "--db" when i + 1 < args.Length: - dbPath = args[++i]; - break; - case "--db": - parseError = "--db requires a value"; - break; - case "--json": - json = true; - break; - case "--integrity-check": - integrityCheck = true; - break; - case "integrity": - integrityCheck = true; - break; - case "schema": - schema = true; - break; - case "--type" when i + 1 < args.Length: - schemaSpecificOptionSeen = true; - var schemaType = args[++i].Trim().ToLowerInvariant(); - if (!SchemaObjectTypes.Contains(schemaType, StringComparer.Ordinal)) - parseError = "--type must be one of table, index, trigger, or view"; - else - parsedSchemaType = schemaType; - break; - case "--type": - parseError = "--type requires a value"; - break; - case "--name" when i + 1 < args.Length: - schemaSpecificOptionSeen = true; - parsedSchemaName = args[++i]; - break; - case "--name": - parseError = "--name requires a value"; - break; - case "--summary-only": - schemaSpecificOptionSeen = true; - schemaSummaryOnly = true; - break; - case "--limit" when i + 1 < args.Length: - schemaSpecificOptionSeen = true; - if (!int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out schemaEntryLimit) - || schemaEntryLimit < 0 - || schemaEntryLimit > SchemaEntryLimit) - { - parseError = $"--limit must be an integer from 0 to {SchemaEntryLimit}"; - } - break; - case "--limit": - parseError = "--limit requires a value"; - break; - case "--max-sql-chars" when i + 1 < args.Length: - schemaSpecificOptionSeen = true; - if (!int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out schemaSqlTextLimit) - || schemaSqlTextLimit < 0 - || schemaSqlTextLimit > SchemaSqlTextLimit) - { - parseError = $"--max-sql-chars must be an integer from 0 to {SchemaSqlTextLimit}"; - } - break; - case "--max-sql-chars": - parseError = "--max-sql-chars requires a value"; - break; - case "--include-internal": - schemaSpecificOptionSeen = true; - if (schemaIncludeInternal == false) - parseError = "--include-internal and --exclude-internal cannot be combined"; - else - schemaIncludeInternal = true; - break; - case "--exclude-internal": - schemaSpecificOptionSeen = true; - if (schemaIncludeInternal == true) - parseError = "--include-internal and --exclude-internal cannot be combined"; - else - schemaIncludeInternal = false; - break; - case "prune": - prune = true; - break; - case "checkpoint": - checkpoint = true; - if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) - name = args[++i]; - break; - case "checkpoints": - listCheckpoints = true; - break; - case "restore": - restore = true; - if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) - name = args[++i]; - else - parseError = "restore requires a checkpoint name"; - break; - case "restore-backups": - restoreBackups = true; - break; - case "--dry-run": - pruneDryRun = true; - break; - case "--apply": - pruneApply = true; - break; - case "--prune": - if (restoreBackups) - restoreBackupsPrune = true; - else if (listCheckpoints) - checkpointsPrune = true; - else - parseError = "--prune is only valid with `cdidx db checkpoints --prune` or `cdidx db restore-backups --prune`"; - break; - case "--delete" when i + 1 < args.Length - && !args[i + 1].StartsWith("-", StringComparison.Ordinal): - if (!listCheckpoints) - { - parseError = "--delete is only valid with `cdidx db checkpoints --delete `"; - break; - } - - checkpointsDelete = true; - name = args[++i]; - break; - case "--delete": - parseError = "--delete requires a checkpoint name"; - break; - case "--keep" when i + 1 < args.Length: - if (!restoreBackups && !checkpointsPrune) - { - parseError = "--keep is only valid with checkpoint or restore-backup pruning"; - break; - } - - if (!int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var parsedKeep) - || parsedKeep < 0 - || parsedKeep > MaxRestoreBackupKeepCount) - { - parseError = $"--keep must be an integer from 0 to {MaxRestoreBackupKeepCount}"; - } - else if (restoreBackups) - { - restoreBackupsKeep = parsedKeep; - } - else - { - checkpointsKeep = parsedKeep; - } - break; - case "--keep": - parseError = "--keep requires a value"; - break; - case "--list": - if (listCheckpoints) - { - checkpointsList = true; - break; - } - if (restoreBackups) - { - restoreBackupsList = true; - break; - } - - parseError = "--list is only valid with `cdidx db checkpoints --list`"; - break; - case "--help" or "-h": - return new DbCommandOptions { ShowHelp = true, DbPath = dbPath, Json = json }; - default: - if (args[i].StartsWith('-')) - parseError = $"db does not support option: '{args[i]}'"; - else - parseError = $"unknown db command or argument: '{args[i]}'"; - break; - } - - if (parseError != null) - break; - } - - if (parseError is null && restoreBackups && pruneApply) - parseError = "--apply is not supported with `cdidx db restore-backups`; `--prune` is the explicit mutation opt-in."; - if (parseError is null && pruneDryRun && restoreBackups && !restoreBackupsPrune) - parseError = "--dry-run is only valid with `cdidx db restore-backups --prune`."; - if (parseError is null && pruneDryRun && listCheckpoints && !checkpointsDelete && !checkpointsPrune) - parseError = "--dry-run is only valid with checkpoint deletion or pruning."; - if (parseError is null && !schema && schemaSpecificOptionSeen) - parseError = "--type, --name, --summary-only, --limit, --max-sql-chars, --include-internal, and --exclude-internal are only valid with `cdidx db schema`."; - if (parseError is null && pruneDryRun && !prune && !checkpoint && !restore && !restoreBackups && !listCheckpoints) - parseError = "--dry-run is only valid with a supported preview operation."; - if (parseError is null && pruneApply && !prune) - parseError = "--apply is only valid with `cdidx db prune --apply`."; - - return new DbCommandOptions - { - DbPath = dbPath, - Json = json, - IntegrityCheck = integrityCheck, - Schema = schema, - Prune = prune, - PruneDryRun = pruneDryRun, - PruneApply = pruneApply, - Checkpoint = checkpoint, - ListCheckpoints = listCheckpoints, - CheckpointsList = checkpointsList, - CheckpointsDelete = checkpointsDelete, - CheckpointsPrune = checkpointsPrune, - CheckpointsKeep = checkpointsKeep, - CheckpointsDryRun = listCheckpoints && pruneDryRun, - Restore = restore, - RestoreDryRun = restore && pruneDryRun, - RestoreBackups = restoreBackups, - RestoreBackupsList = restoreBackupsList, - RestoreBackupsPrune = restoreBackupsPrune, - RestoreBackupsKeep = restoreBackupsKeep, - RestoreBackupsDryRun = restoreBackups && pruneDryRun, - SchemaSummaryOnly = schemaSummaryOnly, - SchemaEntryLimit = schemaEntryLimit, - SchemaSqlTextLimit = schemaSqlTextLimit, - SchemaIncludeInternal = schemaIncludeInternal ?? true, - SchemaType = parsedSchemaType, - SchemaName = parsedSchemaName, - CheckpointDryRun = checkpoint && pruneDryRun, - Name = name, - ParseError = parseError, - }; - } private static int WriteCommandError(bool json, JsonSerializerOptions jsonOptions, string message, int exitCode, string? hint = null, string? errorCode = null, string? category = null) { From 4092b90334698162ac76758010162745b95ccb1e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 18:53:50 +0900 Subject: [PATCH 057/101] Decompose query argument parsing --- .../Cli/QueryCommandRunner.ArgParsing.cs | 1746 +---------------- ...eryCommandRunner.ArgumentParser.Filters.cs | 143 ++ ...eryCommandRunner.ArgumentParser.General.cs | 220 +++ ...yCommandRunner.ArgumentParser.Locations.cs | 241 +++ ...eryCommandRunner.ArgumentParser.Results.cs | 277 +++ ...ueryCommandRunner.ArgumentParser.Search.cs | 287 +++ ...ueryCommandRunner.ArgumentParser.Status.cs | 127 ++ .../Cli/QueryCommandRunner.ArgumentParser.cs | 663 +++++++ 8 files changed, 1969 insertions(+), 1735 deletions(-) create mode 100644 src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs create mode 100644 src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs create mode 100644 src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Locations.cs create mode 100644 src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs create mode 100644 src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs create mode 100644 src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Status.cs create mode 100644 src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs index c2e80ab8b..3e83dc3a4 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgParsing.cs @@ -55,1741 +55,17 @@ public static QueryCommandOptions ParseArgs( bool applySearchSourceDefaults = false, bool allowOutlineSort = false, bool positionalGlobAsPath = false) - { - string? dbPath = null; - string? dataDir = null; - bool? json = null; - string jsonOutputFormat = JsonOutputFormatNdjson; - bool jsonOutputFormatExplicit = false; - int limit = ResolveDefaultPositiveInt(DefaultLimitEnvironmentVariable, DefaultQueryLimit, "--limit", out var defaultLimitError); - int? totalLimit = null; - string? lang = null; - string? kind = null; - string? unusedBucket = null; - string? minUnusedConfidence = null; - string? severity = null; - string? query = null; - bool rawFts = false; - bool includeBody = false; - int? bodyStartLine = null; - int? bodyLines = null; - bool countOnly = false; - bool groupPartials = false; - bool all = false; - bool strictNotFound = false; - bool allowPartial = false; - int? startLine = null; - int? endLine = null; - bool endLineExplicit = false; - int contextBefore = 0; - int contextAfter = 0; - int? symmetricContext = null; - int? explicitContextBefore = null; - int? explicitContextAfter = null; - int? focusLine = null; - int? focusColumn = null; - int focusLength = 1; - int snippetLines = ResolveDefaultPositiveInt(DefaultSnippetLinesEnvironmentVariable, SearchSnippetFormatter.DefaultSnippetLines, "--snippet-lines", out var defaultSnippetLinesError); - var snippetFocus = SearchSnippetFocusMode.Quality; - int maxLineWidth = ResolveDefaultNonNegativeInt(DefaultMaxLineWidthEnvironmentVariable, LineWidthFormatter.DefaultMaxLineWidth, "--max-line-width", out var defaultMaxLineWidthError); - bool contextAfterExplicit = false; - var pathPatterns = new List(); - var userPathPatterns = new List(); - var workspaceDbPaths = new List(); - var projectFilters = new List(); - string? solutionFilter = null; - var excludePaths = new List(); - var visibilityFilters = new List(); - var excludeVisibilityFilters = new List(); - bool excludeTests = false; - bool unusedActionable = false; - bool includeGenerated = false; - DateTime? since = null; - bool noDedup = false; - bool noVisibilityRank = false; - bool exact = false; - bool regex = false; - bool prefix = false; - var guardFilters = new List(); - var guardWindow = DbReader.DefaultSearchGuardWindow; - var guardScope = SearchGuardScope.Window; - bool excludeComments = false; - bool excludeStrings = false; - bool excludeFixtures = false; - List? parseErrors = null; - bool exactName = false; - bool exactSubstring = false; - bool tokenBoundary = false; - bool dbPathExplicit = false; - bool readOnly = false; - bool dryRun = false; - bool checkWorkspace = false; - bool statusCheckExplicit = false; - TimeSpan? staleAfter = null; - HashSet? statusCheckScopes = null; - bool withPaths = false; - string? groupBy = null; - string? uniqueBy = null; - string? countBy = null; - var matchOrigins = new List(); - var excludeOrigins = new List(); - var resultKinds = new List(); - List? searchFields = null; - List? outlineFields = null; - bool outlineFieldsExplicit = false; - bool firstPerFile = false; - bool resultsOnly = false; - bool nextSteps = false; - int groupedPerFileLimit = DefaultSearchGroupedPerFileLimit; - bool groupedPerFileLimitExplicit = false; - int? sampleSize = null; - int? maxJsonBytes = null; - bool rawBytes = false; - bool rawKinds = false; - bool verbose = false; - bool profile = false; - int? slowQueryMs = null; - bool compact = false; - List? inspectFields = null; - double minEntrypointConfidence = 0; - string? statusExplainField = null; - bool statusLogPath = false; - string outputFormat = OutputFormatText; - bool outputFormatExplicit = false; - bool statusConfig = false; - bool limitExplicit = false; - bool snippetLinesExplicit = false; - bool maxLineWidthExplicit = false; - bool strict = false; - var rankMode = ReferenceRankMode.Weighted; - var symbolSortMode = SymbolSortMode.Name; - string? sortValue = null; - bool sortExplicit = false; - var extraNames = new List(); - bool impactDeprecatedDepthUsed = false; - List? mapSections = null; - bool summaryOnly = false; - bool mapSummaryOnly = false; - bool dependencyCycles = false; - int dependencyCycleGraphBudget = DefaultDependencyCycleGraphBudget; - bool dependencySuppressNoise = false; - var dependencySymbols = new List(); - var dependencySymbolFamilies = new List(); - bool dependencySymbolFilterCountExceeded = false; - string? recipeName = null; - var includeRecipeQueries = new List(); - var excludeRecipeQueries = new List(); - bool showExcluded = false; - bool listRecipes = false; - bool namesOnly = false; - string? openIssuesPath = null; - string auditScope = SearchAuditRecipes.DefaultAuditScope; - bool auditScopeExplicit = false; - string? openIssuesRepository = null; - string issueState = IssueDuplicatePreflight.DefaultIssueState; - string duplicateConfidence = IssueDuplicatePreflight.DefaultDuplicateConfidence; - double duplicateThreshold = IssueDuplicatePreflight.DefaultDuplicateThreshold; - bool duplicateConfidenceExplicit = false; - bool duplicateThresholdExplicit = false; - string? issueTitle = null; - var issueLabels = new List(); - SearchCursor? searchCursor = null; - int? unusedCursorOffset = null; - int? outlineCursorOffset = null; - string? rawCursorValue = null; - DependencyCycleCursor? dependencyCycleCursor = null; - var namedSearchQueries = new List(); - bool languagesIndexedOnly = false; - var languageCapabilities = new List(); - var languageLookups = new List(); - var languageExtensionLookups = new List(); - var languageAliasLookups = new List(); - bool sourceOnly = false; - bool noSemanticTokens = false; - ProjectFilterRootResolution? projectFilterRootResolution = null; - - void AddParseError(string error) - { - parseErrors ??= []; - parseErrors.Add(error); - } - - void AddSearchGuardFilter(string optionName, SearchGuardRole role, SearchGuardDirection direction, string value) - { - if (string.IsNullOrWhiteSpace(value)) - { - AddParseError(BuildMissingOptionValueError(optionName)); - return; - } - if (value.Length > QueryLimits.MaxQueryLength) - { - AddParseError($"Error: {optionName} query too long (max {QueryLimits.MaxQueryLength} characters)."); - return; - } - - guardFilters.Add(new SearchGuardFilter(role, direction, value)); - } - - void AddDependencySymbolFilter(string optionName, string value, List target) - { - var trimmed = value.Trim(); - if (trimmed.Length == 0) - { - AddParseError($"Error: {optionName} value cannot be empty."); - return; - } - if (trimmed.Length > QueryLimits.MaxQueryLength) - { - AddParseError($"Error: {optionName} value too long (max {QueryLimits.MaxQueryLength} characters)."); - return; - } - if (target.Contains(trimmed, StringComparer.Ordinal)) - return; - if (dependencySymbols.Count + dependencySymbolFamilies.Count >= MaxDependencySymbolFilterCount) - { - if (!dependencySymbolFilterCountExceeded) - { - AddParseError($"Error: deps accepts at most {MaxDependencySymbolFilterCount} combined --symbol and --symbol-family values. / deps では --symbol と --symbol-family を合計 {MaxDependencySymbolFilterCount} 件まで指定できます。"); - dependencySymbolFilterCountExceeded = true; - } - return; - } - - target.Add(trimmed); - } - - void AddIssueDraftLabels(string rawLabels) - { - if (string.IsNullOrWhiteSpace(rawLabels)) - { - AddParseError("Error: --issue-label value cannot be empty."); - return; - } - - foreach (var label in rawLabels.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - if (issueLabels.Count >= MaxIssueDraftLabelCount) - { - AddParseError($"Error: search issue drafts accept at most {MaxIssueDraftLabelCount} labels."); - return; - } - if (label.Length > IssueDuplicatePreflight.MaxOpenIssueLabelLength) - { - AddParseError($"Error: --issue-label value too long (max {IssueDuplicatePreflight.MaxOpenIssueLabelLength} characters)."); - return; - } - if (!issueLabels.Contains(label, StringComparer.OrdinalIgnoreCase)) - issueLabels.Add(label); - } - } - - void AddRecipeQuerySelectors(string optionName, string rawSelectors, List selectors) - { - if (string.IsNullOrWhiteSpace(rawSelectors)) - { - AddParseError($"Error: {optionName} value cannot be empty."); - return; - } - - foreach (var selector in rawSelectors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - if (selectors.Count >= MaxSearchRecipeQuerySelectorCount) - { - AddParseError($"Error: search recipes accept at most {MaxSearchRecipeQuerySelectorCount} {optionName} values."); - return; - } - if (selector.Length > MaxSearchRecipeQuerySelectorLength) - { - AddParseError($"Error: {optionName} value too long (max {MaxSearchRecipeQuerySelectorLength} characters)."); - return; - } - if (!selectors.Contains(selector, StringComparer.OrdinalIgnoreCase)) - selectors.Add(selector); - } - } - - void AddStatusCheckScopes(string rawScopes) - { - if (string.IsNullOrWhiteSpace(rawScopes)) - { - AddParseError("Error: --check scope list cannot be empty. Use --check or --check=workspace,fold,graph,issues,hotspot,csharp,sql,newer."); - return; - } - if (!ValidateCsvBounds("--check", rawScopes, MaxStatusCheckScopesCsvLength, MaxStatusCheckScopesCsvEntries, AddParseError)) - return; - - statusCheckScopes ??= new HashSet(StringComparer.OrdinalIgnoreCase); - var invalidScope = false; - foreach (var rawScope in rawScopes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - var scope = rawScope.ToLowerInvariant(); - switch (scope) - { - case "workspace": - case "fold": - case "graph": - case "issues": - case "hotspot": - case "csharp": - case "sql": - case "newer": - statusCheckScopes.Add(scope); - break; - default: - invalidScope = true; - AddParseError($"Error: unsupported --check scope '{ConsoleUi.FormatBoundedValue(rawScope)}'. Use one or more of workspace, fold, graph, issues, hotspot, csharp, sql, newer."); - break; - } - } - - if (statusCheckScopes.Count == 0 && !invalidScope) - AddParseError("Error: --check scope list cannot be empty. Use --check or --check=workspace,fold,graph,issues,hotspot,csharp,sql,newer."); - } - - // Track non-repeatable value-taking options that have already been observed and warn on - // subsequent occurrences. Previously `--db /A --db /B` silently used `/B`; this makes the - // override explicit so users (and AI callers) can spot a copy/paste or scripted mistake. - // 非 repeatable な value-taking オプションの初出を記録し、2 回目以降で警告する。以前は - // `--db /A --db /B` が silent に `/B` を採用していたため、スクリプトやコピペのミスに - // ユーザーや AI 呼び出し側が気付けるよう、上書きを明示化する。 - var seenSingleValueOptions = new HashSet(StringComparer.Ordinal); - void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) - { - if (seenSingleValueOptions.Add(canonicalName)) - return; - var displayValue = ConsoleUi.FormatBoundedValue(newValue); - CommandErrorWriter.WriteStderr($"Warning: {canonicalName} specified more than once; the rightmost CLI value '{displayValue}' takes precedence over earlier CLI values and any environment/config default."); - } - - for (int i = 0; i < args.Length; i++) - { - var currentArg = args[i]; - if (allowStatusCheck && currentArg.StartsWith("--check=", StringComparison.Ordinal)) - { - checkWorkspace = true; - statusCheckExplicit = true; - AddStatusCheckScopes(currentArg["--check=".Length..]); - continue; - } - - var inlineValue = TrySplitInlineOptionValue(currentArg, out var inlineOptionName) - ? currentArg[(inlineOptionName!.Length + 1)..] - : null; - var normalizedArg = inlineOptionName ?? currentArg; - - switch (normalizedArg) - { - case "--": - if (i + 1 >= args.Length) - { - AddParseError("Error: -- requires a following literal query."); - } - else if (query == null) - { - query = args[++i]; - } - else - { - extraNames.Add(args[++i]); - } - break; - case "--db": - if (TryReadStringOptionValue(args, ref i, "--db", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var dbPathValue, out var dbPathError)) - { - WarnIfDuplicateSingleValueOption("--db", dbPathValue!); - dbPath = dbPathValue!; - dbPathExplicit = true; - } - else - AddParseError(dbPathError!); - break; - case "--read-only": - case "--immutable": - readOnly = true; - break; - case "--dry-run": - dryRun = true; - break; - case "--pretty": - break; - case "--compact": - compact = true; - json = true; - outputFormat = OutputFormatJson; - break; - case "--body-only": - includeBody = true; - inspectFields = ["definitions"]; - json = true; - outputFormat = OutputFormatJson; - break; - case "--outline-only": - inspectFields = ["file", "definitions", "nearby_symbols"]; - json = true; - if (outputFormat == OutputFormatText) - outputFormat = OutputFormatJson; - break; - case "--workspace-db": - if (TryReadStringOptionValue(args, ref i, "--workspace-db", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var workspaceDbPath, out var workspaceDbError)) - workspaceDbPaths.Add(workspaceDbPath!); - else - AddParseError(workspaceDbError!); - break; - case "--data-dir": - if (TryReadStringOptionValue(args, ref i, "--data-dir", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var dataDirValue, out var dataDirError)) - { - WarnIfDuplicateSingleValueOption("--data-dir", dataDirValue!); - dataDir = dataDirValue!; - } - else - AddParseError(dataDirError!); - break; - case "--json": - if (inlineValue == null) - { - json = true; - if (outputFormat == OutputFormatText) - outputFormat = OutputFormatJson; - } - else if (TryParseJsonOutputFormat(inlineValue, out var parsedJsonOutputFormat)) - { - json = true; - jsonOutputFormat = parsedJsonOutputFormat; - jsonOutputFormatExplicit = true; - if (outputFormat == OutputFormatText) - outputFormat = OutputFormatJson; - } - else - { - AddParseError($"Error: --json format must be one of ndjson or array, got '{ConsoleUi.FormatBoundedValue(inlineValue)}'. Hint: use `--json` or `--json=ndjson` for newline-delimited JSON, or `--json=array` for a single JSON array."); - } - break; - case "--indexed-only": - languagesIndexedOnly = true; - break; - case "--capability": - if (!TryReadStringOptionValue(args, ref i, "--capability", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var capabilityValue, out var capabilityError)) - { - AddParseError(capabilityError!); - } - else if (TryNormalizeLanguageCapability(capabilityValue!, out var capability)) - { - languageCapabilities.Add(capability); - } - else - { - AddParseError($"Error: unsupported --capability value '{ConsoleUi.FormatBoundedValue(capabilityValue)}'. Use all, none, graph, references, symbols, missing-any, missing-graph, missing-references, missing-symbols, or search-only."); - } - break; - case "--language": - if (TryReadStringOptionValue(args, ref i, "--language", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var languageValue, out var languageError)) - { - languageLookups.Add(languageValue!); - lang = NormalizeLangFilterValue(languageValue); - } - else - { - AddParseError(languageError!); - } - break; - case "--extension": - if (TryReadStringOptionValue(args, ref i, "--extension", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var languageExtensionValue, out var languageExtensionError)) - languageExtensionLookups.Add(languageExtensionValue!); - else - AddParseError(languageExtensionError!); - break; - case "--alias": - if (TryReadStringOptionValue(args, ref i, "--alias", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var languageAliasValue, out var languageAliasError)) - languageAliasLookups.Add(languageAliasValue!); - else - AddParseError(languageAliasError!); - break; - case "--format": - if (TryReadStringOptionValue(args, ref i, "--format", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var formatValue, out var formatError)) - { - WarnIfDuplicateSingleValueOption("--format", formatValue!); - if (TryParseOutputFormat(formatValue!, out var parsedOutputFormat)) - { - outputFormat = parsedOutputFormat; - outputFormatExplicit = true; - if (parsedOutputFormat == OutputFormatCompact) - compact = true; - if (parsedOutputFormat == OutputFormatCount) - countOnly = true; - if (parsedOutputFormat != OutputFormatText && - parsedOutputFormat != OutputFormatDot && - parsedOutputFormat != OutputFormatGraphMl) - json = true; - } - else if (allowIssueDraftsFormat && string.Equals(formatValue, OutputFormatIssueDrafts, StringComparison.OrdinalIgnoreCase)) - { - outputFormat = OutputFormatIssueDrafts; - outputFormatExplicit = true; - json = true; - } - else - { - var allowedFormats = allowIssueDraftsFormat - ? "text, json, count, compact, csv, tsv, lsp, qf, sarif, or issue-drafts" - : "text, json, count, compact, csv, tsv, lsp, qf, or sarif"; - AddParseError($"Error: --format must be one of {allowedFormats}; got '{ConsoleUi.FormatBoundedValue(formatValue)}'."); - } - } - else - { - AddParseError(formatError!); - } - break; - case "--limit": - case "--max-results": - case "--top": - var limitOptionName = normalizedArg == "--top" ? "--limit" : normalizedArg; - if (!TryReadRawOptionValue(args, ref i, limitOptionName, inlineValue, out var limitValue, out var missingLimitError)) - AddParseError(missingLimitError!); - else if (TryParsePositiveInt(limitValue!, limitOptionName, out var parsedLimit, out var limitError)) - { - WarnIfDuplicateSingleValueOption("--limit", limitValue!); - limit = parsedLimit; - limitExplicit = true; - } - else - AddParseError(limitError!); - break; - case "--graph-budget": - if (!TryReadRawOptionValue(args, ref i, "--graph-budget", inlineValue, out var graphBudgetValue, out var missingGraphBudgetError)) - AddParseError(missingGraphBudgetError!); - else if (TryParsePositiveInt(graphBudgetValue!, "--graph-budget", out var parsedGraphBudget, out var graphBudgetError)) - { - WarnIfDuplicateSingleValueOption("--graph-budget", graphBudgetValue!); - dependencyCycleGraphBudget = parsedGraphBudget; - } - else - AddParseError(graphBudgetError!); - break; - case "--total-limit": - if (!TryReadRawOptionValue(args, ref i, "--total-limit", inlineValue, out var totalLimitValue, out var missingTotalLimitError)) - AddParseError(missingTotalLimitError!); - else if (TryParseNonNegativeInt(totalLimitValue!, "--total-limit", out var parsedTotalLimit, out var totalLimitError)) - { - WarnIfDuplicateSingleValueOption("--total-limit", totalLimitValue!); - totalLimit = parsedTotalLimit; - } - else - AddParseError(totalLimitError!); - break; - case "--lang": - if (TryReadStringOptionValue(args, ref i, "--lang", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var langValue, out var langError)) - { - WarnIfDuplicateSingleValueOption("--lang", langValue!); - // Normalize to lowercase so '--lang Python' == '--lang python' — every LangMap key and - // every DB 'files.lang' row is lowercase, so the SQL filter and WriteLangHint match. - // Also fold common short aliases (e.g. `py`) to canonical language names so Python-heavy - // workflows can use familiar shorthand without silently returning zero rows. - // '--lang Python' と '--lang python' を同一視するため lowercase 正規化する。LangMap の key と - // DB の `files.lang` はすべて lowercase なので、SQL filter と WriteLangHint が一致する。 - // さらに `py` のような短縮エイリアスを正規名へ畳み込み、Python 利用時の慣用入力で - // 意図せず 0 件になる事故を避ける。 - lang = NormalizeLangFilterValue(langValue); - } - else - AddParseError(langError!); - break; - case "--query": - if (!allowNamedQuery) - { - AddParseError("Error: --query is not supported by this command."); - if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) - i++; - } - else if (TryReadStringOptionValue(args, ref i, "--query", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var queryValue, out var queryError)) - { - WarnIfDuplicateSingleValueOption("--query", queryValue!); - query = queryValue; - } - else - AddParseError(queryError!); - break; - case "--recipe": - if (TryReadStringOptionValue(args, ref i, "--recipe", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var recipeValue, out var recipeError)) - { - WarnIfDuplicateSingleValueOption("--recipe", recipeValue!); - recipeName = recipeValue; - } - else - AddParseError(recipeError!); - break; - case "--include-query": - if (TryReadStringOptionValue(args, ref i, "--include-query", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var includeQueryValue, out var includeQueryError)) - AddRecipeQuerySelectors("--include-query", includeQueryValue!, includeRecipeQueries); - else - AddParseError(includeQueryError!); - break; - case "--exclude-query": - if (TryReadStringOptionValue(args, ref i, "--exclude-query", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var excludeQueryValue, out var excludeQueryError)) - AddRecipeQuerySelectors("--exclude-query", excludeQueryValue!, excludeRecipeQueries); - else - AddParseError(excludeQueryError!); - break; - case "--show-excluded": - showExcluded = true; - break; - case "--list-recipes": - listRecipes = true; - break; - case "--names": - namesOnly = true; - break; - case "--source-only": - sourceOnly = true; - auditScope = SearchAuditRecipes.DefaultAuditScope; - auditScopeExplicit = true; - break; - case "--open-issues": - if (TryReadStringOptionValue(args, ref i, "--open-issues", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var openIssuesValue, out var openIssuesError)) - { - WarnIfDuplicateSingleValueOption("--open-issues", openIssuesValue!); - openIssuesPath = openIssuesValue; - } - else - AddParseError(openIssuesError!); - break; - case "--audit-scope": - if (!TryReadStringOptionValue(args, ref i, "--audit-scope", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var auditScopeValue, out var auditScopeError)) - { - AddParseError(auditScopeError!); - } - else if (TryNormalizeSearchAuditScope(auditScopeValue!, out var normalizedAuditScope)) - { - WarnIfDuplicateSingleValueOption("--audit-scope", auditScopeValue!); - auditScope = normalizedAuditScope; - auditScopeExplicit = true; - } - else - { - AddParseError($"Error: unsupported --audit-scope value '{ConsoleUi.FormatBoundedValue(auditScopeValue)}'. Use source or all."); - } - break; - case "--repo": - if (TryReadStringOptionValue(args, ref i, "--repo", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var repoValue, out var repoError)) - { - WarnIfDuplicateSingleValueOption("--repo", repoValue!); - openIssuesRepository = repoValue; - } - else - AddParseError(repoError!); - break; - case "--issue-state": - if (TryReadStringOptionValue(args, ref i, "--issue-state", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var issueStateValue, out var issueStateError)) - issueState = issueStateValue!.ToLowerInvariant(); - else - AddParseError(issueStateError!); - break; - case "--duplicate-confidence": - if (TryReadStringOptionValue(args, ref i, "--duplicate-confidence", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var duplicateConfidenceValue, out var duplicateConfidenceError)) - { - WarnIfDuplicateSingleValueOption("--duplicate-confidence", duplicateConfidenceValue!); - if (IssueDuplicatePreflight.TryNormalizeDuplicateConfidence(duplicateConfidenceValue!, out var normalizedDuplicateConfidence)) - { - duplicateConfidence = normalizedDuplicateConfidence; - duplicateThreshold = IssueDuplicatePreflight.ThresholdForDuplicateConfidence(normalizedDuplicateConfidence); - duplicateConfidenceExplicit = true; - } - else - { - AddParseError($"Error: --duplicate-confidence must be one of low, medium, high; got '{ConsoleUi.FormatBoundedValue(duplicateConfidenceValue)}'."); - } - } - else - { - AddParseError(duplicateConfidenceError!); - } - break; - case "--duplicate-threshold": - if (!TryReadRawOptionValue(args, ref i, "--duplicate-threshold", inlineValue, out var duplicateThresholdValue, out var missingDuplicateThresholdError)) - { - AddParseError(missingDuplicateThresholdError!); - } - else if (TryParseConfidence(duplicateThresholdValue!, out var parsedDuplicateThreshold)) - { - WarnIfDuplicateSingleValueOption("--duplicate-threshold", duplicateThresholdValue!); - duplicateThreshold = parsedDuplicateThreshold; - duplicateThresholdExplicit = true; - } - else - { - AddParseError($"Error: --duplicate-threshold must be a number between 0 and 1; got '{ConsoleUi.FormatBoundedValue(duplicateThresholdValue)}'."); - } - break; - case "--issue-title": - if (TryReadStringOptionValue(args, ref i, "--issue-title", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var issueTitleValue, out var issueTitleError)) - { - WarnIfDuplicateSingleValueOption("--issue-title", issueTitleValue!); - var trimmedTitle = issueTitleValue!.Trim(); - if (trimmedTitle.Length == 0) - AddParseError("Error: --issue-title value cannot be empty."); - else if (trimmedTitle.Length > MaxIssueDraftTitleLength) - AddParseError($"Error: --issue-title value too long (max {MaxIssueDraftTitleLength} characters)."); - else - issueTitle = trimmedTitle; - } - else - AddParseError(issueTitleError!); - break; - case "--issue-label": - if (TryReadStringOptionValue(args, ref i, "--issue-label", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var issueLabelValue, out var issueLabelError)) - AddIssueDraftLabels(issueLabelValue!); - else - AddParseError(issueLabelError!); - break; - case "--cursor": - var allowSeparatedDashPrefixedCursorValue = inlineValue is null - && i + 1 < args.Length - && TryParseSearchCursor(args[i + 1], out _); - if (TryReadStringOptionValue(args, ref i, "--cursor", inlineValue, allowSeparatedDashPrefixedLiteralValue: allowSeparatedDashPrefixedCursorValue, out var cursorValue, out var cursorError)) - { - WarnIfDuplicateSingleValueOption("--cursor", cursorValue!); - var parsedCursorValue = cursorValue!; - if (TryParseSearchCursor(parsedCursorValue, out var parsedCursor)) - searchCursor = parsedCursor; - else if (TryParseUnusedCursor(parsedCursorValue, out var parsedUnusedCursorOffset)) - unusedCursorOffset = parsedUnusedCursorOffset; - else if (TryParseOutlineCursor(parsedCursorValue, out var parsedOutlineCursorOffset)) - outlineCursorOffset = parsedOutlineCursorOffset; - else if (TryParseDependencyCycleCursor(parsedCursorValue, out var parsedDependencyCycleCursor)) - dependencyCycleCursor = parsedDependencyCycleCursor; - else - { - AddParseError("Error: --cursor must be a search, unused, outline, or dependency-cycle pagination cursor returned as `next_cursor`."); - break; - } - rawCursorValue = parsedCursorValue; - } - else - { - AddParseError(cursorError!); - } - break; - case "--named-query": - if (!allowNamedQuery) - { - AddParseError("Error: --named-query is not supported by this command."); - if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) - i++; - } - else if (TryReadStringOptionValue(args, ref i, "--named-query", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var namedQueryValue, out var namedQueryError)) - { - if (TryParseNamedSearchQuery(namedQueryValue!, out var namedQuery, out var namedQueryParseError)) - namedSearchQueries.Add(namedQuery); - else - AddParseError(namedQueryParseError!); - } - else - { - AddParseError(namedQueryError!); - } - break; - case "--require-before": - if (TryReadStringOptionValue(args, ref i, "--require-before", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var requireBeforeValue, out var requireBeforeError)) - AddSearchGuardFilter("--require-before", SearchGuardRole.Require, SearchGuardDirection.Before, requireBeforeValue!); - else - AddParseError(requireBeforeError!); - break; - case "--require-after": - if (TryReadStringOptionValue(args, ref i, "--require-after", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var requireAfterValue, out var requireAfterError)) - AddSearchGuardFilter("--require-after", SearchGuardRole.Require, SearchGuardDirection.After, requireAfterValue!); - else - AddParseError(requireAfterError!); - break; - case "--reject-before": - if (TryReadStringOptionValue(args, ref i, "--reject-before", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var rejectBeforeValue, out var rejectBeforeError)) - AddSearchGuardFilter("--reject-before", SearchGuardRole.Reject, SearchGuardDirection.Before, rejectBeforeValue!); - else - AddParseError(rejectBeforeError!); - break; - case "--reject-after": - if (TryReadStringOptionValue(args, ref i, "--reject-after", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var rejectAfterValue, out var rejectAfterError)) - AddSearchGuardFilter("--reject-after", SearchGuardRole.Reject, SearchGuardDirection.After, rejectAfterValue!); - else - AddParseError(rejectAfterError!); - break; - case "--guard-window": - if (!TryReadRawOptionValue(args, ref i, "--guard-window", inlineValue, out var guardWindowValue, out var missingGuardWindowError)) - { - AddParseError(missingGuardWindowError!); - } - else if (TryParseNonNegativeInt(guardWindowValue!, "--guard-window", out var parsedGuardWindow, out var guardWindowError)) - { - WarnIfDuplicateSingleValueOption("--guard-window", guardWindowValue!); - if (parsedGuardWindow > DbReader.MaxSearchGuardWindow) - AddParseError($"Error: --guard-window must be between 0 and {DbReader.MaxSearchGuardWindow}; got {parsedGuardWindow}."); - else - guardWindow = parsedGuardWindow; - } - else - { - AddParseError(guardWindowError!); - } - break; - case "--guard-scope": - if (TryReadStringOptionValue(args, ref i, "--guard-scope", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var guardScopeValue, out var guardScopeError)) - { - WarnIfDuplicateSingleValueOption("--guard-scope", guardScopeValue!); - if (TryNormalizeSearchGuardScope(guardScopeValue!, out var parsedGuardScope)) - guardScope = parsedGuardScope; - else - AddParseError($"Error: unsupported --guard-scope value '{ConsoleUi.FormatBoundedValue(guardScopeValue!)}'. Use window or same-line."); - } - else - AddParseError(guardScopeError!); - break; - case "--kind": - if (TryReadStringOptionValue(args, ref i, "--kind", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var kindValue, out var kindError)) - { - WarnIfDuplicateSingleValueOption("--kind", kindValue!); - // Normalize to lowercase so '--kind FUNCTION' == '--kind function'. AllValidKinds entries - // and every DB 'symbols.kind' row are lowercase. - // '--kind FUNCTION' と '--kind function' を同一視するため lowercase 正規化する。AllValidKinds - // と DB の `symbols.kind` はすべて lowercase。 - kind = kindValue?.ToLowerInvariant(); - } - else - AddParseError(kindError!); - break; - case "--bucket": - if (TryReadStringOptionValue(args, ref i, "--bucket", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var unusedBucketValue, out var unusedBucketError)) - { - WarnIfDuplicateSingleValueOption("--bucket", unusedBucketValue!); - unusedBucket = unusedBucketValue?.ToLowerInvariant(); - } - else - AddParseError(unusedBucketError!); - break; - case "--confidence": - case "--min-confidence": - var confidenceFlag = normalizedArg; - if (TryReadStringOptionValue(args, ref i, confidenceFlag, inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var minUnusedConfidenceValue, out var minUnusedConfidenceError)) - { - WarnIfDuplicateSingleValueOption("--min-confidence", minUnusedConfidenceValue!); - minUnusedConfidence = minUnusedConfidenceValue?.ToLowerInvariant(); - } - else - AddParseError(minUnusedConfidenceError!); - break; - case "--severity": - if (TryReadStringOptionValue(args, ref i, "--severity", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var severityValue, out var severityError)) - { - WarnIfDuplicateSingleValueOption("--severity", severityValue!); - severity = severityValue?.ToLowerInvariant(); - } - else - { - AddParseError(severityError!); - } - break; - case "--visibility": - if (TryReadStringOptionValue(args, ref i, "--visibility", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var visibilityValue, out var visibilityError)) - AddVisibilityFilterValues("--visibility", visibilityValue!, visibilityFilters, AddParseError); - else - AddParseError(visibilityError!); - break; - case "--exclude-visibility": - if (TryReadStringOptionValue(args, ref i, "--exclude-visibility", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var excludeVisibilityValue, out var excludeVisibilityError)) - AddVisibilityFilterValues("--exclude-visibility", excludeVisibilityValue!, excludeVisibilityFilters, AddParseError); - else - AddParseError(excludeVisibilityError!); - break; - case "--rank-by": - if (TryReadStringOptionValue(args, ref i, "--rank-by", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var rankByValue, out var rankByError)) - { - WarnIfDuplicateSingleValueOption("--rank-by", rankByValue!); - if (TryParseReferenceRankMode(rankByValue!, out var parsedRankMode)) - rankMode = parsedRankMode; - else - AddParseError($"Error: --rank-by must be one of weighted, count, kind; got '{rankByValue}'."); - } - else - AddParseError(rankByError!); - break; - case "--sort": - if (TryReadStringOptionValue(args, ref i, "--sort", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var sortRawValue, out var sortError)) - { - WarnIfDuplicateSingleValueOption("--sort", sortRawValue!); - var normalizedSortValue = sortRawValue!; - if (allowOutlineSort && TryParseOutlineSortMode(normalizedSortValue, out _)) - { - sortExplicit = true; - } - else if (!allowOutlineSort && TryParseSymbolSortMode(normalizedSortValue, out var parsedSortMode)) - { - symbolSortMode = parsedSortMode; - sortExplicit = true; - } - else - { - var allowedSortValues = allowOutlineSort - ? "source, kind, references, size, span, complexity, path, or name" - : "hotspot, references, size, complexity, path"; - AddParseError($"Error: --sort must be one of {allowedSortValues}; got '{normalizedSortValue}'."); - } - sortValue = normalizedSortValue; - } - else - AddParseError(sortError!); - break; - case "--sections": - if (TryReadStringOptionValue(args, ref i, "--sections", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var sectionsValue, out var sectionsError)) - { - WarnIfDuplicateSingleValueOption("--sections", sectionsValue!); - mapSections = ParseMapSections(sectionsValue!, AddParseError); - } - else - AddParseError(sectionsError!); - break; - case "--summary-only": - summaryOnly = true; - mapSummaryOnly = true; - break; - case "--fields": - if (TryReadStringOptionValue(args, ref i, "--fields", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var fieldsValue, out var fieldsError)) - { - WarnIfDuplicateSingleValueOption("--fields", fieldsValue!); - inspectFields = ParseInspectFields(fieldsValue!, AddParseError, out var includeBodyFromFields); - includeBody |= includeBodyFromFields; - json = true; - outputFormat = OutputFormatJson; - } - else - { - AddParseError(fieldsError!); - } - break; - case "--fts": - rawFts = true; - break; - case "--body": - includeBody = true; - break; - case "--body-start": - if (!TryReadRawOptionValue(args, ref i, "--body-start", inlineValue, out var bodyStartValue, out var missingBodyStartError)) - AddParseError(missingBodyStartError!); - else if (TryParsePositiveInt(bodyStartValue!, "--body-start", out var parsedBodyStartLine, out var bodyStartError)) - { - WarnIfDuplicateSingleValueOption("--body-start", bodyStartValue!); - bodyStartLine = parsedBodyStartLine; - includeBody = true; - } - else - AddParseError(bodyStartError!); - break; - case "--body-lines": - case "--body-line-count": - var bodyLinesFlag = normalizedArg; - if (!TryReadRawOptionValue(args, ref i, bodyLinesFlag, inlineValue, out var bodyLinesValue, out var missingBodyLinesError)) - AddParseError(missingBodyLinesError!); - else if (TryParsePositiveInt(bodyLinesValue!, bodyLinesFlag, out var parsedBodyLines, out var bodyLinesError)) - { - WarnIfDuplicateSingleValueOption("--body-lines", bodyLinesValue!); - bodyLines = parsedBodyLines; - includeBody = true; - } - else - AddParseError(bodyLinesError!); - break; - case "--count": - countOnly = true; - break; - case "--group-partials": - groupPartials = true; - break; - case "--cycles": - dependencyCycles = true; - break; - case "--suppress-noise": - dependencySuppressNoise = true; - break; - case "--symbol": - if (TryReadStringOptionValue(args, ref i, "--symbol", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var dependencySymbolValue, out var dependencySymbolError)) - AddDependencySymbolFilter("--symbol", dependencySymbolValue!, dependencySymbols); - else - AddParseError(dependencySymbolError!); - break; - case "--symbol-family": - if (TryReadStringOptionValue(args, ref i, "--symbol-family", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var dependencySymbolFamilyValue, out var dependencySymbolFamilyError)) - AddDependencySymbolFilter("--symbol-family", dependencySymbolFamilyValue!, dependencySymbolFamilies); - else - AddParseError(dependencySymbolFamilyError!); - break; - case "--strict-not-found": - strictNotFound = true; - break; - case "--allow-partial": - allowPartial = true; - break; - case "--strict": - strict = true; - break; - case "--by-bucket": - break; - case "--all": - all = true; - break; - case "--no-dedup": - noDedup = true; - break; - case "--no-visibility-rank": - noVisibilityRank = true; - break; - case "--exact": - exact = true; - break; - case "--regex": - regex = true; - break; - case "--exact-name": - exactName = true; - break; - case "--exact-substring": - exactSubstring = true; - break; - case "--token-boundary": - tokenBoundary = true; - break; - case "--prefix": - prefix = true; - break; - case "--max-hops": - case "--depth": - var depthOptionName = normalizedArg; - if (!TryReadRawOptionValue(args, ref i, depthOptionName, inlineValue, out var depthValue, out var missingDepthError)) - AddParseError(missingDepthError!); - else if (TryParseNonNegativeInt(depthValue!, depthOptionName, out var parsedDepth, out var depthError)) - { - WarnIfDuplicateSingleValueOption("--max-hops", depthValue!); - contextAfter = parsedDepth; // reused as depth for impact / impact用に再利用 - contextAfterExplicit = true; - if (depthOptionName == "--depth") - impactDeprecatedDepthUsed = true; - } - else - AddParseError(depthError!); - break; - case "--reverse": - break; // handled by specific commands / 特定コマンドで処理 - case "--group-by-name": - break; - case "--group-by": - if (TryReadStringOptionValue(args, ref i, "--group-by", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var groupByValue, out var groupByError)) - { - WarnIfDuplicateSingleValueOption("--group-by", groupByValue!); - groupBy = groupByValue?.ToLowerInvariant(); - } - else - AddParseError(groupByError!); - break; - case "--unique": - if (TryReadStringOptionValue(args, ref i, "--unique", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var uniqueValue, out var uniqueError)) - { - WarnIfDuplicateSingleValueOption("--unique", uniqueValue!); - uniqueBy = uniqueValue?.ToLowerInvariant(); - } - else - AddParseError(uniqueError!); - break; - case "--count-by": - if (TryReadStringOptionValue(args, ref i, "--count-by", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var countByValue, out var countByError)) - { - WarnIfDuplicateSingleValueOption("--count-by", countByValue!); - countBy = countByValue?.ToLowerInvariant(); - } - else - AddParseError(countByError!); - break; - case "--origin": - case "--match-origin": - var originOptionName = normalizedArg; - if (TryReadStringOptionValue(args, ref i, originOptionName, inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var originValue, out var originError)) - AddSearchMatchOrigins(originOptionName, originValue!, matchOrigins, AddParseError); - else - AddParseError(originError!); - break; - case "--exclude-origin": - if (TryReadStringOptionValue(args, ref i, "--exclude-origin", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var excludedOriginValue, out var excludedOriginError)) - AddSearchMatchOrigins("--exclude-origin", excludedOriginValue!, excludeOrigins, AddParseError); - else - AddParseError(excludedOriginError!); - break; - case "--result-kind": - if (TryReadStringOptionValue(args, ref i, "--result-kind", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var resultKindValue, out var resultKindError)) - AddSearchResultKinds(resultKindValue!, resultKinds, AddParseError); - else - AddParseError(resultKindError!); - break; - case "--search-fields": - if (TryReadStringOptionValue(args, ref i, "--search-fields", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var searchFieldsValue, out var searchFieldsError)) - { - WarnIfDuplicateSingleValueOption("--search-fields", searchFieldsValue!); - searchFields = ParseSearchProjectionFields(searchFieldsValue!, AddParseError); - json = true; - outputFormat = OutputFormatJson; - } - else - AddParseError(searchFieldsError!); - break; - case "--first-per-file": - firstPerFile = true; - break; - case "--results-only": - resultsOnly = true; - json = true; - if (!outputFormatExplicit) - outputFormat = OutputFormatJson; - break; - case "--next-steps": - nextSteps = true; - break; - case "--sample": - if (!TryReadRawOptionValue(args, ref i, "--sample", inlineValue, out var sampleValue, out var missingSampleError)) - AddParseError(missingSampleError!); - else if (TryParsePositiveInt(sampleValue!, "--sample", out var parsedSample, out var sampleError)) - { - WarnIfDuplicateSingleValueOption("--sample", sampleValue!); - sampleSize = parsedSample; - } - else - AddParseError(sampleError!); - break; - case "--per-file-limit": - groupedPerFileLimitExplicit = true; - if (!TryReadRawOptionValue(args, ref i, "--per-file-limit", inlineValue, out var perFileLimitValue, out var missingPerFileLimitError)) - AddParseError(missingPerFileLimitError!); - else if (TryParsePositiveInt(perFileLimitValue!, "--per-file-limit", out var parsedPerFileLimit, out var perFileLimitError)) - { - WarnIfDuplicateSingleValueOption("--per-file-limit", perFileLimitValue!); - groupedPerFileLimit = Math.Min(parsedPerFileLimit, MaxSearchGroupedPerFileLimit); - } - else - AddParseError(perFileLimitError!); - break; - case "--max-json-bytes": - if (!TryReadRawOptionValue(args, ref i, "--max-json-bytes", inlineValue, out var maxJsonBytesValue, out var missingMaxJsonBytesError)) - AddParseError(missingMaxJsonBytesError!); - else if (TryParsePositiveInt(maxJsonBytesValue!, "--max-json-bytes", out var parsedMaxJsonBytes, out var maxJsonBytesError)) - { - WarnIfDuplicateSingleValueOption("--max-json-bytes", maxJsonBytesValue!); - maxJsonBytes = Math.Min(parsedMaxJsonBytes, MaxSearchJsonByteLimit); - } - else - AddParseError(maxJsonBytesError!); - break; - case "--with-paths": - withPaths = true; - break; - case "--bytes": - rawBytes = true; - break; - case "--raw-kinds": - rawKinds = true; - break; - case "--verbose": - verbose = true; - break; - case "--profile": - profile = true; - break; - case "--slow-query-ms": - if (!TryReadRawOptionValue(args, ref i, "--slow-query-ms", inlineValue, out var slowQueryValue, out var missingSlowQueryError)) - AddParseError(missingSlowQueryError!); - else if (TryParseNonNegativeInt(slowQueryValue!, "--slow-query-ms", out var parsedSlowQueryMs, out var slowQueryError)) - { - WarnIfDuplicateSingleValueOption("--slow-query-ms", slowQueryValue!); - slowQueryMs = parsedSlowQueryMs; - } - else - AddParseError(slowQueryError!); - break; - case "--min-entrypoint-confidence": - if (!TryReadRawOptionValue(args, ref i, "--min-entrypoint-confidence", inlineValue, out var minEntrypointConfidenceValue, out var missingMinEntrypointConfidenceError)) - AddParseError(missingMinEntrypointConfidenceError!); - else if (TryParseConfidence(minEntrypointConfidenceValue!, out var parsedMinEntrypointConfidence)) - { - WarnIfDuplicateSingleValueOption("--min-entrypoint-confidence", minEntrypointConfidenceValue!); - minEntrypointConfidence = parsedMinEntrypointConfidence; - } - else - AddParseError($"Error: --min-entrypoint-confidence must be a number from 0.0 through 1.0; got '{ConsoleUi.FormatBoundedValue(minEntrypointConfidenceValue)}'."); - break; - case "--check": - if (allowStatusCheck) - { - checkWorkspace = true; - statusCheckExplicit = true; - } - else if (allowNamedQuery && query == null) - { - query = currentArg; - } - else - { - AddParseError("Error: --check is not supported by this command."); - } - break; - case "--outline-fields": - if (TryReadStringOptionValue(args, ref i, "--outline-fields", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var outlineFieldsValue, out var outlineFieldsError)) - { - WarnIfDuplicateSingleValueOption("--outline-fields", outlineFieldsValue!); - outlineFields = ParseOutlineProjectionFields(outlineFieldsValue!, AddParseError); - outlineFieldsExplicit = true; - json = true; - outputFormat = OutputFormatJson; - } - else - { - AddParseError(outlineFieldsError!); - } - break; - case "--stale-after": - if (allowStatusCheck) - { - if (TryReadStringOptionValue(args, ref i, "--stale-after", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var staleAfterValue, out var staleAfterError)) - { - WarnIfDuplicateSingleValueOption("--stale-after", staleAfterValue!); - if (TryParseStaleAfter(staleAfterValue!, out var parsedStaleAfter, out var parseStaleAfterError)) - { - staleAfter = parsedStaleAfter; - checkWorkspace = true; - } - else - AddParseError(parseStaleAfterError!); - } - else - { - AddParseError(staleAfterError!); - } - } - else - { - AddParseError("Error: --stale-after is not supported by this command."); - } - break; - case "--explain": - if (allowStatusCheck) - { - if (TryReadStringOptionValue(args, ref i, "--explain", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var explainValue, out var explainError)) - { - WarnIfDuplicateSingleValueOption("--explain", explainValue!); - statusExplainField = explainValue; - } - else - AddParseError(explainError!); - } - else if (allowNamedQuery && query == null) - { - query = currentArg; - } - else - { - AddParseError("Error: --explain is not supported by this command."); - } - break; - case "--log-path": - if (allowStatusCheck) - { - statusLogPath = true; - } - else - { - AddParseError("Error: --log-path is not supported by this command."); - } - break; - case "--config": - if (allowStatusCheck) - { - statusConfig = true; - } - else - { - AddParseError("Error: --config is only supported by status."); - } - break; - case "--log-format": - case "--log-retain-count": - case "--log-max-size-mb": - if (allowNamedQuery && query == null) - { - query = currentArg; - } - else - { - AddParseError($"Error: unsupported option: {ConsoleUi.FormatBoundedValue(currentArg)}. Use `--` before a query literal that starts with `-`."); - } - break; - case "--path": - if (TryReadStringOptionValue(args, ref i, "--path", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var pathPattern, out var pathError)) - { - pathPatterns.Add(pathPattern!); // Repeatable; multiple values OR together / 繰り返し可、複数値は OR で結合 - userPathPatterns.Add(pathPattern!); - } - else - AddParseError(pathError!); - break; - case "--project": - if (TryReadStringOptionValue(args, ref i, "--project", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var projectName, out var projectError)) - projectFilters.Add(projectName!); - else - AddParseError(projectError!); - break; - case "--solution": - if (TryReadStringOptionValue(args, ref i, "--solution", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var solutionValue, out var solutionError)) - { - WarnIfDuplicateSingleValueOption("--solution", solutionValue!); - solutionFilter = solutionValue; - } - else - AddParseError(solutionError!); - break; - case "--exclude-path": - if (TryReadStringOptionValue(args, ref i, "--exclude-path", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var excludePath, out var excludePathError)) - excludePaths.Add(excludePath!); - else - AddParseError(excludePathError!); - break; - case "--exclude-tests": - excludeTests = true; - break; - case "--no-semantic-tokens": - noSemanticTokens = true; - break; - case "--exclude-comments": - excludeComments = true; - break; - case "--exclude-strings": - excludeStrings = true; - break; - case "--exclude-fixtures": - excludeFixtures = true; - break; - case "--actionable": - unusedActionable = true; - break; - case "--include-generated": - case "--generated": - includeGenerated = true; - break; - case "--since": - if (!TryReadStringOptionValue(args, ref i, "--since", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var sinceValue, out var sinceError)) - AddParseError(sinceError!); - else if (TryParseIso8601Since(sinceValue!, out var parsedSince)) - { - WarnIfDuplicateSingleValueOption("--since", sinceValue!); - since = parsedSince; - } - else - AddParseError($"Error: could not parse --since value '{ConsoleUi.FormatBoundedValue(sinceValue)}' as a date/time. Use ISO 8601 format (e.g. 2024-01-01 or 2024-01-01T00:00:00Z)."); - break; - case "--line": - if (!TryReadRawOptionValue(args, ref i, "--line", inlineValue, out var lineValue, out var missingLineError)) - AddParseError(missingLineError!); - else if (TryParsePositiveInt(lineValue!, "--line", out var parsedLine, out var lineError)) - { - WarnIfDuplicateSingleValueOption("--start", lineValue!); - startLine = parsedLine; - if (!endLineExplicit) - endLine = parsedLine; - } - else - AddParseError(lineError!); - break; - case "--start": - case "--start-line": - var startFlag = normalizedArg; - if (!TryReadRawOptionValue(args, ref i, startFlag, inlineValue, out var startValue, out var missingStartError)) - AddParseError(missingStartError!); - else if (TryParsePositiveInt(startValue!, startFlag, out var parsedStart, out var startError)) - { - WarnIfDuplicateSingleValueOption("--start", startValue!); - startLine = parsedStart; - } - else - AddParseError(startError!); - break; - case "--end": - case "--end-line": - var endFlag = normalizedArg; - if (!TryReadRawOptionValue(args, ref i, endFlag, inlineValue, out var endValue, out var missingEndError)) - AddParseError(missingEndError!); - else if (TryParsePositiveInt(endValue!, endFlag, out var parsedEnd, out var endError)) - { - WarnIfDuplicateSingleValueOption("--end", endValue!); - endLine = parsedEnd; - endLineExplicit = true; - } - else - AddParseError(endError!); - break; - case "--context": - if (!TryReadRawOptionValue(args, ref i, "--context", inlineValue, out var contextValue, out var missingContextError)) - AddParseError(missingContextError!); - else if (TryParseNonNegativeInt(contextValue!, "--context", out var parsedContext, out var contextError)) - { - WarnIfDuplicateSingleValueOption("--context", contextValue!); - contextBefore = parsedContext; - contextAfter = parsedContext; - contextAfterExplicit = true; - symmetricContext = parsedContext; - } - else - AddParseError(contextError!); - break; - case "--before": - if (!TryReadRawOptionValue(args, ref i, "--before", inlineValue, out var beforeValue, out var missingBeforeError)) - AddParseError(missingBeforeError!); - else if (TryParseNonNegativeInt(beforeValue!, "--before", out var parsedBefore, out var beforeError)) - { - WarnIfDuplicateSingleValueOption("--before", beforeValue!); - contextBefore = parsedBefore; - explicitContextBefore = parsedBefore; - } - else - AddParseError(beforeError!); - break; - case "--after": - if (!TryReadRawOptionValue(args, ref i, "--after", inlineValue, out var afterValue, out var missingAfterError)) - AddParseError(missingAfterError!); - else if (TryParseNonNegativeInt(afterValue!, "--after", out var parsedAfter, out var afterError)) - { - WarnIfDuplicateSingleValueOption("--after", afterValue!); - contextAfter = parsedAfter; - explicitContextAfter = parsedAfter; - } - else - AddParseError(afterError!); - break; - case "--focus-line": - if (!TryReadRawOptionValue(args, ref i, "--focus-line", inlineValue, out var focusLineValue, out var missingFocusLineError)) - AddParseError(missingFocusLineError!); - else if (TryParsePositiveInt(focusLineValue!, "--focus-line", out var parsedFocusLine, out var focusLineError)) - { - WarnIfDuplicateSingleValueOption("--focus-line", focusLineValue!); - focusLine = parsedFocusLine; - } - else - AddParseError(focusLineError!); - break; - case "--focus-column": - if (!TryReadRawOptionValue(args, ref i, "--focus-column", inlineValue, out var focusColumnValue, out var missingFocusColumnError)) - AddParseError(missingFocusColumnError!); - else if (TryParsePositiveInt(focusColumnValue!, "--focus-column", out var parsedFocusColumn, out var focusColumnError)) - { - WarnIfDuplicateSingleValueOption("--focus-column", focusColumnValue!); - focusColumn = parsedFocusColumn; - } - else - AddParseError(focusColumnError!); - break; - case "--focus-length": - if (!TryReadRawOptionValue(args, ref i, "--focus-length", inlineValue, out var focusLengthValue, out var missingFocusLengthError)) - AddParseError(missingFocusLengthError!); - else if (TryParsePositiveInt(focusLengthValue!, "--focus-length", out var parsedFocusLength, out var focusLengthError)) - { - WarnIfDuplicateSingleValueOption("--focus-length", focusLengthValue!); - focusLength = parsedFocusLength; - } - else - AddParseError(focusLengthError!); - break; - case "--name": - if (TryReadStringOptionValue(args, ref i, "--name", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var extraName, out var nameError)) - extraNames.Add(extraName!); // Repeatable; OR-joined with other --name values and extra positional names / 繰り返し可、他の --name や追加の positional 引数と OR 結合 - else - AddParseError($"{nameError} / --name には値(シンボル名パターン)が必要です。"); - break; - case "--snippet-lines": - if (!TryReadRawOptionValue(args, ref i, "--snippet-lines", inlineValue, out var snippetLinesValue, out var missingSnippetLinesError)) - AddParseError(missingSnippetLinesError!); - else if (TryParseNonNegativeInt(snippetLinesValue!, "--snippet-lines", out var parsedSnippetLines, out var snippetLinesError)) - { - WarnIfDuplicateSingleValueOption("--snippet-lines", snippetLinesValue!); - snippetLines = parsedSnippetLines; - snippetLinesExplicit = true; - } - else - AddParseError(snippetLinesError!); - break; - case "--snippet-focus": - if (!TryReadStringOptionValue(args, ref i, "--snippet-focus", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var snippetFocusValue, out var snippetFocusError)) - { - AddParseError(snippetFocusError!); - } - else if (TryParseSnippetFocusMode(snippetFocusValue!, out var parsedSnippetFocus)) - { - WarnIfDuplicateSingleValueOption("--snippet-focus", snippetFocusValue!); - snippetFocus = parsedSnippetFocus; - } - else - { - AddParseError($"Error: invalid --snippet-focus value '{ConsoleUi.FormatBoundedValue(snippetFocusValue)}'. Use leftmost, quality, or proximity."); - } - break; - case "--max-line-width": - if (!TryReadRawOptionValue(args, ref i, "--max-line-width", inlineValue, out var maxLineWidthValue, out var missingMaxLineWidthError)) - AddParseError(missingMaxLineWidthError!); - else if (TryParseNonNegativeInt(maxLineWidthValue!, "--max-line-width", out var parsedMaxLineWidth, out var maxLineWidthError)) - { - WarnIfDuplicateSingleValueOption("--max-line-width", maxLineWidthValue!); - maxLineWidth = parsedMaxLineWidth; - maxLineWidthExplicit = true; - } - else - AddParseError(maxLineWidthError!); - break; - default: - if (args[i].StartsWith('-')) - { - AddParseError($"Error: unsupported option: {ConsoleUi.FormatBoundedValue(args[i])}. Use `--` before a query literal that starts with `-`."); - break; - } - else if (query == null && positionalGlobAsPath && DbReader.PathLikePatternHasWildcard(args[i])) - { - pathPatterns.Add(args[i]); - userPathPatterns.Add(args[i]); - } - else if (query == null) - { - query = args[i]; - } - else - { - // Extra positional args become additional symbol names / 追加の positional 引数を追加の symbol name として扱う - extraNames.Add(args[i]); - } - break; - } - } - - if (unusedActionable) - { - unusedBucket ??= "likely_unused_private"; - minUnusedConfidence ??= "medium"; - if (visibilityFilters.Count == 0) - visibilityFilters.Add("private"); - excludeTests = true; - } - - var dbResolution = DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, dbPath, dataDir); - var resolvedDbPath = dbResolution.DbPath; - - if (parseErrors == null && projectFilters.Count > 0) - { - try - { - projectFilterRootResolution = ResolveProjectFilterRoot(resolvedDbPath, dbPathExplicit); - foreach (var glob in SolutionProjectResolver.ResolveProjectDirectoryGlobs(projectFilterRootResolution.Value.Root, projectFilters, solutionFilter)) - pathPatterns.Add(glob); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) - { - AddParseError($"Error: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); - } - } - - ValidateQueryPathOptionValues(userPathPatterns, excludePaths, AddParseError); - if (guardFilters.Count > DbReader.MaxSearchGuardFilters) - AddParseError($"Error: search accepts at most {DbReader.MaxSearchGuardFilters} guard filters; got {guardFilters.Count}."); - var duplicateNamedQuery = namedSearchQueries - .GroupBy(query => query.Name, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(group => group.Count() > 1); - if (duplicateNamedQuery != null) - AddParseError($"Error: duplicate --named-query name '{ConsoleUi.FormatBoundedValue(duplicateNamedQuery.Key)}'. Use unique names so grouped results are unambiguous."); - if (duplicateConfidenceExplicit && duplicateThresholdExplicit) - AddParseError("Error: --duplicate-confidence and --duplicate-threshold cannot be combined; use the preset or the explicit score threshold."); - if (parseErrors == null - && applySearchSourceDefaults - && auditScopeExplicit - && recipeName == null - && !listRecipes - && string.Equals(auditScope, SearchAuditRecipes.DefaultAuditScope, StringComparison.OrdinalIgnoreCase)) - { - if (pathPatterns.Count == 0) - AddDistinct(pathPatterns, SearchAuditRecipes.DefaultSourcePathPatterns); - AddDistinct(excludePaths, SearchAuditRecipes.DefaultSourceExcludePaths); - AddSourceOnlyDefaultExcludeOrigin(excludeOrigins, matchOrigins, SearchMatchClassifier.Comment); - AddSourceOnlyDefaultExcludeOrigin(excludeOrigins, matchOrigins, SearchMatchClassifier.HelpText); - AddSourceOnlyDefaultExcludeOrigin(excludeOrigins, matchOrigins, SearchMatchClassifier.SchemaDescription); - excludeTests = true; - } - - if (validateDefaultLimit && !limitExplicit && defaultLimitError != null) - AddParseError(defaultLimitError); - if (validateDefaultSnippetLines && !snippetLinesExplicit && defaultSnippetLinesError != null) - AddParseError(defaultSnippetLinesError); - if (validateDefaultMaxLineWidth && !maxLineWidthExplicit && defaultMaxLineWidthError != null) - AddParseError(defaultMaxLineWidthError); - - if (staleAfter.HasValue) - statusCheckScopes?.Add("workspace"); - - if (readOnly) - { - var canAppendReadOnlyFlags = !SqliteFileUri.StartsWithFileScheme(resolvedDbPath) || - SqliteFileUri.TryValidateBounds(resolvedDbPath, out _); - if (canAppendReadOnlyFlags) - resolvedDbPath = DbContext.ToReadOnlyUri(resolvedDbPath); - } - - return new QueryCommandOptions - { - DbPath = resolvedDbPath, - DbPathExplicit = dbPathExplicit, - ReadOnly = readOnly, - DryRun = dryRun, - DataDir = dbResolution.DataDir, - DataDirSource = dbResolution.DataDirSource, - Json = json ?? jsonDefault, - JsonOutputFormat = jsonOutputFormat, - JsonOutputFormatExplicit = jsonOutputFormatExplicit, - OutputFormat = outputFormat, - Limit = limit, - TotalLimit = totalLimit, - LimitExplicit = limitExplicit, - Lang = lang, - Kind = kind, - UnusedBucket = unusedBucket, - MinUnusedConfidence = minUnusedConfidence, - UnusedActionable = unusedActionable, - Severity = severity, - Query = query, - RawFts = rawFts, - IncludeBody = includeBody, - BodyStartLine = bodyStartLine, - BodyLines = bodyLines, - StartLine = startLine, - EndLine = endLine, - ContextBefore = contextBefore, - ContextAfter = contextAfter, - ContextAfterExplicit = contextAfterExplicit, - SymmetricContext = symmetricContext, - ExplicitContextBefore = explicitContextBefore, - ExplicitContextAfter = explicitContextAfter, - ImpactDeprecatedDepthUsed = impactDeprecatedDepthUsed, - FocusLine = focusLine, - FocusColumn = focusColumn, - FocusLength = focusLength, - SnippetLines = snippetLines, - SnippetFocus = snippetFocus, - MaxLineWidth = maxLineWidth, - PathPatterns = pathPatterns, - WorkspaceDbPaths = workspaceDbPaths, - ProjectFilters = projectFilters, - ProjectFilterRoot = projectFilterRootResolution?.Root, - ProjectFilterRootFallbackReason = projectFilterRootResolution?.FallbackReason, - SolutionFilter = solutionFilter, - ExcludePaths = excludePaths, - VisibilityFilters = visibilityFilters, - ExcludeVisibilityFilters = excludeVisibilityFilters, - ExcludeTests = excludeTests, - IncludeGenerated = includeGenerated, - CountOnly = countOnly, - GroupPartials = groupPartials, - All = all, - StrictNotFound = strictNotFound, - AllowPartial = allowPartial, - Strict = strict, - Since = since, - NoDedup = noDedup, - NoVisibilityRank = noVisibilityRank, - Exact = exact, - Regex = regex, - Prefix = prefix, - GuardFilters = guardFilters, - GuardWindow = guardWindow, - GuardScope = guardScope, - ExcludeComments = excludeComments, - ExcludeStrings = excludeStrings, - ExcludeFixtures = excludeFixtures, - ExactName = exactName, - ExactSubstring = exactSubstring, - TokenBoundary = tokenBoundary, - CheckWorkspace = checkWorkspace, - StatusCheckMode = checkWorkspace - ? statusCheckExplicit - ? StatusCheckModeExplicit - : StatusCheckModeImpliedByStaleAfter - : null, - StaleAfter = staleAfter, - StatusCheckScopes = statusCheckScopes, - WithPaths = withPaths, - GroupBy = groupBy, - UniqueBy = uniqueBy, - CountBy = countBy, - MatchOrigins = matchOrigins, - ExcludeOrigins = excludeOrigins, - ResultKinds = resultKinds, - SearchFields = searchFields, - OutlineFields = outlineFields, - OutlineFieldsExplicit = outlineFieldsExplicit, - FirstPerFile = firstPerFile, - ResultsOnly = resultsOnly, - NextSteps = nextSteps, - GroupedPerFileLimit = groupedPerFileLimit, - GroupedPerFileLimitExplicit = groupedPerFileLimitExplicit, - SampleSize = sampleSize, - MaxJsonBytes = maxJsonBytes, - RawBytes = rawBytes, - RawKinds = rawKinds, - Verbose = verbose, - Profile = profile, - SlowQueryMs = slowQueryMs, - Compact = compact, - InspectFields = inspectFields, - MinEntrypointConfidence = minEntrypointConfidence, - StatusExplainField = statusExplainField, - StatusLogPath = statusLogPath, - StatusConfig = statusConfig, - RankMode = rankMode, - SymbolSortMode = symbolSortMode, - SortValue = sortValue, - SortExplicit = sortExplicit, - ExtraNames = extraNames, - MapSections = mapSections, - SummaryOnly = summaryOnly, - MapSummaryOnly = mapSummaryOnly, - DependencyCycles = dependencyCycles, - DependencyCycleGraphBudget = dependencyCycleGraphBudget, - DependencySuppressNoise = dependencySuppressNoise, - DependencySymbols = dependencySymbols, - DependencySymbolFamilies = dependencySymbolFamilies, - RecipeName = recipeName, - IncludeRecipeQueries = includeRecipeQueries, - ExcludeRecipeQueries = excludeRecipeQueries, - ShowExcluded = showExcluded, - ListRecipes = listRecipes, - NamesOnly = namesOnly, - OpenIssuesPath = openIssuesPath, - AuditScope = auditScope, - AuditScopeExplicit = auditScopeExplicit, - OpenIssuesRepository = openIssuesRepository, - IssueState = issueState, - DuplicateConfidence = duplicateThresholdExplicit ? IssueDuplicatePreflight.CustomDuplicateConfidence : duplicateConfidence, - DuplicateThreshold = duplicateThreshold, - DuplicatePreflightTuningExplicit = duplicateConfidenceExplicit || duplicateThresholdExplicit, - IssueTitle = issueTitle, - IssueLabels = issueLabels, - SearchCursor = searchCursor, - UnusedCursorOffset = unusedCursorOffset, - OutlineCursorOffset = outlineCursorOffset, - CursorValue = rawCursorValue, - DependencyCycleCursor = dependencyCycleCursor, - NamedSearchQueries = namedSearchQueries, - LanguagesIndexedOnly = languagesIndexedOnly, - LanguageCapabilities = languageCapabilities, - LanguageLookups = languageLookups, - LanguageExtensionLookups = languageExtensionLookups, - LanguageAliasLookups = languageAliasLookups, - SourceOnly = sourceOnly, - NoSemanticTokens = noSemanticTokens, - ParseError = parseErrors == null ? null : string.Join(Environment.NewLine, parseErrors), - }; - } + => new QueryArgumentParser( + jsonDefault, + allowNamedQuery, + allowStatusCheck, + allowIssueDraftsFormat, + validateDefaultLimit, + validateDefaultSnippetLines, + validateDefaultMaxLineWidth, + applySearchSourceDefaults, + allowOutlineSort, + positionalGlobAsPath).Parse(args); private static bool TryParseNamedSearchQuery(string value, out SearchNamedQuery namedQuery, out string? error) { diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs new file mode 100644 index 000000000..82e722cfb --- /dev/null +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Filters.cs @@ -0,0 +1,143 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class QueryCommandRunner +{ + private sealed partial class QueryArgumentParser + { + private bool TryParseFilterOption(string normalizedArg, string currentArg, string? inlineValue, string[] args, ref int i) + { + switch (normalizedArg) + { + case "--kind": + if (TryReadStringOptionValue(args, ref i, "--kind", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var kindValue, out var kindError)) + { + WarnIfDuplicateSingleValueOption("--kind", kindValue!); + // Normalize to lowercase so '--kind FUNCTION' == '--kind function'. AllValidKinds entries + // and every DB 'symbols.kind' row are lowercase. + // '--kind FUNCTION' と '--kind function' を同一視するため lowercase 正規化する。AllValidKinds + // と DB の `symbols.kind` はすべて lowercase。 + kind = kindValue?.ToLowerInvariant(); + } + else + AddParseError(kindError!); + break; + case "--bucket": + if (TryReadStringOptionValue(args, ref i, "--bucket", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var unusedBucketValue, out var unusedBucketError)) + { + WarnIfDuplicateSingleValueOption("--bucket", unusedBucketValue!); + unusedBucket = unusedBucketValue?.ToLowerInvariant(); + } + else + AddParseError(unusedBucketError!); + break; + case "--confidence": + case "--min-confidence": + var confidenceFlag = normalizedArg; + if (TryReadStringOptionValue(args, ref i, confidenceFlag, inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var minUnusedConfidenceValue, out var minUnusedConfidenceError)) + { + WarnIfDuplicateSingleValueOption("--min-confidence", minUnusedConfidenceValue!); + minUnusedConfidence = minUnusedConfidenceValue?.ToLowerInvariant(); + } + else + AddParseError(minUnusedConfidenceError!); + break; + case "--severity": + if (TryReadStringOptionValue(args, ref i, "--severity", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var severityValue, out var severityError)) + { + WarnIfDuplicateSingleValueOption("--severity", severityValue!); + severity = severityValue?.ToLowerInvariant(); + } + else + { + AddParseError(severityError!); + } + break; + case "--visibility": + if (TryReadStringOptionValue(args, ref i, "--visibility", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var visibilityValue, out var visibilityError)) + AddVisibilityFilterValues("--visibility", visibilityValue!, visibilityFilters, AddParseError); + else + AddParseError(visibilityError!); + break; + case "--exclude-visibility": + if (TryReadStringOptionValue(args, ref i, "--exclude-visibility", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var excludeVisibilityValue, out var excludeVisibilityError)) + AddVisibilityFilterValues("--exclude-visibility", excludeVisibilityValue!, excludeVisibilityFilters, AddParseError); + else + AddParseError(excludeVisibilityError!); + break; + case "--rank-by": + if (TryReadStringOptionValue(args, ref i, "--rank-by", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var rankByValue, out var rankByError)) + { + WarnIfDuplicateSingleValueOption("--rank-by", rankByValue!); + if (TryParseReferenceRankMode(rankByValue!, out var parsedRankMode)) + rankMode = parsedRankMode; + else + AddParseError($"Error: --rank-by must be one of weighted, count, kind; got '{rankByValue}'."); + } + else + AddParseError(rankByError!); + break; + case "--sort": + if (TryReadStringOptionValue(args, ref i, "--sort", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var sortRawValue, out var sortError)) + { + WarnIfDuplicateSingleValueOption("--sort", sortRawValue!); + var normalizedSortValue = sortRawValue!; + if (allowOutlineSort && TryParseOutlineSortMode(normalizedSortValue, out _)) + { + sortExplicit = true; + } + else if (!allowOutlineSort && TryParseSymbolSortMode(normalizedSortValue, out var parsedSortMode)) + { + symbolSortMode = parsedSortMode; + sortExplicit = true; + } + else + { + var allowedSortValues = allowOutlineSort + ? "source, kind, references, size, span, complexity, path, or name" + : "hotspot, references, size, complexity, path"; + AddParseError($"Error: --sort must be one of {allowedSortValues}; got '{normalizedSortValue}'."); + } + sortValue = normalizedSortValue; + } + else + AddParseError(sortError!); + break; + case "--sections": + if (TryReadStringOptionValue(args, ref i, "--sections", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var sectionsValue, out var sectionsError)) + { + WarnIfDuplicateSingleValueOption("--sections", sectionsValue!); + mapSections = ParseMapSections(sectionsValue!, AddParseError); + } + else + AddParseError(sectionsError!); + break; + case "--summary-only": + summaryOnly = true; + mapSummaryOnly = true; + break; + case "--fields": + if (TryReadStringOptionValue(args, ref i, "--fields", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var fieldsValue, out var fieldsError)) + { + WarnIfDuplicateSingleValueOption("--fields", fieldsValue!); + inspectFields = ParseInspectFields(fieldsValue!, AddParseError, out var includeBodyFromFields); + includeBody |= includeBodyFromFields; + json = true; + outputFormat = OutputFormatJson; + } + else + { + AddParseError(fieldsError!); + } + break; + default: + return false; + } + + return true; + } + } +} diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs new file mode 100644 index 000000000..c57bca380 --- /dev/null +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs @@ -0,0 +1,220 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class QueryCommandRunner +{ + private sealed partial class QueryArgumentParser + { + private bool TryParseGeneralOption(string normalizedArg, string currentArg, string? inlineValue, string[] args, ref int i) + { + switch (normalizedArg) + { + case "--": + if (i + 1 >= args.Length) + { + AddParseError("Error: -- requires a following literal query."); + } + else if (query == null) + { + query = args[++i]; + } + else + { + extraNames.Add(args[++i]); + } + break; + case "--db": + if (TryReadStringOptionValue(args, ref i, "--db", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var dbPathValue, out var dbPathError)) + { + WarnIfDuplicateSingleValueOption("--db", dbPathValue!); + dbPath = dbPathValue!; + dbPathExplicit = true; + } + else + AddParseError(dbPathError!); + break; + case "--read-only": + case "--immutable": + readOnly = true; + break; + case "--dry-run": + dryRun = true; + break; + case "--pretty": + break; + case "--compact": + compact = true; + json = true; + outputFormat = OutputFormatJson; + break; + case "--body-only": + includeBody = true; + inspectFields = ["definitions"]; + json = true; + outputFormat = OutputFormatJson; + break; + case "--outline-only": + inspectFields = ["file", "definitions", "nearby_symbols"]; + json = true; + if (outputFormat == OutputFormatText) + outputFormat = OutputFormatJson; + break; + case "--workspace-db": + if (TryReadStringOptionValue(args, ref i, "--workspace-db", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var workspaceDbPath, out var workspaceDbError)) + workspaceDbPaths.Add(workspaceDbPath!); + else + AddParseError(workspaceDbError!); + break; + case "--data-dir": + if (TryReadStringOptionValue(args, ref i, "--data-dir", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var dataDirValue, out var dataDirError)) + { + WarnIfDuplicateSingleValueOption("--data-dir", dataDirValue!); + dataDir = dataDirValue!; + } + else + AddParseError(dataDirError!); + break; + case "--json": + if (inlineValue == null) + { + json = true; + if (outputFormat == OutputFormatText) + outputFormat = OutputFormatJson; + } + else if (TryParseJsonOutputFormat(inlineValue, out var parsedJsonOutputFormat)) + { + json = true; + jsonOutputFormat = parsedJsonOutputFormat; + jsonOutputFormatExplicit = true; + if (outputFormat == OutputFormatText) + outputFormat = OutputFormatJson; + } + else + { + AddParseError($"Error: --json format must be one of ndjson or array, got '{ConsoleUi.FormatBoundedValue(inlineValue)}'. Hint: use `--json` or `--json=ndjson` for newline-delimited JSON, or `--json=array` for a single JSON array."); + } + break; + case "--indexed-only": + languagesIndexedOnly = true; + break; + case "--capability": + if (!TryReadStringOptionValue(args, ref i, "--capability", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var capabilityValue, out var capabilityError)) + { + AddParseError(capabilityError!); + } + else if (TryNormalizeLanguageCapability(capabilityValue!, out var capability)) + { + languageCapabilities.Add(capability); + } + else + { + AddParseError($"Error: unsupported --capability value '{ConsoleUi.FormatBoundedValue(capabilityValue)}'. Use all, none, graph, references, symbols, missing-any, missing-graph, missing-references, missing-symbols, or search-only."); + } + break; + case "--language": + if (TryReadStringOptionValue(args, ref i, "--language", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var languageValue, out var languageError)) + { + languageLookups.Add(languageValue!); + lang = NormalizeLangFilterValue(languageValue); + } + else + { + AddParseError(languageError!); + } + break; + case "--extension": + if (TryReadStringOptionValue(args, ref i, "--extension", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var languageExtensionValue, out var languageExtensionError)) + languageExtensionLookups.Add(languageExtensionValue!); + else + AddParseError(languageExtensionError!); + break; + case "--alias": + if (TryReadStringOptionValue(args, ref i, "--alias", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var languageAliasValue, out var languageAliasError)) + languageAliasLookups.Add(languageAliasValue!); + else + AddParseError(languageAliasError!); + break; + case "--format": + if (TryReadStringOptionValue(args, ref i, "--format", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var formatValue, out var formatError)) + { + WarnIfDuplicateSingleValueOption("--format", formatValue!); + if (TryParseOutputFormat(formatValue!, out var parsedOutputFormat)) + { + outputFormat = parsedOutputFormat; + outputFormatExplicit = true; + if (parsedOutputFormat == OutputFormatCompact) + compact = true; + if (parsedOutputFormat == OutputFormatCount) + countOnly = true; + if (parsedOutputFormat != OutputFormatText && + parsedOutputFormat != OutputFormatDot && + parsedOutputFormat != OutputFormatGraphMl) + json = true; + } + else if (allowIssueDraftsFormat && string.Equals(formatValue, OutputFormatIssueDrafts, StringComparison.OrdinalIgnoreCase)) + { + outputFormat = OutputFormatIssueDrafts; + outputFormatExplicit = true; + json = true; + } + else + { + var allowedFormats = allowIssueDraftsFormat + ? "text, json, count, compact, csv, tsv, lsp, qf, sarif, or issue-drafts" + : "text, json, count, compact, csv, tsv, lsp, qf, or sarif"; + AddParseError($"Error: --format must be one of {allowedFormats}; got '{ConsoleUi.FormatBoundedValue(formatValue)}'."); + } + } + else + { + AddParseError(formatError!); + } + break; + case "--limit": + case "--max-results": + case "--top": + var limitOptionName = normalizedArg == "--top" ? "--limit" : normalizedArg; + if (!TryReadRawOptionValue(args, ref i, limitOptionName, inlineValue, out var limitValue, out var missingLimitError)) + AddParseError(missingLimitError!); + else if (TryParsePositiveInt(limitValue!, limitOptionName, out var parsedLimit, out var limitError)) + { + WarnIfDuplicateSingleValueOption("--limit", limitValue!); + limit = parsedLimit; + limitExplicit = true; + } + else + AddParseError(limitError!); + break; + case "--graph-budget": + if (!TryReadRawOptionValue(args, ref i, "--graph-budget", inlineValue, out var graphBudgetValue, out var missingGraphBudgetError)) + AddParseError(missingGraphBudgetError!); + else if (TryParsePositiveInt(graphBudgetValue!, "--graph-budget", out var parsedGraphBudget, out var graphBudgetError)) + { + WarnIfDuplicateSingleValueOption("--graph-budget", graphBudgetValue!); + dependencyCycleGraphBudget = parsedGraphBudget; + } + else + AddParseError(graphBudgetError!); + break; + case "--total-limit": + if (!TryReadRawOptionValue(args, ref i, "--total-limit", inlineValue, out var totalLimitValue, out var missingTotalLimitError)) + AddParseError(missingTotalLimitError!); + else if (TryParseNonNegativeInt(totalLimitValue!, "--total-limit", out var parsedTotalLimit, out var totalLimitError)) + { + WarnIfDuplicateSingleValueOption("--total-limit", totalLimitValue!); + totalLimit = parsedTotalLimit; + } + else + AddParseError(totalLimitError!); + break; + default: + return false; + } + + return true; + } + } +} diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Locations.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Locations.cs new file mode 100644 index 000000000..bf94c47be --- /dev/null +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Locations.cs @@ -0,0 +1,241 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class QueryCommandRunner +{ + private sealed partial class QueryArgumentParser + { + private bool TryParseLocationOption(string normalizedArg, string currentArg, string? inlineValue, string[] args, ref int i) + { + switch (normalizedArg) + { + case "--path": + if (TryReadStringOptionValue(args, ref i, "--path", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var pathPattern, out var pathError)) + { + pathPatterns.Add(pathPattern!); // Repeatable; multiple values OR together / 繰り返し可、複数値は OR で結合 + userPathPatterns.Add(pathPattern!); + } + else + AddParseError(pathError!); + break; + case "--project": + if (TryReadStringOptionValue(args, ref i, "--project", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var projectName, out var projectError)) + projectFilters.Add(projectName!); + else + AddParseError(projectError!); + break; + case "--solution": + if (TryReadStringOptionValue(args, ref i, "--solution", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var solutionValue, out var solutionError)) + { + WarnIfDuplicateSingleValueOption("--solution", solutionValue!); + solutionFilter = solutionValue; + } + else + AddParseError(solutionError!); + break; + case "--exclude-path": + if (TryReadStringOptionValue(args, ref i, "--exclude-path", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var excludePath, out var excludePathError)) + excludePaths.Add(excludePath!); + else + AddParseError(excludePathError!); + break; + case "--exclude-tests": + excludeTests = true; + break; + case "--no-semantic-tokens": + noSemanticTokens = true; + break; + case "--exclude-comments": + excludeComments = true; + break; + case "--exclude-strings": + excludeStrings = true; + break; + case "--exclude-fixtures": + excludeFixtures = true; + break; + case "--actionable": + unusedActionable = true; + break; + case "--include-generated": + case "--generated": + includeGenerated = true; + break; + case "--since": + if (!TryReadStringOptionValue(args, ref i, "--since", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var sinceValue, out var sinceError)) + AddParseError(sinceError!); + else if (TryParseIso8601Since(sinceValue!, out var parsedSince)) + { + WarnIfDuplicateSingleValueOption("--since", sinceValue!); + since = parsedSince; + } + else + AddParseError($"Error: could not parse --since value '{ConsoleUi.FormatBoundedValue(sinceValue)}' as a date/time. Use ISO 8601 format (e.g. 2024-01-01 or 2024-01-01T00:00:00Z)."); + break; + case "--line": + if (!TryReadRawOptionValue(args, ref i, "--line", inlineValue, out var lineValue, out var missingLineError)) + AddParseError(missingLineError!); + else if (TryParsePositiveInt(lineValue!, "--line", out var parsedLine, out var lineError)) + { + WarnIfDuplicateSingleValueOption("--start", lineValue!); + startLine = parsedLine; + if (!endLineExplicit) + endLine = parsedLine; + } + else + AddParseError(lineError!); + break; + case "--start": + case "--start-line": + var startFlag = normalizedArg; + if (!TryReadRawOptionValue(args, ref i, startFlag, inlineValue, out var startValue, out var missingStartError)) + AddParseError(missingStartError!); + else if (TryParsePositiveInt(startValue!, startFlag, out var parsedStart, out var startError)) + { + WarnIfDuplicateSingleValueOption("--start", startValue!); + startLine = parsedStart; + } + else + AddParseError(startError!); + break; + case "--end": + case "--end-line": + var endFlag = normalizedArg; + if (!TryReadRawOptionValue(args, ref i, endFlag, inlineValue, out var endValue, out var missingEndError)) + AddParseError(missingEndError!); + else if (TryParsePositiveInt(endValue!, endFlag, out var parsedEnd, out var endError)) + { + WarnIfDuplicateSingleValueOption("--end", endValue!); + endLine = parsedEnd; + endLineExplicit = true; + } + else + AddParseError(endError!); + break; + case "--context": + if (!TryReadRawOptionValue(args, ref i, "--context", inlineValue, out var contextValue, out var missingContextError)) + AddParseError(missingContextError!); + else if (TryParseNonNegativeInt(contextValue!, "--context", out var parsedContext, out var contextError)) + { + WarnIfDuplicateSingleValueOption("--context", contextValue!); + contextBefore = parsedContext; + contextAfter = parsedContext; + contextAfterExplicit = true; + symmetricContext = parsedContext; + } + else + AddParseError(contextError!); + break; + case "--before": + if (!TryReadRawOptionValue(args, ref i, "--before", inlineValue, out var beforeValue, out var missingBeforeError)) + AddParseError(missingBeforeError!); + else if (TryParseNonNegativeInt(beforeValue!, "--before", out var parsedBefore, out var beforeError)) + { + WarnIfDuplicateSingleValueOption("--before", beforeValue!); + contextBefore = parsedBefore; + explicitContextBefore = parsedBefore; + } + else + AddParseError(beforeError!); + break; + case "--after": + if (!TryReadRawOptionValue(args, ref i, "--after", inlineValue, out var afterValue, out var missingAfterError)) + AddParseError(missingAfterError!); + else if (TryParseNonNegativeInt(afterValue!, "--after", out var parsedAfter, out var afterError)) + { + WarnIfDuplicateSingleValueOption("--after", afterValue!); + contextAfter = parsedAfter; + explicitContextAfter = parsedAfter; + } + else + AddParseError(afterError!); + break; + case "--focus-line": + if (!TryReadRawOptionValue(args, ref i, "--focus-line", inlineValue, out var focusLineValue, out var missingFocusLineError)) + AddParseError(missingFocusLineError!); + else if (TryParsePositiveInt(focusLineValue!, "--focus-line", out var parsedFocusLine, out var focusLineError)) + { + WarnIfDuplicateSingleValueOption("--focus-line", focusLineValue!); + focusLine = parsedFocusLine; + } + else + AddParseError(focusLineError!); + break; + case "--focus-column": + if (!TryReadRawOptionValue(args, ref i, "--focus-column", inlineValue, out var focusColumnValue, out var missingFocusColumnError)) + AddParseError(missingFocusColumnError!); + else if (TryParsePositiveInt(focusColumnValue!, "--focus-column", out var parsedFocusColumn, out var focusColumnError)) + { + WarnIfDuplicateSingleValueOption("--focus-column", focusColumnValue!); + focusColumn = parsedFocusColumn; + } + else + AddParseError(focusColumnError!); + break; + case "--focus-length": + if (!TryReadRawOptionValue(args, ref i, "--focus-length", inlineValue, out var focusLengthValue, out var missingFocusLengthError)) + AddParseError(missingFocusLengthError!); + else if (TryParsePositiveInt(focusLengthValue!, "--focus-length", out var parsedFocusLength, out var focusLengthError)) + { + WarnIfDuplicateSingleValueOption("--focus-length", focusLengthValue!); + focusLength = parsedFocusLength; + } + else + AddParseError(focusLengthError!); + break; + case "--name": + if (TryReadStringOptionValue(args, ref i, "--name", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var extraName, out var nameError)) + extraNames.Add(extraName!); // Repeatable; OR-joined with other --name values and extra positional names / 繰り返し可、他の --name や追加の positional 引数と OR 結合 + else + AddParseError($"{nameError} / --name には値(シンボル名パターン)が必要です。"); + break; + case "--snippet-lines": + if (!TryReadRawOptionValue(args, ref i, "--snippet-lines", inlineValue, out var snippetLinesValue, out var missingSnippetLinesError)) + AddParseError(missingSnippetLinesError!); + else if (TryParseNonNegativeInt(snippetLinesValue!, "--snippet-lines", out var parsedSnippetLines, out var snippetLinesError)) + { + WarnIfDuplicateSingleValueOption("--snippet-lines", snippetLinesValue!); + snippetLines = parsedSnippetLines; + snippetLinesExplicit = true; + } + else + AddParseError(snippetLinesError!); + break; + case "--snippet-focus": + if (!TryReadStringOptionValue(args, ref i, "--snippet-focus", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var snippetFocusValue, out var snippetFocusError)) + { + AddParseError(snippetFocusError!); + } + else if (TryParseSnippetFocusMode(snippetFocusValue!, out var parsedSnippetFocus)) + { + WarnIfDuplicateSingleValueOption("--snippet-focus", snippetFocusValue!); + snippetFocus = parsedSnippetFocus; + } + else + { + AddParseError($"Error: invalid --snippet-focus value '{ConsoleUi.FormatBoundedValue(snippetFocusValue)}'. Use leftmost, quality, or proximity."); + } + break; + case "--max-line-width": + if (!TryReadRawOptionValue(args, ref i, "--max-line-width", inlineValue, out var maxLineWidthValue, out var missingMaxLineWidthError)) + AddParseError(missingMaxLineWidthError!); + else if (TryParseNonNegativeInt(maxLineWidthValue!, "--max-line-width", out var parsedMaxLineWidth, out var maxLineWidthError)) + { + WarnIfDuplicateSingleValueOption("--max-line-width", maxLineWidthValue!); + maxLineWidth = parsedMaxLineWidth; + maxLineWidthExplicit = true; + } + else + AddParseError(maxLineWidthError!); + break; + default: + return false; + } + + return true; + } + } +} diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs new file mode 100644 index 000000000..04535c3df --- /dev/null +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs @@ -0,0 +1,277 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class QueryCommandRunner +{ + private sealed partial class QueryArgumentParser + { + private bool TryParseResultOption(string normalizedArg, string currentArg, string? inlineValue, string[] args, ref int i) + { + switch (normalizedArg) + { + case "--fts": + rawFts = true; + break; + case "--body": + includeBody = true; + break; + case "--body-start": + if (!TryReadRawOptionValue(args, ref i, "--body-start", inlineValue, out var bodyStartValue, out var missingBodyStartError)) + AddParseError(missingBodyStartError!); + else if (TryParsePositiveInt(bodyStartValue!, "--body-start", out var parsedBodyStartLine, out var bodyStartError)) + { + WarnIfDuplicateSingleValueOption("--body-start", bodyStartValue!); + bodyStartLine = parsedBodyStartLine; + includeBody = true; + } + else + AddParseError(bodyStartError!); + break; + case "--body-lines": + case "--body-line-count": + var bodyLinesFlag = normalizedArg; + if (!TryReadRawOptionValue(args, ref i, bodyLinesFlag, inlineValue, out var bodyLinesValue, out var missingBodyLinesError)) + AddParseError(missingBodyLinesError!); + else if (TryParsePositiveInt(bodyLinesValue!, bodyLinesFlag, out var parsedBodyLines, out var bodyLinesError)) + { + WarnIfDuplicateSingleValueOption("--body-lines", bodyLinesValue!); + bodyLines = parsedBodyLines; + includeBody = true; + } + else + AddParseError(bodyLinesError!); + break; + case "--count": + countOnly = true; + break; + case "--group-partials": + groupPartials = true; + break; + case "--cycles": + dependencyCycles = true; + break; + case "--suppress-noise": + dependencySuppressNoise = true; + break; + case "--symbol": + if (TryReadStringOptionValue(args, ref i, "--symbol", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var dependencySymbolValue, out var dependencySymbolError)) + AddDependencySymbolFilter("--symbol", dependencySymbolValue!, dependencySymbols); + else + AddParseError(dependencySymbolError!); + break; + case "--symbol-family": + if (TryReadStringOptionValue(args, ref i, "--symbol-family", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var dependencySymbolFamilyValue, out var dependencySymbolFamilyError)) + AddDependencySymbolFilter("--symbol-family", dependencySymbolFamilyValue!, dependencySymbolFamilies); + else + AddParseError(dependencySymbolFamilyError!); + break; + case "--strict-not-found": + strictNotFound = true; + break; + case "--allow-partial": + allowPartial = true; + break; + case "--strict": + strict = true; + break; + case "--by-bucket": + break; + case "--all": + all = true; + break; + case "--no-dedup": + noDedup = true; + break; + case "--no-visibility-rank": + noVisibilityRank = true; + break; + case "--exact": + exact = true; + break; + case "--regex": + regex = true; + break; + case "--exact-name": + exactName = true; + break; + case "--exact-substring": + exactSubstring = true; + break; + case "--token-boundary": + tokenBoundary = true; + break; + case "--prefix": + prefix = true; + break; + case "--max-hops": + case "--depth": + var depthOptionName = normalizedArg; + if (!TryReadRawOptionValue(args, ref i, depthOptionName, inlineValue, out var depthValue, out var missingDepthError)) + AddParseError(missingDepthError!); + else if (TryParseNonNegativeInt(depthValue!, depthOptionName, out var parsedDepth, out var depthError)) + { + WarnIfDuplicateSingleValueOption("--max-hops", depthValue!); + contextAfter = parsedDepth; // reused as depth for impact / impact用に再利用 + contextAfterExplicit = true; + if (depthOptionName == "--depth") + impactDeprecatedDepthUsed = true; + } + else + AddParseError(depthError!); + break; + case "--reverse": + break; // handled by specific commands / 特定コマンドで処理 + case "--group-by-name": + break; + case "--group-by": + if (TryReadStringOptionValue(args, ref i, "--group-by", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var groupByValue, out var groupByError)) + { + WarnIfDuplicateSingleValueOption("--group-by", groupByValue!); + groupBy = groupByValue?.ToLowerInvariant(); + } + else + AddParseError(groupByError!); + break; + case "--unique": + if (TryReadStringOptionValue(args, ref i, "--unique", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var uniqueValue, out var uniqueError)) + { + WarnIfDuplicateSingleValueOption("--unique", uniqueValue!); + uniqueBy = uniqueValue?.ToLowerInvariant(); + } + else + AddParseError(uniqueError!); + break; + case "--count-by": + if (TryReadStringOptionValue(args, ref i, "--count-by", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var countByValue, out var countByError)) + { + WarnIfDuplicateSingleValueOption("--count-by", countByValue!); + countBy = countByValue?.ToLowerInvariant(); + } + else + AddParseError(countByError!); + break; + case "--origin": + case "--match-origin": + var originOptionName = normalizedArg; + if (TryReadStringOptionValue(args, ref i, originOptionName, inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var originValue, out var originError)) + AddSearchMatchOrigins(originOptionName, originValue!, matchOrigins, AddParseError); + else + AddParseError(originError!); + break; + case "--exclude-origin": + if (TryReadStringOptionValue(args, ref i, "--exclude-origin", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var excludedOriginValue, out var excludedOriginError)) + AddSearchMatchOrigins("--exclude-origin", excludedOriginValue!, excludeOrigins, AddParseError); + else + AddParseError(excludedOriginError!); + break; + case "--result-kind": + if (TryReadStringOptionValue(args, ref i, "--result-kind", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var resultKindValue, out var resultKindError)) + AddSearchResultKinds(resultKindValue!, resultKinds, AddParseError); + else + AddParseError(resultKindError!); + break; + case "--search-fields": + if (TryReadStringOptionValue(args, ref i, "--search-fields", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var searchFieldsValue, out var searchFieldsError)) + { + WarnIfDuplicateSingleValueOption("--search-fields", searchFieldsValue!); + searchFields = ParseSearchProjectionFields(searchFieldsValue!, AddParseError); + json = true; + outputFormat = OutputFormatJson; + } + else + AddParseError(searchFieldsError!); + break; + case "--first-per-file": + firstPerFile = true; + break; + case "--results-only": + resultsOnly = true; + json = true; + if (!outputFormatExplicit) + outputFormat = OutputFormatJson; + break; + case "--next-steps": + nextSteps = true; + break; + case "--sample": + if (!TryReadRawOptionValue(args, ref i, "--sample", inlineValue, out var sampleValue, out var missingSampleError)) + AddParseError(missingSampleError!); + else if (TryParsePositiveInt(sampleValue!, "--sample", out var parsedSample, out var sampleError)) + { + WarnIfDuplicateSingleValueOption("--sample", sampleValue!); + sampleSize = parsedSample; + } + else + AddParseError(sampleError!); + break; + case "--per-file-limit": + groupedPerFileLimitExplicit = true; + if (!TryReadRawOptionValue(args, ref i, "--per-file-limit", inlineValue, out var perFileLimitValue, out var missingPerFileLimitError)) + AddParseError(missingPerFileLimitError!); + else if (TryParsePositiveInt(perFileLimitValue!, "--per-file-limit", out var parsedPerFileLimit, out var perFileLimitError)) + { + WarnIfDuplicateSingleValueOption("--per-file-limit", perFileLimitValue!); + groupedPerFileLimit = Math.Min(parsedPerFileLimit, MaxSearchGroupedPerFileLimit); + } + else + AddParseError(perFileLimitError!); + break; + case "--max-json-bytes": + if (!TryReadRawOptionValue(args, ref i, "--max-json-bytes", inlineValue, out var maxJsonBytesValue, out var missingMaxJsonBytesError)) + AddParseError(missingMaxJsonBytesError!); + else if (TryParsePositiveInt(maxJsonBytesValue!, "--max-json-bytes", out var parsedMaxJsonBytes, out var maxJsonBytesError)) + { + WarnIfDuplicateSingleValueOption("--max-json-bytes", maxJsonBytesValue!); + maxJsonBytes = Math.Min(parsedMaxJsonBytes, MaxSearchJsonByteLimit); + } + else + AddParseError(maxJsonBytesError!); + break; + case "--with-paths": + withPaths = true; + break; + case "--bytes": + rawBytes = true; + break; + case "--raw-kinds": + rawKinds = true; + break; + case "--verbose": + verbose = true; + break; + case "--profile": + profile = true; + break; + case "--slow-query-ms": + if (!TryReadRawOptionValue(args, ref i, "--slow-query-ms", inlineValue, out var slowQueryValue, out var missingSlowQueryError)) + AddParseError(missingSlowQueryError!); + else if (TryParseNonNegativeInt(slowQueryValue!, "--slow-query-ms", out var parsedSlowQueryMs, out var slowQueryError)) + { + WarnIfDuplicateSingleValueOption("--slow-query-ms", slowQueryValue!); + slowQueryMs = parsedSlowQueryMs; + } + else + AddParseError(slowQueryError!); + break; + case "--min-entrypoint-confidence": + if (!TryReadRawOptionValue(args, ref i, "--min-entrypoint-confidence", inlineValue, out var minEntrypointConfidenceValue, out var missingMinEntrypointConfidenceError)) + AddParseError(missingMinEntrypointConfidenceError!); + else if (TryParseConfidence(minEntrypointConfidenceValue!, out var parsedMinEntrypointConfidence)) + { + WarnIfDuplicateSingleValueOption("--min-entrypoint-confidence", minEntrypointConfidenceValue!); + minEntrypointConfidence = parsedMinEntrypointConfidence; + } + else + AddParseError($"Error: --min-entrypoint-confidence must be a number from 0.0 through 1.0; got '{ConsoleUi.FormatBoundedValue(minEntrypointConfidenceValue)}'."); + break; + default: + return false; + } + + return true; + } + } +} diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs new file mode 100644 index 000000000..d30372188 --- /dev/null +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Search.cs @@ -0,0 +1,287 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class QueryCommandRunner +{ + private sealed partial class QueryArgumentParser + { + private bool TryParseSearchOption(string normalizedArg, string currentArg, string? inlineValue, string[] args, ref int i) + { + switch (normalizedArg) + { + case "--lang": + if (TryReadStringOptionValue(args, ref i, "--lang", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var langValue, out var langError)) + { + WarnIfDuplicateSingleValueOption("--lang", langValue!); + // Normalize to lowercase so '--lang Python' == '--lang python' — every LangMap key and + // every DB 'files.lang' row is lowercase, so the SQL filter and WriteLangHint match. + // Also fold common short aliases (e.g. `py`) to canonical language names so Python-heavy + // workflows can use familiar shorthand without silently returning zero rows. + // '--lang Python' と '--lang python' を同一視するため lowercase 正規化する。LangMap の key と + // DB の `files.lang` はすべて lowercase なので、SQL filter と WriteLangHint が一致する。 + // さらに `py` のような短縮エイリアスを正規名へ畳み込み、Python 利用時の慣用入力で + // 意図せず 0 件になる事故を避ける。 + lang = NormalizeLangFilterValue(langValue); + } + else + AddParseError(langError!); + break; + case "--query": + if (!allowNamedQuery) + { + AddParseError("Error: --query is not supported by this command."); + if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) + i++; + } + else if (TryReadStringOptionValue(args, ref i, "--query", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var queryValue, out var queryError)) + { + WarnIfDuplicateSingleValueOption("--query", queryValue!); + query = queryValue; + } + else + AddParseError(queryError!); + break; + case "--recipe": + if (TryReadStringOptionValue(args, ref i, "--recipe", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var recipeValue, out var recipeError)) + { + WarnIfDuplicateSingleValueOption("--recipe", recipeValue!); + recipeName = recipeValue; + } + else + AddParseError(recipeError!); + break; + case "--include-query": + if (TryReadStringOptionValue(args, ref i, "--include-query", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var includeQueryValue, out var includeQueryError)) + AddRecipeQuerySelectors("--include-query", includeQueryValue!, includeRecipeQueries); + else + AddParseError(includeQueryError!); + break; + case "--exclude-query": + if (TryReadStringOptionValue(args, ref i, "--exclude-query", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var excludeQueryValue, out var excludeQueryError)) + AddRecipeQuerySelectors("--exclude-query", excludeQueryValue!, excludeRecipeQueries); + else + AddParseError(excludeQueryError!); + break; + case "--show-excluded": + showExcluded = true; + break; + case "--list-recipes": + listRecipes = true; + break; + case "--names": + namesOnly = true; + break; + case "--source-only": + sourceOnly = true; + auditScope = SearchAuditRecipes.DefaultAuditScope; + auditScopeExplicit = true; + break; + case "--open-issues": + if (TryReadStringOptionValue(args, ref i, "--open-issues", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var openIssuesValue, out var openIssuesError)) + { + WarnIfDuplicateSingleValueOption("--open-issues", openIssuesValue!); + openIssuesPath = openIssuesValue; + } + else + AddParseError(openIssuesError!); + break; + case "--audit-scope": + if (!TryReadStringOptionValue(args, ref i, "--audit-scope", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var auditScopeValue, out var auditScopeError)) + { + AddParseError(auditScopeError!); + } + else if (TryNormalizeSearchAuditScope(auditScopeValue!, out var normalizedAuditScope)) + { + WarnIfDuplicateSingleValueOption("--audit-scope", auditScopeValue!); + auditScope = normalizedAuditScope; + auditScopeExplicit = true; + } + else + { + AddParseError($"Error: unsupported --audit-scope value '{ConsoleUi.FormatBoundedValue(auditScopeValue)}'. Use source or all."); + } + break; + case "--repo": + if (TryReadStringOptionValue(args, ref i, "--repo", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var repoValue, out var repoError)) + { + WarnIfDuplicateSingleValueOption("--repo", repoValue!); + openIssuesRepository = repoValue; + } + else + AddParseError(repoError!); + break; + case "--issue-state": + if (TryReadStringOptionValue(args, ref i, "--issue-state", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var issueStateValue, out var issueStateError)) + issueState = issueStateValue!.ToLowerInvariant(); + else + AddParseError(issueStateError!); + break; + case "--duplicate-confidence": + if (TryReadStringOptionValue(args, ref i, "--duplicate-confidence", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var duplicateConfidenceValue, out var duplicateConfidenceError)) + { + WarnIfDuplicateSingleValueOption("--duplicate-confidence", duplicateConfidenceValue!); + if (IssueDuplicatePreflight.TryNormalizeDuplicateConfidence(duplicateConfidenceValue!, out var normalizedDuplicateConfidence)) + { + duplicateConfidence = normalizedDuplicateConfidence; + duplicateThreshold = IssueDuplicatePreflight.ThresholdForDuplicateConfidence(normalizedDuplicateConfidence); + duplicateConfidenceExplicit = true; + } + else + { + AddParseError($"Error: --duplicate-confidence must be one of low, medium, high; got '{ConsoleUi.FormatBoundedValue(duplicateConfidenceValue)}'."); + } + } + else + { + AddParseError(duplicateConfidenceError!); + } + break; + case "--duplicate-threshold": + if (!TryReadRawOptionValue(args, ref i, "--duplicate-threshold", inlineValue, out var duplicateThresholdValue, out var missingDuplicateThresholdError)) + { + AddParseError(missingDuplicateThresholdError!); + } + else if (TryParseConfidence(duplicateThresholdValue!, out var parsedDuplicateThreshold)) + { + WarnIfDuplicateSingleValueOption("--duplicate-threshold", duplicateThresholdValue!); + duplicateThreshold = parsedDuplicateThreshold; + duplicateThresholdExplicit = true; + } + else + { + AddParseError($"Error: --duplicate-threshold must be a number between 0 and 1; got '{ConsoleUi.FormatBoundedValue(duplicateThresholdValue)}'."); + } + break; + case "--issue-title": + if (TryReadStringOptionValue(args, ref i, "--issue-title", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var issueTitleValue, out var issueTitleError)) + { + WarnIfDuplicateSingleValueOption("--issue-title", issueTitleValue!); + var trimmedTitle = issueTitleValue!.Trim(); + if (trimmedTitle.Length == 0) + AddParseError("Error: --issue-title value cannot be empty."); + else if (trimmedTitle.Length > MaxIssueDraftTitleLength) + AddParseError($"Error: --issue-title value too long (max {MaxIssueDraftTitleLength} characters)."); + else + issueTitle = trimmedTitle; + } + else + AddParseError(issueTitleError!); + break; + case "--issue-label": + if (TryReadStringOptionValue(args, ref i, "--issue-label", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var issueLabelValue, out var issueLabelError)) + AddIssueDraftLabels(issueLabelValue!); + else + AddParseError(issueLabelError!); + break; + case "--cursor": + var allowSeparatedDashPrefixedCursorValue = inlineValue is null + && i + 1 < args.Length + && TryParseSearchCursor(args[i + 1], out _); + if (TryReadStringOptionValue(args, ref i, "--cursor", inlineValue, allowSeparatedDashPrefixedLiteralValue: allowSeparatedDashPrefixedCursorValue, out var cursorValue, out var cursorError)) + { + WarnIfDuplicateSingleValueOption("--cursor", cursorValue!); + var parsedCursorValue = cursorValue!; + if (TryParseSearchCursor(parsedCursorValue, out var parsedCursor)) + searchCursor = parsedCursor; + else if (TryParseUnusedCursor(parsedCursorValue, out var parsedUnusedCursorOffset)) + unusedCursorOffset = parsedUnusedCursorOffset; + else if (TryParseOutlineCursor(parsedCursorValue, out var parsedOutlineCursorOffset)) + outlineCursorOffset = parsedOutlineCursorOffset; + else if (TryParseDependencyCycleCursor(parsedCursorValue, out var parsedDependencyCycleCursor)) + dependencyCycleCursor = parsedDependencyCycleCursor; + else + { + AddParseError("Error: --cursor must be a search, unused, outline, or dependency-cycle pagination cursor returned as `next_cursor`."); + break; + } + rawCursorValue = parsedCursorValue; + } + else + { + AddParseError(cursorError!); + } + break; + case "--named-query": + if (!allowNamedQuery) + { + AddParseError("Error: --named-query is not supported by this command."); + if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) + i++; + } + else if (TryReadStringOptionValue(args, ref i, "--named-query", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var namedQueryValue, out var namedQueryError)) + { + if (TryParseNamedSearchQuery(namedQueryValue!, out var namedQuery, out var namedQueryParseError)) + namedSearchQueries.Add(namedQuery); + else + AddParseError(namedQueryParseError!); + } + else + { + AddParseError(namedQueryError!); + } + break; + case "--require-before": + if (TryReadStringOptionValue(args, ref i, "--require-before", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var requireBeforeValue, out var requireBeforeError)) + AddSearchGuardFilter("--require-before", SearchGuardRole.Require, SearchGuardDirection.Before, requireBeforeValue!); + else + AddParseError(requireBeforeError!); + break; + case "--require-after": + if (TryReadStringOptionValue(args, ref i, "--require-after", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var requireAfterValue, out var requireAfterError)) + AddSearchGuardFilter("--require-after", SearchGuardRole.Require, SearchGuardDirection.After, requireAfterValue!); + else + AddParseError(requireAfterError!); + break; + case "--reject-before": + if (TryReadStringOptionValue(args, ref i, "--reject-before", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var rejectBeforeValue, out var rejectBeforeError)) + AddSearchGuardFilter("--reject-before", SearchGuardRole.Reject, SearchGuardDirection.Before, rejectBeforeValue!); + else + AddParseError(rejectBeforeError!); + break; + case "--reject-after": + if (TryReadStringOptionValue(args, ref i, "--reject-after", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var rejectAfterValue, out var rejectAfterError)) + AddSearchGuardFilter("--reject-after", SearchGuardRole.Reject, SearchGuardDirection.After, rejectAfterValue!); + else + AddParseError(rejectAfterError!); + break; + case "--guard-window": + if (!TryReadRawOptionValue(args, ref i, "--guard-window", inlineValue, out var guardWindowValue, out var missingGuardWindowError)) + { + AddParseError(missingGuardWindowError!); + } + else if (TryParseNonNegativeInt(guardWindowValue!, "--guard-window", out var parsedGuardWindow, out var guardWindowError)) + { + WarnIfDuplicateSingleValueOption("--guard-window", guardWindowValue!); + if (parsedGuardWindow > DbReader.MaxSearchGuardWindow) + AddParseError($"Error: --guard-window must be between 0 and {DbReader.MaxSearchGuardWindow}; got {parsedGuardWindow}."); + else + guardWindow = parsedGuardWindow; + } + else + { + AddParseError(guardWindowError!); + } + break; + case "--guard-scope": + if (TryReadStringOptionValue(args, ref i, "--guard-scope", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var guardScopeValue, out var guardScopeError)) + { + WarnIfDuplicateSingleValueOption("--guard-scope", guardScopeValue!); + if (TryNormalizeSearchGuardScope(guardScopeValue!, out var parsedGuardScope)) + guardScope = parsedGuardScope; + else + AddParseError($"Error: unsupported --guard-scope value '{ConsoleUi.FormatBoundedValue(guardScopeValue!)}'. Use window or same-line."); + } + else + AddParseError(guardScopeError!); + break; + default: + return false; + } + + return true; + } + } +} diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Status.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Status.cs new file mode 100644 index 000000000..63a1c0491 --- /dev/null +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Status.cs @@ -0,0 +1,127 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class QueryCommandRunner +{ + private sealed partial class QueryArgumentParser + { + private bool TryParseStatusOption(string normalizedArg, string currentArg, string? inlineValue, string[] args, ref int i) + { + switch (normalizedArg) + { + case "--check": + if (allowStatusCheck) + { + checkWorkspace = true; + statusCheckExplicit = true; + } + else if (allowNamedQuery && query == null) + { + query = currentArg; + } + else + { + AddParseError("Error: --check is not supported by this command."); + } + break; + case "--outline-fields": + if (TryReadStringOptionValue(args, ref i, "--outline-fields", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var outlineFieldsValue, out var outlineFieldsError)) + { + WarnIfDuplicateSingleValueOption("--outline-fields", outlineFieldsValue!); + outlineFields = ParseOutlineProjectionFields(outlineFieldsValue!, AddParseError); + outlineFieldsExplicit = true; + json = true; + outputFormat = OutputFormatJson; + } + else + { + AddParseError(outlineFieldsError!); + } + break; + case "--stale-after": + if (allowStatusCheck) + { + if (TryReadStringOptionValue(args, ref i, "--stale-after", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var staleAfterValue, out var staleAfterError)) + { + WarnIfDuplicateSingleValueOption("--stale-after", staleAfterValue!); + if (TryParseStaleAfter(staleAfterValue!, out var parsedStaleAfter, out var parseStaleAfterError)) + { + staleAfter = parsedStaleAfter; + checkWorkspace = true; + } + else + AddParseError(parseStaleAfterError!); + } + else + { + AddParseError(staleAfterError!); + } + } + else + { + AddParseError("Error: --stale-after is not supported by this command."); + } + break; + case "--explain": + if (allowStatusCheck) + { + if (TryReadStringOptionValue(args, ref i, "--explain", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var explainValue, out var explainError)) + { + WarnIfDuplicateSingleValueOption("--explain", explainValue!); + statusExplainField = explainValue; + } + else + AddParseError(explainError!); + } + else if (allowNamedQuery && query == null) + { + query = currentArg; + } + else + { + AddParseError("Error: --explain is not supported by this command."); + } + break; + case "--log-path": + if (allowStatusCheck) + { + statusLogPath = true; + } + else + { + AddParseError("Error: --log-path is not supported by this command."); + } + break; + case "--config": + if (allowStatusCheck) + { + statusConfig = true; + } + else + { + AddParseError("Error: --config is only supported by status."); + } + break; + case "--log-format": + case "--log-retain-count": + case "--log-max-size-mb": + if (allowNamedQuery && query == null) + { + query = currentArg; + } + else + { + AddParseError($"Error: unsupported option: {ConsoleUi.FormatBoundedValue(currentArg)}. Use `--` before a query literal that starts with `-`."); + } + break; + default: + return false; + } + + return true; + } + } +} diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs new file mode 100644 index 000000000..ce22b1664 --- /dev/null +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -0,0 +1,663 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class QueryCommandRunner +{ + private sealed partial class QueryArgumentParser + { + private readonly bool jsonDefault; + private readonly bool allowNamedQuery; + private readonly bool allowStatusCheck; + private readonly bool allowIssueDraftsFormat; + private readonly bool validateDefaultLimit; + private readonly bool validateDefaultSnippetLines; + private readonly bool validateDefaultMaxLineWidth; + private readonly bool applySearchSourceDefaults; + private readonly bool allowOutlineSort; + private readonly bool positionalGlobAsPath; + private string? dbPath; + private string? dataDir; + private bool? json; + private string jsonOutputFormat = JsonOutputFormatNdjson; + private bool jsonOutputFormatExplicit; + private int limit; + private readonly string? defaultLimitError; + private int? totalLimit; + private string? lang; + private string? kind; + private string? unusedBucket; + private string? minUnusedConfidence; + private string? severity; + private string? query; + private bool rawFts; + private bool includeBody; + private int? bodyStartLine; + private int? bodyLines; + private bool countOnly; + private bool groupPartials; + private bool all; + private bool strictNotFound; + private bool allowPartial; + private int? startLine; + private int? endLine; + private bool endLineExplicit; + private int contextBefore; + private int contextAfter; + private int? symmetricContext; + private int? explicitContextBefore; + private int? explicitContextAfter; + private int? focusLine; + private int? focusColumn; + private int focusLength = 1; + private int snippetLines; + private readonly string? defaultSnippetLinesError; + private SearchSnippetFocusMode snippetFocus = SearchSnippetFocusMode.Quality; + private int maxLineWidth; + private readonly string? defaultMaxLineWidthError; + private bool contextAfterExplicit; + private List pathPatterns = []; + private List userPathPatterns = []; + private List workspaceDbPaths = []; + private List projectFilters = []; + private string? solutionFilter; + private List excludePaths = []; + private List visibilityFilters = []; + private List excludeVisibilityFilters = []; + private bool excludeTests; + private bool unusedActionable; + private bool includeGenerated; + private DateTime? since; + private bool noDedup; + private bool noVisibilityRank; + private bool exact; + private bool regex; + private bool prefix; + private List guardFilters = []; + private int guardWindow = DbReader.DefaultSearchGuardWindow; + private SearchGuardScope guardScope = SearchGuardScope.Window; + private bool excludeComments; + private bool excludeStrings; + private bool excludeFixtures; + private List? parseErrors; + private bool exactName; + private bool exactSubstring; + private bool tokenBoundary; + private bool dbPathExplicit; + private bool readOnly; + private bool dryRun; + private bool checkWorkspace; + private bool statusCheckExplicit; + private TimeSpan? staleAfter; + private HashSet? statusCheckScopes; + private bool withPaths; + private string? groupBy; + private string? uniqueBy; + private string? countBy; + private List matchOrigins = []; + private List excludeOrigins = []; + private List resultKinds = []; + private List? searchFields; + private List? outlineFields; + private bool outlineFieldsExplicit; + private bool firstPerFile; + private bool resultsOnly; + private bool nextSteps; + private int groupedPerFileLimit = DefaultSearchGroupedPerFileLimit; + private bool groupedPerFileLimitExplicit; + private int? sampleSize; + private int? maxJsonBytes; + private bool rawBytes; + private bool rawKinds; + private bool verbose; + private bool profile; + private int? slowQueryMs; + private bool compact; + private List? inspectFields; + private double minEntrypointConfidence; + private string? statusExplainField; + private bool statusLogPath; + private string outputFormat = OutputFormatText; + private bool outputFormatExplicit; + private bool statusConfig; + private bool limitExplicit; + private bool snippetLinesExplicit; + private bool maxLineWidthExplicit; + private bool strict; + private ReferenceRankMode rankMode = ReferenceRankMode.Weighted; + private SymbolSortMode symbolSortMode = SymbolSortMode.Name; + private string? sortValue; + private bool sortExplicit; + private List extraNames = []; + private bool impactDeprecatedDepthUsed; + private List? mapSections; + private bool summaryOnly; + private bool mapSummaryOnly; + private bool dependencyCycles; + private int dependencyCycleGraphBudget = DefaultDependencyCycleGraphBudget; + private bool dependencySuppressNoise; + private List dependencySymbols = []; + private List dependencySymbolFamilies = []; + private bool dependencySymbolFilterCountExceeded; + private string? recipeName; + private List includeRecipeQueries = []; + private List excludeRecipeQueries = []; + private bool showExcluded; + private bool listRecipes; + private bool namesOnly; + private string? openIssuesPath; + private string auditScope = SearchAuditRecipes.DefaultAuditScope; + private bool auditScopeExplicit; + private string? openIssuesRepository; + private string issueState = IssueDuplicatePreflight.DefaultIssueState; + private string duplicateConfidence = IssueDuplicatePreflight.DefaultDuplicateConfidence; + private double duplicateThreshold = IssueDuplicatePreflight.DefaultDuplicateThreshold; + private bool duplicateConfidenceExplicit; + private bool duplicateThresholdExplicit; + private string? issueTitle; + private List issueLabels = []; + private SearchCursor? searchCursor; + private int? unusedCursorOffset; + private int? outlineCursorOffset; + private string? rawCursorValue; + private DependencyCycleCursor? dependencyCycleCursor; + private List namedSearchQueries = []; + private bool languagesIndexedOnly; + private List languageCapabilities = []; + private List languageLookups = []; + private List languageExtensionLookups = []; + private List languageAliasLookups = []; + private bool sourceOnly; + private bool noSemanticTokens; + private ProjectFilterRootResolution? projectFilterRootResolution; + private readonly HashSet seenSingleValueOptions = new(StringComparer.Ordinal); + + internal QueryArgumentParser( + bool jsonDefault, + bool allowNamedQuery, + bool allowStatusCheck, + bool allowIssueDraftsFormat, + bool validateDefaultLimit, + bool validateDefaultSnippetLines, + bool validateDefaultMaxLineWidth, + bool applySearchSourceDefaults, + bool allowOutlineSort, + bool positionalGlobAsPath) + { + this.jsonDefault = jsonDefault; + this.allowNamedQuery = allowNamedQuery; + this.allowStatusCheck = allowStatusCheck; + this.allowIssueDraftsFormat = allowIssueDraftsFormat; + this.validateDefaultLimit = validateDefaultLimit; + this.validateDefaultSnippetLines = validateDefaultSnippetLines; + this.validateDefaultMaxLineWidth = validateDefaultMaxLineWidth; + this.applySearchSourceDefaults = applySearchSourceDefaults; + this.allowOutlineSort = allowOutlineSort; + this.positionalGlobAsPath = positionalGlobAsPath; + limit = ResolveDefaultPositiveInt(DefaultLimitEnvironmentVariable, DefaultQueryLimit, "--limit", out defaultLimitError); + snippetLines = ResolveDefaultPositiveInt(DefaultSnippetLinesEnvironmentVariable, SearchSnippetFormatter.DefaultSnippetLines, "--snippet-lines", out defaultSnippetLinesError); + maxLineWidth = ResolveDefaultNonNegativeInt(DefaultMaxLineWidthEnvironmentVariable, LineWidthFormatter.DefaultMaxLineWidth, "--max-line-width", out defaultMaxLineWidthError); + } + + internal QueryCommandOptions Parse(string[] args) + { + ParseRawArguments(args); + + if (unusedActionable) + { + unusedBucket ??= "likely_unused_private"; + minUnusedConfidence ??= "medium"; + if (visibilityFilters.Count == 0) + visibilityFilters.Add("private"); + excludeTests = true; + } + + var dbResolution = DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, dbPath, dataDir); + var resolvedDbPath = dbResolution.DbPath; + + ResolveProjectFilters(resolvedDbPath); + ValidateParsedOptions(); + ApplySearchSourceOptionDefaults(); + ValidateEnvironmentDefaults(); + + if (staleAfter.HasValue) + statusCheckScopes?.Add("workspace"); + + if (readOnly) + { + var canAppendReadOnlyFlags = !SqliteFileUri.StartsWithFileScheme(resolvedDbPath) || + SqliteFileUri.TryValidateBounds(resolvedDbPath, out _); + if (canAppendReadOnlyFlags) + resolvedDbPath = DbContext.ToReadOnlyUri(resolvedDbPath); + } + + return BuildOptions(dbResolution, resolvedDbPath); + } + + private void ResolveProjectFilters(string resolvedDbPath) + { + if (parseErrors != null || projectFilters.Count == 0) + return; + + try + { + projectFilterRootResolution = ResolveProjectFilterRoot(resolvedDbPath, dbPathExplicit); + foreach (var glob in SolutionProjectResolver.ResolveProjectDirectoryGlobs(projectFilterRootResolution.Value.Root, projectFilters, solutionFilter)) + pathPatterns.Add(glob); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + AddParseError($"Error: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); + } + } + + private void ValidateParsedOptions() + { + ValidateQueryPathOptionValues(userPathPatterns, excludePaths, AddParseError); + if (guardFilters.Count > DbReader.MaxSearchGuardFilters) + AddParseError($"Error: search accepts at most {DbReader.MaxSearchGuardFilters} guard filters; got {guardFilters.Count}."); + var duplicateNamedQuery = namedSearchQueries + .GroupBy(query => query.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateNamedQuery != null) + AddParseError($"Error: duplicate --named-query name '{ConsoleUi.FormatBoundedValue(duplicateNamedQuery.Key)}'. Use unique names so grouped results are unambiguous."); + if (duplicateConfidenceExplicit && duplicateThresholdExplicit) + AddParseError("Error: --duplicate-confidence and --duplicate-threshold cannot be combined; use the preset or the explicit score threshold."); + } + + private void ApplySearchSourceOptionDefaults() + { + if (parseErrors != null + || !applySearchSourceDefaults + || !auditScopeExplicit + || recipeName != null + || listRecipes + || !string.Equals(auditScope, SearchAuditRecipes.DefaultAuditScope, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (pathPatterns.Count == 0) + AddDistinct(pathPatterns, SearchAuditRecipes.DefaultSourcePathPatterns); + AddDistinct(excludePaths, SearchAuditRecipes.DefaultSourceExcludePaths); + AddSourceOnlyDefaultExcludeOrigin(excludeOrigins, matchOrigins, SearchMatchClassifier.Comment); + AddSourceOnlyDefaultExcludeOrigin(excludeOrigins, matchOrigins, SearchMatchClassifier.HelpText); + AddSourceOnlyDefaultExcludeOrigin(excludeOrigins, matchOrigins, SearchMatchClassifier.SchemaDescription); + excludeTests = true; + } + + private void ValidateEnvironmentDefaults() + { + if (validateDefaultLimit && !limitExplicit && defaultLimitError != null) + AddParseError(defaultLimitError); + if (validateDefaultSnippetLines && !snippetLinesExplicit && defaultSnippetLinesError != null) + AddParseError(defaultSnippetLinesError); + if (validateDefaultMaxLineWidth && !maxLineWidthExplicit && defaultMaxLineWidthError != null) + AddParseError(defaultMaxLineWidthError); + } + + private QueryCommandOptions BuildOptions(DbPathResolution dbResolution, string resolvedDbPath) + { + return new QueryCommandOptions + { + DbPath = resolvedDbPath, + DbPathExplicit = dbPathExplicit, + ReadOnly = readOnly, + DryRun = dryRun, + DataDir = dbResolution.DataDir, + DataDirSource = dbResolution.DataDirSource, + Json = json ?? jsonDefault, + JsonOutputFormat = jsonOutputFormat, + JsonOutputFormatExplicit = jsonOutputFormatExplicit, + OutputFormat = outputFormat, + Limit = limit, + TotalLimit = totalLimit, + LimitExplicit = limitExplicit, + Lang = lang, + Kind = kind, + UnusedBucket = unusedBucket, + MinUnusedConfidence = minUnusedConfidence, + UnusedActionable = unusedActionable, + Severity = severity, + Query = query, + RawFts = rawFts, + IncludeBody = includeBody, + BodyStartLine = bodyStartLine, + BodyLines = bodyLines, + StartLine = startLine, + EndLine = endLine, + ContextBefore = contextBefore, + ContextAfter = contextAfter, + ContextAfterExplicit = contextAfterExplicit, + SymmetricContext = symmetricContext, + ExplicitContextBefore = explicitContextBefore, + ExplicitContextAfter = explicitContextAfter, + ImpactDeprecatedDepthUsed = impactDeprecatedDepthUsed, + FocusLine = focusLine, + FocusColumn = focusColumn, + FocusLength = focusLength, + SnippetLines = snippetLines, + SnippetFocus = snippetFocus, + MaxLineWidth = maxLineWidth, + PathPatterns = pathPatterns, + WorkspaceDbPaths = workspaceDbPaths, + ProjectFilters = projectFilters, + ProjectFilterRoot = projectFilterRootResolution?.Root, + ProjectFilterRootFallbackReason = projectFilterRootResolution?.FallbackReason, + SolutionFilter = solutionFilter, + ExcludePaths = excludePaths, + VisibilityFilters = visibilityFilters, + ExcludeVisibilityFilters = excludeVisibilityFilters, + ExcludeTests = excludeTests, + IncludeGenerated = includeGenerated, + CountOnly = countOnly, + GroupPartials = groupPartials, + All = all, + StrictNotFound = strictNotFound, + AllowPartial = allowPartial, + Strict = strict, + Since = since, + NoDedup = noDedup, + NoVisibilityRank = noVisibilityRank, + Exact = exact, + Regex = regex, + Prefix = prefix, + GuardFilters = guardFilters, + GuardWindow = guardWindow, + GuardScope = guardScope, + ExcludeComments = excludeComments, + ExcludeStrings = excludeStrings, + ExcludeFixtures = excludeFixtures, + ExactName = exactName, + ExactSubstring = exactSubstring, + TokenBoundary = tokenBoundary, + CheckWorkspace = checkWorkspace, + StatusCheckMode = checkWorkspace + ? statusCheckExplicit + ? StatusCheckModeExplicit + : StatusCheckModeImpliedByStaleAfter + : null, + StaleAfter = staleAfter, + StatusCheckScopes = statusCheckScopes, + WithPaths = withPaths, + GroupBy = groupBy, + UniqueBy = uniqueBy, + CountBy = countBy, + MatchOrigins = matchOrigins, + ExcludeOrigins = excludeOrigins, + ResultKinds = resultKinds, + SearchFields = searchFields, + OutlineFields = outlineFields, + OutlineFieldsExplicit = outlineFieldsExplicit, + FirstPerFile = firstPerFile, + ResultsOnly = resultsOnly, + NextSteps = nextSteps, + GroupedPerFileLimit = groupedPerFileLimit, + GroupedPerFileLimitExplicit = groupedPerFileLimitExplicit, + SampleSize = sampleSize, + MaxJsonBytes = maxJsonBytes, + RawBytes = rawBytes, + RawKinds = rawKinds, + Verbose = verbose, + Profile = profile, + SlowQueryMs = slowQueryMs, + Compact = compact, + InspectFields = inspectFields, + MinEntrypointConfidence = minEntrypointConfidence, + StatusExplainField = statusExplainField, + StatusLogPath = statusLogPath, + StatusConfig = statusConfig, + RankMode = rankMode, + SymbolSortMode = symbolSortMode, + SortValue = sortValue, + SortExplicit = sortExplicit, + ExtraNames = extraNames, + MapSections = mapSections, + SummaryOnly = summaryOnly, + MapSummaryOnly = mapSummaryOnly, + DependencyCycles = dependencyCycles, + DependencyCycleGraphBudget = dependencyCycleGraphBudget, + DependencySuppressNoise = dependencySuppressNoise, + DependencySymbols = dependencySymbols, + DependencySymbolFamilies = dependencySymbolFamilies, + RecipeName = recipeName, + IncludeRecipeQueries = includeRecipeQueries, + ExcludeRecipeQueries = excludeRecipeQueries, + ShowExcluded = showExcluded, + ListRecipes = listRecipes, + NamesOnly = namesOnly, + OpenIssuesPath = openIssuesPath, + AuditScope = auditScope, + AuditScopeExplicit = auditScopeExplicit, + OpenIssuesRepository = openIssuesRepository, + IssueState = issueState, + DuplicateConfidence = duplicateThresholdExplicit ? IssueDuplicatePreflight.CustomDuplicateConfidence : duplicateConfidence, + DuplicateThreshold = duplicateThreshold, + DuplicatePreflightTuningExplicit = duplicateConfidenceExplicit || duplicateThresholdExplicit, + IssueTitle = issueTitle, + IssueLabels = issueLabels, + SearchCursor = searchCursor, + UnusedCursorOffset = unusedCursorOffset, + OutlineCursorOffset = outlineCursorOffset, + CursorValue = rawCursorValue, + DependencyCycleCursor = dependencyCycleCursor, + NamedSearchQueries = namedSearchQueries, + LanguagesIndexedOnly = languagesIndexedOnly, + LanguageCapabilities = languageCapabilities, + LanguageLookups = languageLookups, + LanguageExtensionLookups = languageExtensionLookups, + LanguageAliasLookups = languageAliasLookups, + SourceOnly = sourceOnly, + NoSemanticTokens = noSemanticTokens, + ParseError = parseErrors == null ? null : string.Join(Environment.NewLine, parseErrors), + }; + } + + private void AddParseError(string error) + { + parseErrors ??= []; + parseErrors.Add(error); + } + + private void AddSearchGuardFilter(string optionName, SearchGuardRole role, SearchGuardDirection direction, string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + AddParseError(BuildMissingOptionValueError(optionName)); + return; + } + if (value.Length > QueryLimits.MaxQueryLength) + { + AddParseError($"Error: {optionName} query too long (max {QueryLimits.MaxQueryLength} characters)."); + return; + } + + guardFilters.Add(new SearchGuardFilter(role, direction, value)); + } + + private void AddDependencySymbolFilter(string optionName, string value, List target) + { + var trimmed = value.Trim(); + if (trimmed.Length == 0) + { + AddParseError($"Error: {optionName} value cannot be empty."); + return; + } + if (trimmed.Length > QueryLimits.MaxQueryLength) + { + AddParseError($"Error: {optionName} value too long (max {QueryLimits.MaxQueryLength} characters)."); + return; + } + if (target.Contains(trimmed, StringComparer.Ordinal)) + return; + if (dependencySymbols.Count + dependencySymbolFamilies.Count >= MaxDependencySymbolFilterCount) + { + if (!dependencySymbolFilterCountExceeded) + { + AddParseError($"Error: deps accepts at most {MaxDependencySymbolFilterCount} combined --symbol and --symbol-family values. / deps では --symbol と --symbol-family を合計 {MaxDependencySymbolFilterCount} 件まで指定できます。"); + dependencySymbolFilterCountExceeded = true; + } + return; + } + + target.Add(trimmed); + } + + private void AddIssueDraftLabels(string rawLabels) + { + if (string.IsNullOrWhiteSpace(rawLabels)) + { + AddParseError("Error: --issue-label value cannot be empty."); + return; + } + + foreach (var label in rawLabels.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (issueLabels.Count >= MaxIssueDraftLabelCount) + { + AddParseError($"Error: search issue drafts accept at most {MaxIssueDraftLabelCount} labels."); + return; + } + if (label.Length > IssueDuplicatePreflight.MaxOpenIssueLabelLength) + { + AddParseError($"Error: --issue-label value too long (max {IssueDuplicatePreflight.MaxOpenIssueLabelLength} characters)."); + return; + } + if (!issueLabels.Contains(label, StringComparer.OrdinalIgnoreCase)) + issueLabels.Add(label); + } + } + + private void AddRecipeQuerySelectors(string optionName, string rawSelectors, List selectors) + { + if (string.IsNullOrWhiteSpace(rawSelectors)) + { + AddParseError($"Error: {optionName} value cannot be empty."); + return; + } + + foreach (var selector in rawSelectors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (selectors.Count >= MaxSearchRecipeQuerySelectorCount) + { + AddParseError($"Error: search recipes accept at most {MaxSearchRecipeQuerySelectorCount} {optionName} values."); + return; + } + if (selector.Length > MaxSearchRecipeQuerySelectorLength) + { + AddParseError($"Error: {optionName} value too long (max {MaxSearchRecipeQuerySelectorLength} characters)."); + return; + } + if (!selectors.Contains(selector, StringComparer.OrdinalIgnoreCase)) + selectors.Add(selector); + } + } + + private void AddStatusCheckScopes(string rawScopes) + { + if (string.IsNullOrWhiteSpace(rawScopes)) + { + AddParseError("Error: --check scope list cannot be empty. Use --check or --check=workspace,fold,graph,issues,hotspot,csharp,sql,newer."); + return; + } + if (!ValidateCsvBounds("--check", rawScopes, MaxStatusCheckScopesCsvLength, MaxStatusCheckScopesCsvEntries, AddParseError)) + return; + + statusCheckScopes ??= new HashSet(StringComparer.OrdinalIgnoreCase); + var invalidScope = false; + foreach (var rawScope in rawScopes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var scope = rawScope.ToLowerInvariant(); + switch (scope) + { + case "workspace": + case "fold": + case "graph": + case "issues": + case "hotspot": + case "csharp": + case "sql": + case "newer": + statusCheckScopes.Add(scope); + break; + default: + invalidScope = true; + AddParseError($"Error: unsupported --check scope '{ConsoleUi.FormatBoundedValue(rawScope)}'. Use one or more of workspace, fold, graph, issues, hotspot, csharp, sql, newer."); + break; + } + } + + if (statusCheckScopes.Count == 0 && !invalidScope) + AddParseError("Error: --check scope list cannot be empty. Use --check or --check=workspace,fold,graph,issues,hotspot,csharp,sql,newer."); + } + // Track non-repeatable value-taking options that have already been observed and warn on + // subsequent occurrences. Previously `--db /A --db /B` silently used `/B`; this makes the + // override explicit so users (and AI callers) can spot a copy/paste or scripted mistake. + // 非 repeatable な value-taking オプションの初出を記録し、2 回目以降で警告する。以前は + // `--db /A --db /B` が silent に `/B` を採用していたため、スクリプトやコピペのミスに + // ユーザーや AI 呼び出し側が気付けるよう、上書きを明示化する。 + private void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) + { + if (seenSingleValueOptions.Add(canonicalName)) + return; + var displayValue = ConsoleUi.FormatBoundedValue(newValue); + CommandErrorWriter.WriteStderr($"Warning: {canonicalName} specified more than once; the rightmost CLI value '{displayValue}' takes precedence over earlier CLI values and any environment/config default."); + } + + private void ParseRawArguments(string[] args) + { + for (int i = 0; i < args.Length; i++) + { + var currentArg = args[i]; + if (allowStatusCheck && currentArg.StartsWith("--check=", StringComparison.Ordinal)) + { + checkWorkspace = true; + statusCheckExplicit = true; + AddStatusCheckScopes(currentArg["--check=".Length..]); + continue; + } + + var inlineValue = TrySplitInlineOptionValue(currentArg, out var inlineOptionName) + ? currentArg[(inlineOptionName!.Length + 1)..] + : null; + var normalizedArg = inlineOptionName ?? currentArg; + + if (TryParseGeneralOption(normalizedArg, currentArg, inlineValue, args, ref i) + || TryParseSearchOption(normalizedArg, currentArg, inlineValue, args, ref i) + || TryParseFilterOption(normalizedArg, currentArg, inlineValue, args, ref i) + || TryParseResultOption(normalizedArg, currentArg, inlineValue, args, ref i) + || TryParseStatusOption(normalizedArg, currentArg, inlineValue, args, ref i) + || TryParseLocationOption(normalizedArg, currentArg, inlineValue, args, ref i)) + { + continue; + } + + ParsePositionalArgument(currentArg); + } + } + + private void ParsePositionalArgument(string argument) + { + if (argument.StartsWith('-')) + { + AddParseError($"Error: unsupported option: {ConsoleUi.FormatBoundedValue(argument)}. Use `--` before a query literal that starts with `-`."); + } + else if (query == null && positionalGlobAsPath && DbReader.PathLikePatternHasWildcard(argument)) + { + pathPatterns.Add(argument); + userPathPatterns.Add(argument); + } + else if (query == null) + { + query = argument; + } + else + { + // Extra positional args become additional symbol names / 追加の positional 引数を追加の symbol name として扱う + extraNames.Add(argument); + } + } + } +} From bf6dcada06914a913fd885d725b5a08c91bb33c9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 18:56:33 +0900 Subject: [PATCH 058/101] Split program runner responsibilities --- .../Cli/ProgramRunner.DisplayFlags.cs | 335 ++ src/CodeIndex/Cli/ProgramRunner.Doctor.cs | 612 +++ .../Cli/ProgramRunner.ErrorHandling.cs | 184 + src/CodeIndex/Cli/ProgramRunner.Lsp.cs | 113 + src/CodeIndex/Cli/ProgramRunner.Mcp.cs | 844 +++ src/CodeIndex/Cli/ProgramRunner.Metrics.cs | 487 ++ .../Cli/ProgramRunner.QueryArguments.cs | 608 +++ .../Cli/ProgramRunner.TestExtractor.cs | 238 + src/CodeIndex/Cli/ProgramRunner.Upgrade.cs | 387 ++ .../Cli/ProgramRunner.UpgradeDownloads.cs | 259 + .../ProgramRunner.UpgradeInstallDirectory.cs | 198 + .../Cli/ProgramRunner.UpgradeOptions.cs | 177 + .../Cli/ProgramRunner.UpgradeProcess.cs | 295 + .../Cli/ProgramRunner.UpgradeTrust.cs | 129 + src/CodeIndex/Cli/ProgramRunner.Version.cs | 174 + src/CodeIndex/Cli/ProgramRunner.VersionPin.cs | 199 + src/CodeIndex/Cli/ProgramRunner.cs | 4859 +---------------- 17 files changed, 5249 insertions(+), 4849 deletions(-) create mode 100644 src/CodeIndex/Cli/ProgramRunner.DisplayFlags.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.Doctor.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.ErrorHandling.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.Lsp.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.Mcp.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.Metrics.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.TestExtractor.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.Upgrade.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.UpgradeDownloads.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.UpgradeInstallDirectory.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.UpgradeOptions.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.UpgradeProcess.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.UpgradeTrust.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.Version.cs create mode 100644 src/CodeIndex/Cli/ProgramRunner.VersionPin.cs diff --git a/src/CodeIndex/Cli/ProgramRunner.DisplayFlags.cs b/src/CodeIndex/Cli/ProgramRunner.DisplayFlags.cs new file mode 100644 index 000000000..f70a6cc9d --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.DisplayFlags.cs @@ -0,0 +1,335 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + internal static bool TryConsumeColorFlag(ref string[] args, out string error) + { + error = string.Empty; + ConsoleUi.SetColorMode(ColorMode.Auto); + if (args.Length == 0) + return true; + + var kept = new List(args.Length); + ColorMode? requested = null; + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + + // After a `--` token, leave everything alone so subcommands keep + // their query-escape semantics (e.g. `cdidx search -- --color=auto`). + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (ShouldPreserveQueryCommandToken(args, i)) + { + kept.Add(arg); + continue; + } + + string? rawValue = null; + if (arg == "--color") + { + if (i + 1 >= args.Length) + { + error = "Error: --color requires a value (one of `auto`, `always`, `never`)."; + return false; + } + rawValue = args[++i]; + } + else if (arg.StartsWith("--color=", StringComparison.Ordinal)) + { + rawValue = arg.Substring("--color=".Length); + } + else + { + kept.Add(arg); + continue; + } + + if (!ConsoleUi.TryParseColorMode(rawValue, out var mode)) + { + error = $"Error: invalid --color value `{rawValue}`."; + return false; + } + requested = mode; + } + + if (requested.HasValue) + ConsoleUi.SetColorMode(requested.Value); + args = kept.ToArray(); + return true; + } + + internal static void TryConsumeAsciiFlag(ref string[] args) + { + ConsoleUi.SetAsciiOutput(false); + if (args.Length == 0) + return; + + var kept = new List(args.Length); + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (ShouldPreserveQueryCommandToken(args, i)) + { + kept.Add(arg); + continue; + } + if (arg == "--ascii") + { + ConsoleUi.SetAsciiOutput(true); + continue; + } + + kept.Add(arg); + } + + args = kept.ToArray(); + } + + internal static void TryConsumeNoProgressFlag(ref string[] args) + { + ConsoleUi.SetProgressAnimationEnabled(null); + if (args.Length == 0) + return; + + var kept = new List(args.Length); + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (ShouldPreserveQueryCommandToken(args, i)) + { + kept.Add(arg); + continue; + } + if (arg == "--no-progress") + { + ConsoleUi.SetProgressAnimationEnabled(false); + continue; + } + + kept.Add(arg); + } + + args = kept.ToArray(); + } + + // Strip `--palette ` / `--palette=` from `args` before + // subcommand parsing. Mirrors `TryConsumeColorFlag` so any subcommand + // (CLI or MCP) inherits the chosen ANSI palette without re-parsing. + // Anything after `--` is passed through verbatim so subcommand + // query-escape semantics are preserved (#1569). + internal static bool TryConsumePaletteFlag(ref string[] args, out string error) + { + error = string.Empty; + ConsoleUi.SetColorPalette(null); + if (args.Length == 0) + return true; + + var kept = new List(args.Length); + ColorPalette? requested = null; + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (ShouldPreserveQueryCommandToken(args, i)) + { + kept.Add(arg); + continue; + } + + string? rawValue = null; + if (arg == "--palette") + { + if (i + 1 >= args.Length) + { + error = "Error: --palette requires a value (one of `basic`, `256`, `truecolor`)."; + return false; + } + rawValue = args[++i]; + } + else if (arg.StartsWith("--palette=", StringComparison.Ordinal)) + { + rawValue = arg.Substring("--palette=".Length); + } + else + { + kept.Add(arg); + continue; + } + + if (!ConsoleUi.TryParseColorPalette(rawValue, out var palette)) + { + error = $"Error: invalid --palette value `{rawValue}`."; + return false; + } + requested = palette; + } + + if (requested.HasValue) + ConsoleUi.SetColorPalette(requested.Value); + args = kept.ToArray(); + return true; + } + + // Strip the `--debug-unsafe` opt-in from `args` before subcommand parsing. + // The flag must be passed every command invocation (not via env var) so a stale + // CDIDX_DEBUG=unsafe in a shell profile or CI env cannot quietly leak indexed + // source content (#1530). Anything after `--` is left untouched so subcommand + // query strings keep their literal semantics. + // サブコマンド処理前に `--debug-unsafe` を取り除く。環境変数 CDIDX_DEBUG=unsafe が + // シェルプロファイル / CI に残った状態で索引済みソースが漏れないよう、明示的にフラグを + // 毎回渡す運用にする(#1530)。`--` 以降はサブコマンドのクエリ文字列を保つため触らない。 + internal static bool TryConsumeDebugUnsafeFlag(ref string[] args) + { + if (args.Length == 0) + return false; + + var kept = new List(args.Length); + var passthrough = false; + var seen = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (ShouldPreserveQueryCommandToken(args, i)) + { + kept.Add(arg); + continue; + } + if (arg == "--debug-unsafe") + { + seen = true; + continue; + } + kept.Add(arg); + } + + if (seen) + { + DbDebug.EnableUnsafeForProcess(); + args = kept.ToArray(); + } + return seen; + } + + internal static bool TryConsumeStrictVersionFlag(ref string[] args, out bool strictVersion, out string error) + { + strictVersion = IsTruthyEnvironmentVariable("CDIDX_STRICT_VERSION"); + error = string.Empty; + if (args.Length == 0) + return true; + + var kept = new List(args.Length); + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (ShouldPreserveQueryCommandToken(args, i)) + { + kept.Add(arg); + continue; + } + if (arg == "--strict-version") + { + strictVersion = true; + continue; + } + if (arg.StartsWith("--strict-version=", StringComparison.Ordinal)) + { + error = "Error: --strict-version does not accept a value."; + return false; + } + kept.Add(arg); + } + + args = kept.ToArray(); + return true; + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.Doctor.cs b/src/CodeIndex/Cli/ProgramRunner.Doctor.cs new file mode 100644 index 000000000..711e97865 --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.Doctor.cs @@ -0,0 +1,612 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + private static int RunDoctor(string[] args, string appVersion, JsonSerializerOptions jsonOptions) + { + var wantsJson = args.Any(static arg => arg == "--json" || arg.StartsWith("--json=", StringComparison.Ordinal)); + var json = false; + bool? redactPaths = null; + var envInventory = DoctorEnvironmentInventoryMode.None; + string? envDomain = null; + string? envCategory = null; + string? envSensitivity = null; + int? maxJsonBytes = null; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "--env-domain" || arg.StartsWith("--env-domain=", StringComparison.Ordinal)) + { + _ = TryReadDoctorValueOption(args, ref i, arg, "--env-domain", wantsJson, jsonOptions, out envDomain, out var optionExitCode); + if (optionExitCode.HasValue) + return optionExitCode.Value; + continue; + } + if (arg == "--env-category" || arg.StartsWith("--env-category=", StringComparison.Ordinal)) + { + _ = TryReadDoctorValueOption(args, ref i, arg, "--env-category", wantsJson, jsonOptions, out envCategory, out var optionExitCode); + if (optionExitCode.HasValue) + return optionExitCode.Value; + continue; + } + if (arg == "--env-sensitivity" || arg.StartsWith("--env-sensitivity=", StringComparison.Ordinal)) + { + _ = TryReadDoctorValueOption(args, ref i, arg, "--env-sensitivity", wantsJson, jsonOptions, out envSensitivity, out var optionExitCode); + if (optionExitCode.HasValue) + return optionExitCode.Value; + continue; + } + if (arg == "--max-json-bytes" || arg.StartsWith("--max-json-bytes=", StringComparison.Ordinal)) + { + if (!TryConsumeInlineOrNext(args, ref i, arg, "--max-json-bytes", out var value) + || !int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) + || parsed <= 0) + { + return CommandErrorWriter.WriteJsonOrHuman( + wantsJson, + jsonOptions, + "--max-json-bytes requires a positive integer.", + CommandExitCodes.InvalidArgument, + "pass a positive UTF-8 byte limit, for example `--max-json-bytes 16384`.", + usage: GetDoctorUsage()); + } + maxJsonBytes = parsed; + continue; + } + + switch (arg) + { + case "--json": + json = true; + break; + case "--redact-paths": + redactPaths = true; + break; + case "--show-paths": + redactPaths = false; + break; + case "--env-inventory": + case "--env-inventory=compact": + envInventory = DoctorEnvironmentInventoryMode.Compact; + break; + case "--env-inventory=full": + envInventory = DoctorEnvironmentInventoryMode.Full; + break; + default: + return CommandErrorWriter.WriteJsonOrHuman( + wantsJson, + jsonOptions, + arg.StartsWith("--json=", StringComparison.Ordinal) + ? "doctor supports --json only; --json= is not supported." + : $"Unknown doctor argument: {arg}", + CommandExitCodes.InvalidArgument, + $"use `{GetDoctorUsage()}`."); + } + } + + var filtersRequested = envDomain is not null || envCategory is not null || envSensitivity is not null; + if (filtersRequested && envInventory != DoctorEnvironmentInventoryMode.Full) + { + return CommandErrorWriter.WriteJsonOrHuman( + wantsJson, + jsonOptions, + "doctor environment inventory filters require --env-inventory=full.", + CommandExitCodes.InvalidArgument, + "add `--env-inventory=full` before filtering by domain, category, or sensitivity.", + usage: GetDoctorUsage()); + } + if (maxJsonBytes.HasValue && (!json || envInventory != DoctorEnvironmentInventoryMode.Full)) + { + return CommandErrorWriter.WriteJsonOrHuman( + wantsJson, + jsonOptions, + "doctor --max-json-bytes requires --json and --env-inventory=full.", + CommandExitCodes.InvalidArgument, + "use `cdidx doctor --json --env-inventory=full --max-json-bytes `.", + usage: GetDoctorUsage()); + } + + if (!TryFilterDoctorEnvironmentInventory( + envDomain, + envCategory, + envSensitivity, + wantsJson, + jsonOptions, + out var filteredInventory, + out var filterExitCode)) + { + return filterExitCode; + } + + if (json) + { + return WriteDoctorJson( + appVersion, + jsonOptions, + redactPaths ?? true, + envInventory == DoctorEnvironmentInventoryMode.Full, + filteredInventory, + maxJsonBytes); + } + + if (envInventory == DoctorEnvironmentInventoryMode.Full) + { + WriteEnvironmentInventory(filteredInventory); + return CommandExitCodes.Success; + } + + if (envInventory == DoctorEnvironmentInventoryMode.Compact) + { + WriteEnvironmentInventorySummary(); + return CommandExitCodes.Success; + } + + var dbResolution = DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null); + Console.WriteLine("cdidx doctor"); + Console.WriteLine(ConsoleUi.FormatSummaryLine("version", appVersion)); + Console.WriteLine(ConsoleUi.FormatSummaryLine("commit", ConsoleUi.LoadBuildMetadata().Commit)); + Console.WriteLine(ConsoleUi.FormatSummaryLine("rid", RuntimeInformation.RuntimeIdentifier)); + Console.WriteLine(ConsoleUi.FormatSummaryLine("os", RuntimeInformation.OSDescription)); + Console.WriteLine(ConsoleUi.FormatSummaryLine("kernel", Environment.OSVersion.VersionString)); + Console.WriteLine(ConsoleUi.FormatSummaryLine("dotnet", RuntimeInformation.FrameworkDescription)); + Console.WriteLine(ConsoleUi.FormatSummaryLine("process", Environment.ProcessPath ?? "")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("base_dir", AppContext.BaseDirectory)); + Console.WriteLine(ConsoleUi.FormatSummaryLine("cwd", Environment.CurrentDirectory)); + Console.WriteLine(); + Console.WriteLine("terminal:"); + Console.WriteLine(ConsoleUi.FormatSummaryLine("stdout_tty", !Console.IsOutputRedirected, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("stderr_tty", !Console.IsErrorRedirected, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("columns", FormatDoctorEnvironmentValue(CdidxEnvironment.GetProcessEnvironmentVariable("COLUMNS")), indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("no_color", FormatDoctorEnvironmentValue(CdidxEnvironment.GetProcessEnvironmentVariable("NO_COLOR")), indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("term", FormatDoctorEnvironmentValue(CdidxEnvironment.GetProcessEnvironmentVariable("TERM")), indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("locale", CultureInfo.CurrentCulture.Name, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("ui_locale", CultureInfo.CurrentUICulture.Name, indent: " ")); + Console.WriteLine(); + var display = BuildDoctorDisplayJson(); + Console.WriteLine("display:"); + Console.WriteLine(ConsoleUi.FormatSummaryLine("color", display.Color.Enabled, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("color_source", display.Color.Source, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("terminal_hint", display.TerminalHint.HasHint, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("progress", display.Progress.Enabled, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("progress_source", display.Progress.Source, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("max_line_width", display.MaxLineWidth.Value, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("max_line_width_source", display.MaxLineWidth.Source, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("ambiguous_width", display.AmbiguousWidth.Wide, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("ambiguous_locale", display.AmbiguousWidth.Locale, indent: " ")); + Console.WriteLine(); + Console.WriteLine("paths:"); + Console.WriteLine(ConsoleUi.FormatSummaryLine("db", dbResolution.DbPath, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("data_dir", dbResolution.DataDir ?? "", indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("data_source", dbResolution.DataDirSource ?? "explicit-db", indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("log_dir", GlobalToolLog.ResolveLogDirectoryForStatus(), indent: " ")); + Console.WriteLine(); + Console.WriteLine("config:"); + Console.WriteLine(ConsoleUi.FormatSummaryLine(CdidxConfigFile.FileName, File.Exists(Path.Combine(Environment.CurrentDirectory, CdidxConfigFile.FileName)) ? "present" : "not found", indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine(CdidxConfigFile.DisableEnvVar, FormatDoctorEnvironmentValue(CdidxEnvironment.GetProcessEnvironmentVariable(CdidxConfigFile.DisableEnvVar)), indent: " ")); + Console.WriteLine(); + Console.WriteLine("github:"); + Console.WriteLine(ConsoleUi.FormatSummaryLine("proxy_default_credentials", GitHubHttpClientFactory.FormatProxyDefaultCredentialsStatus(), indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("max_request_timeout_s", GitHubHttpClientFactory.MaxRequestTimeout.TotalSeconds.ToString("0", CultureInfo.InvariantCulture), indent: " ")); + Console.WriteLine(); + Console.WriteLine("cdidx_env:"); + foreach (var (key, value) in EnumerateCdidxEnvironment()) + Console.WriteLine(ConsoleUi.FormatSummaryLine(key, value, indent: " ")); + return CommandExitCodes.Success; + } + + private enum DoctorEnvironmentInventoryMode + { + None, + Compact, + Full, + } + + private static string GetDoctorUsage() + => "cdidx doctor [--json] [--redact-paths|--show-paths] [--env-inventory[=compact|full]] [--env-domain ] [--env-category ] [--env-sensitivity ] [--max-json-bytes ]"; + + private static bool TryReadDoctorValueOption( + string[] args, + ref int index, + string arg, + string flag, + bool wantsJson, + JsonSerializerOptions jsonOptions, + out string? value, + out int? exitCode) + { + value = null; + exitCode = null; + if (arg != flag && !arg.StartsWith(flag + "=", StringComparison.Ordinal)) + return false; + + if (!TryConsumeInlineOrNext(args, ref index, arg, flag, out var parsed) + || string.IsNullOrWhiteSpace(parsed)) + { + exitCode = CommandErrorWriter.WriteJsonOrHuman( + wantsJson, + jsonOptions, + $"{flag} requires a non-empty value.", + CommandExitCodes.InvalidArgument, + $"pass one value reported by `cdidx doctor --env-inventory` for {flag}.", + usage: GetDoctorUsage()); + return true; + } + + value = parsed; + return true; + } + + private static bool TryFilterDoctorEnvironmentInventory( + string? domain, + string? category, + string? sensitivity, + bool wantsJson, + JsonSerializerOptions jsonOptions, + out IReadOnlyList filtered, + out int exitCode) + { + filtered = []; + exitCode = CommandExitCodes.Success; + foreach (var (flag, value, selector) in new (string Flag, string? Value, Func Selector)[] + { + ("--env-domain", domain, static item => item.Domain), + ("--env-category", category, static item => item.Category), + ("--env-sensitivity", sensitivity, static item => item.Sensitivity), + }) + { + if (value is null) + continue; + if (EnvironmentVariableInventory.Items.Any(item => string.Equals(selector(item), value, StringComparison.OrdinalIgnoreCase))) + continue; + + var allowed = string.Join( + ", ", + EnvironmentVariableInventory.Items + .Select(selector) + .Distinct(StringComparer.Ordinal) + .OrderBy(static candidate => candidate, StringComparer.Ordinal)); + exitCode = CommandErrorWriter.WriteJsonOrHuman( + wantsJson, + jsonOptions, + $"Unknown {flag} value: {value}", + CommandExitCodes.InvalidArgument, + $"choose one of: {allowed}.", + usage: GetDoctorUsage()); + return false; + } + + filtered = EnvironmentVariableInventory.Items + .Where(item => domain is null || string.Equals(item.Domain, domain, StringComparison.OrdinalIgnoreCase)) + .Where(item => category is null || string.Equals(item.Category, category, StringComparison.OrdinalIgnoreCase)) + .Where(item => sensitivity is null || string.Equals(item.Sensitivity, sensitivity, StringComparison.OrdinalIgnoreCase)) + .OrderBy(static item => item.Name, StringComparer.Ordinal) + .ToArray(); + return true; + } + + private static int WriteDoctorJson( + string appVersion, + JsonSerializerOptions jsonOptions, + bool redactPaths, + bool includeFullEnvironmentInventory, + IReadOnlyList environmentInventory, + int? maxJsonBytes) + { + var dbResolution = DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null); + var build = ConsoleUi.LoadBuildMetadata(); + var payload = new DoctorJsonResult( + ApiVersion: "1", + Version: appVersion, + Commit: build.Commit, + Rid: RuntimeInformation.RuntimeIdentifier, + Os: RuntimeInformation.OSDescription, + Kernel: Environment.OSVersion.VersionString, + Dotnet: RuntimeInformation.FrameworkDescription, + Process: RedactDoctorPath(Environment.ProcessPath ?? "", redactPaths), + BaseDir: RedactDoctorPath(AppContext.BaseDirectory, redactPaths), + Cwd: RedactDoctorPath(Environment.CurrentDirectory, redactPaths), + Terminal: new DoctorTerminalJsonResult( + StdoutTty: !Console.IsOutputRedirected, + StderrTty: !Console.IsErrorRedirected, + Columns: FormatDoctorJsonEnvironmentValue("COLUMNS", redactPaths), + NoColor: FormatDoctorJsonEnvironmentValue("NO_COLOR", redactPaths), + Term: FormatDoctorJsonEnvironmentValue("TERM", redactPaths), + Locale: CultureInfo.CurrentCulture.Name, + UiLocale: CultureInfo.CurrentUICulture.Name), + Display: BuildDoctorDisplayJson(), + Paths: new DoctorPathsJsonResult( + Db: RedactDoctorPath(dbResolution.DbPath, redactPaths), + DataDir: RedactDoctorPath(dbResolution.DataDir ?? "", redactPaths), + DataSource: dbResolution.DataDirSource ?? "explicit-db", + LogDir: RedactDoctorPath(GlobalToolLog.ResolveLogDirectoryForStatus(), redactPaths)), + Config: new DoctorConfigJsonResult( + DotCdidxrcJson: File.Exists(Path.Combine(Environment.CurrentDirectory, CdidxConfigFile.FileName)) ? "present" : "not_found", + DisableConfigFile: FormatDoctorJsonEnvironmentValue(CdidxConfigFile.DisableEnvVar, redactPaths)), + CdidxEnv: EnumerateCdidxEnvironmentJson(redactPaths).ToArray(), + EnvironmentInventorySummary: includeFullEnvironmentInventory + ? EnvironmentVariableInventory.BuildSummary(environmentInventory) + : EnvironmentVariableInventory.BuildSummary(), + EnvironmentInventory: includeFullEnvironmentInventory ? environmentInventory : null, + Redaction: new DoctorRedactionJsonResult( + PathsRedacted: redactPaths, + SecretsRedacted: true)); + + var json = JsonSerializer.Serialize(payload, CliJsonSerializerContextFactory.Create(jsonOptions).DoctorJsonResult); + var byteCount = Encoding.UTF8.GetByteCount(json) + Encoding.UTF8.GetByteCount(Environment.NewLine); + if (maxJsonBytes.HasValue && byteCount > maxJsonBytes.Value) + { + return CommandErrorWriter.WriteJsonOrHuman( + true, + jsonOptions, + $"doctor JSON output is {byteCount.ToString(CultureInfo.InvariantCulture)} bytes and exceeds --max-json-bytes {maxJsonBytes.Value.ToString(CultureInfo.InvariantCulture)}.", + CommandExitCodes.UsageError, + "increase --max-json-bytes or narrow the full environment inventory with --env-domain, --env-category, or --env-sensitivity.", + usage: GetDoctorUsage()); + } + + Console.WriteLine(json); + return CommandExitCodes.Success; + } + + private static DoctorDisplayJsonResult BuildDoctorDisplayJson() + { + var maxLineWidth = EnvironmentOptionParser.ReadInt32( + QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable, + LineWidthFormatter.DefaultMaxLineWidth, + minimum: 0, + maximum: LineWidthFormatter.MaxAllowedLineWidth); + + return new DoctorDisplayJsonResult( + Color: BuildDoctorColorDecision(), + Progress: BuildDoctorProgressDecision(), + TerminalHint: BuildDoctorTerminalHint(), + MaxLineWidth: new DoctorDisplayMaxLineWidthJsonResult( + maxLineWidth.Value, + maxLineWidth.SourceKind, + maxLineWidth.Source, + maxLineWidth.Status, + maxLineWidth.UsedFallback, + maxLineWidth.Fallback, + maxLineWidth.Minimum, + maxLineWidth.Maximum, + maxLineWidth.Name, + maxLineWidth.RawValue is null ? "" : ConsoleUi.FormatBoundedValue(maxLineWidth.RawValue)), + AmbiguousWidth: BuildDoctorAmbiguousWidthDecision(), + Truncation: new DoctorDisplayTruncationJsonResult( + LineWidthFormatter.DefaultMaxLineWidth, + LineWidthFormatter.MaxAllowedLineWidth, + ConsoleUi.DefaultDiagnosticValueCharLimit, + "... ")); + } + + private static DoctorDisplayDecisionJsonResult BuildDoctorColorDecision() + { + var enabled = ConsoleUi.ShouldUseColor(); + return ConsoleUi.GetColorModeForDiagnostics() switch + { + ColorMode.Always => new DoctorDisplayDecisionJsonResult(enabled, "flag", "--color=always"), + ColorMode.Never => new DoctorDisplayDecisionJsonResult(enabled, "flag", "--color=never"), + _ when IsDoctorForceColorRequested() => new DoctorDisplayDecisionJsonResult(enabled, "CLICOLOR_FORCE", "forced"), + _ when !string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("NO_COLOR")) => new DoctorDisplayDecisionJsonResult(enabled, "NO_COLOR", "disabled"), + _ when CdidxEnvironment.GetEnvironmentVariable("CLICOLOR") == "0" => new DoctorDisplayDecisionJsonResult(enabled, "CLICOLOR", "disabled"), + _ => new DoctorDisplayDecisionJsonResult(enabled, "terminal", enabled ? "ansi_available" : "not_interactive") + }; + } + + private static DoctorDisplayDecisionJsonResult BuildDoctorProgressDecision() + { + var enabled = ConsoleUi.ShouldUseProgressAnimation(); + var progressOverride = ConsoleUi.GetProgressAnimationOverrideForDiagnostics(); + if (progressOverride.HasValue) + return new DoctorDisplayDecisionJsonResult(enabled, "flag", progressOverride.Value ? "enabled_override" : "--no-progress"); + if (IsTruthyDoctorEnvironmentValue(CdidxEnvironment.GetEnvironmentVariable(ConsoleUi.DisableProgressEnvironmentVariable))) + return new DoctorDisplayDecisionJsonResult(enabled, ConsoleUi.DisableProgressEnvironmentVariable, "disabled"); + if (IsTruthyDoctorEnvironmentValue(CdidxEnvironment.GetEnvironmentVariable(ConsoleUi.PrefersReducedMotionEnvironmentVariable))) + return new DoctorDisplayDecisionJsonResult(enabled, ConsoleUi.PrefersReducedMotionEnvironmentVariable, "reduced_motion"); + return new DoctorDisplayDecisionJsonResult(enabled, "default", "enabled"); + } + + private static DoctorDisplayTerminalHintJsonResult BuildDoctorTerminalHint() + { + var wtSession = FormatDoctorJsonEnvironmentValue("WT_SESSION", redactPaths: false); + var wtProfile = FormatDoctorJsonEnvironmentValue("WT_PROFILE_ID", redactPaths: false); + return new DoctorDisplayTerminalHintJsonResult( + HasDoctorTerminalEnvironmentHint(), + IsDoctorTerminalEnvironmentDisabled(), + Console.IsOutputRedirected, + Console.Out is StringWriter, + FormatDoctorJsonEnvironmentValue("TERM", redactPaths: false), + FormatDoctorJsonEnvironmentValue("TERM_PROGRAM", redactPaths: false), + FormatDoctorJsonEnvironmentValue("CI", redactPaths: false), + wtSession != "" ? wtSession : wtProfile); + } + + private static DoctorDisplayAmbiguousWidthJsonResult BuildDoctorAmbiguousWidthDecision() + { + var locale = CdidxEnvironment.GetEnvironmentVariable("LC_ALL"); + var source = "LC_ALL"; + if (string.IsNullOrEmpty(locale)) + { + locale = CdidxEnvironment.GetEnvironmentVariable("LC_CTYPE"); + source = "LC_CTYPE"; + } + if (string.IsNullOrEmpty(locale)) + { + locale = CdidxEnvironment.GetEnvironmentVariable("LANG"); + source = "LANG"; + } + if (string.IsNullOrEmpty(locale)) + { + locale = ""; + source = "default"; + } + + var wide = locale.StartsWith("ja", StringComparison.OrdinalIgnoreCase) + || locale.StartsWith("zh", StringComparison.OrdinalIgnoreCase) + || locale.StartsWith("ko", StringComparison.OrdinalIgnoreCase); + return new DoctorDisplayAmbiguousWidthJsonResult(wide, source, ConsoleUi.FormatBoundedValue(locale)); + } + + private static bool HasDoctorTerminalEnvironmentHint() + { + if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("WT_SESSION"))) + return true; + if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("WT_PROFILE_ID"))) + return true; + if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("TERM_PROGRAM"))) + return true; + + var term = CdidxEnvironment.GetEnvironmentVariable("TERM"); + return !string.IsNullOrWhiteSpace(term) + && !term.Equals("dumb", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsDoctorTerminalEnvironmentDisabled() + => string.Equals(CdidxEnvironment.GetEnvironmentVariable("TERM"), "dumb", StringComparison.OrdinalIgnoreCase) + || IsDoctorCiEnvironment(); + + private static bool IsDoctorCiEnvironment() + { + var ci = CdidxEnvironment.GetEnvironmentVariable("CI"); + return !string.IsNullOrEmpty(ci) + && !ci.Equals("0", StringComparison.OrdinalIgnoreCase) + && !ci.Equals("false", StringComparison.OrdinalIgnoreCase) + && !ci.Equals("no", StringComparison.OrdinalIgnoreCase) + && !ci.Equals("off", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsDoctorForceColorRequested() + { + var force = CdidxEnvironment.GetEnvironmentVariable("CLICOLOR_FORCE"); + return !string.IsNullOrEmpty(force) && force != "0"; + } + + private static bool IsTruthyDoctorEnvironmentValue(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + return value.Trim() is not ("0" or "false" or "False" or "FALSE" or "no" or "No" or "NO"); + } + + private static void WriteEnvironmentInventory(IReadOnlyList items) + { + Console.WriteLine("environment_inventory:"); + foreach (var item in items) + { + var firstLocation = item.Locations.FirstOrDefault(); + var location = firstLocation is null + ? "" + : $"{firstLocation.Path}:{firstLocation.Line}"; + Console.WriteLine($" {item.Name}"); + Console.WriteLine(ConsoleUi.FormatSummaryLine("domain", item.Domain, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("category", item.Category, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("sensitivity", item.Sensitivity, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("policy", item.Policy, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("default", item.DefaultBehavior, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("config", item.ConfigFileSupported, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("invalid", item.InvalidValueBehavior, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("location", location, indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("description", item.Description, indent: " ")); + } + } + + private static void WriteEnvironmentInventorySummary() + { + var summary = EnvironmentVariableInventory.BuildSummary(); + Console.WriteLine("environment_inventory_summary:"); + Console.WriteLine(ConsoleUi.FormatSummaryLine("total", summary.Total, indent: " ")); + WriteEnvironmentInventorySummaryBuckets("by_domain", summary.ByDomain); + WriteEnvironmentInventorySummaryBuckets("by_sensitivity", summary.BySensitivity); + WriteEnvironmentInventorySummaryBuckets("by_category", summary.ByCategory); + Console.WriteLine(ConsoleUi.FormatSummaryLine("full_detail", "cdidx doctor --env-inventory=full", indent: " ")); + } + + private static void WriteEnvironmentInventorySummaryBuckets( + string title, + IReadOnlyList buckets) + { + Console.WriteLine($" {title}:"); + foreach (var bucket in buckets) + Console.WriteLine(ConsoleUi.FormatSummaryLine(bucket.Name, bucket.Count, indent: " ")); + } + + private static IEnumerable EnumerateCdidxEnvironmentJson(bool redactPaths) + { + var rows = CdidxEnvironment.EnumerateProcessEnvironmentVariables() + .Where(e => e.Key.StartsWith("CDIDX_", StringComparison.Ordinal)) + .OrderBy(e => e.Key, StringComparer.Ordinal); + foreach (var row in rows) + { + var sensitive = IsSensitiveEnvironmentName(row.Key); + var value = sensitive + ? "" + : string.IsNullOrEmpty(row.Value) + ? "" + : RedactDoctorPath(row.Value, redactPaths); + var bounded = ConsoleUi.BoundDisplayText(value); + yield return new DoctorEnvironmentVariableJsonResult(row.Key, bounded.Text, sensitive, bounded.Truncated, bounded.OriginalLength); + } + } + + private static string FormatDoctorJsonEnvironmentValue(string name, bool redactPaths) + { + var value = CdidxEnvironment.GetProcessEnvironmentVariable(name); + return value == null ? "" : ConsoleUi.FormatBoundedValue(RedactDoctorPath(value, redactPaths)); + } + + private static string RedactDoctorPath(string value, bool redactPaths) + => redactPaths ? DiagnosticRedactor.RedactSensitiveText(value, "[redacted]", redactPaths: true) : value; + + private static IEnumerable<(string Key, string Value)> EnumerateCdidxEnvironment() + { + var rows = CdidxEnvironment.EnumerateProcessEnvironmentVariables() + .Where(e => e.Key.StartsWith("CDIDX_", StringComparison.Ordinal)) + .OrderBy(e => e.Key, StringComparer.Ordinal); + var any = false; + foreach (var row in rows) + { + any = true; + yield return (row.Key, IsSensitiveEnvironmentName(row.Key) ? "" : string.IsNullOrEmpty(row.Value) ? "" : ConsoleUi.FormatBoundedValue(row.Value)); + } + + if (!any) + yield return ("", ""); + } + + private static string FormatDoctorEnvironmentValue(string? value) + => value == null ? "" : ConsoleUi.FormatBoundedValue(value); + + private static bool IsSensitiveEnvironmentName(string name) => + name.Contains("TOKEN", StringComparison.OrdinalIgnoreCase) + || name.Contains("PASSWORD", StringComparison.OrdinalIgnoreCase) + || name.Contains("PASSWD", StringComparison.OrdinalIgnoreCase) + || name.Contains("PWD", StringComparison.OrdinalIgnoreCase) + || name.Contains("SECRET", StringComparison.OrdinalIgnoreCase) + || name.Contains("AUTH", StringComparison.OrdinalIgnoreCase) + || name.Contains("APIKEY", StringComparison.OrdinalIgnoreCase) + || name.Contains("API_KEY", StringComparison.OrdinalIgnoreCase) + || name.Contains("PRIVATE_KEY", StringComparison.OrdinalIgnoreCase) + || name.EndsWith("_KEY", StringComparison.OrdinalIgnoreCase) + || name.Contains("CREDENTIAL", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/CodeIndex/Cli/ProgramRunner.ErrorHandling.cs b/src/CodeIndex/Cli/ProgramRunner.ErrorHandling.cs new file mode 100644 index 000000000..39e6b87e3 --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.ErrorHandling.cs @@ -0,0 +1,184 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + private static bool IsTruthyEnvironmentVariable(string name) + { + var value = CdidxEnvironment.GetEnvironmentVariable(name); + return value != null + && !string.Equals(value, "0", StringComparison.OrdinalIgnoreCase) + && !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) + && !string.Equals(value, "no", StringComparison.OrdinalIgnoreCase) + && !string.Equals(value, "off", StringComparison.OrdinalIgnoreCase); + } + + internal static int MapCodeIndexExceptionExitCode(string code) => code switch + { + CommandErrorCodes.DbNotFound => CommandExitCodes.NotFound, + CommandErrorCodes.CheckpointNotFound => CommandExitCodes.NotFound, + CommandErrorCodes.DbLocked => CommandExitCodes.TransientDatabaseError, + CommandErrorCodes.DbNotWritable => CommandExitCodes.DatabaseError, + CommandErrorCodes.DbIntegrityFailed => CommandExitCodes.DatabaseError, + CommandErrorCodes.SchemaTooNew => CommandExitCodes.DatabaseError, + CommandErrorCodes.TempStoreExhausted => CommandExitCodes.DatabaseError, + CommandErrorCodes.DbError => CommandExitCodes.DatabaseError, + CommandErrorCodes.DirectoryNotFound => CommandExitCodes.NotFound, + CommandErrorCodes.FeatureUnavailable => CommandExitCodes.FeatureUnavailable, + CommandErrorCodes.UsageError => CommandExitCodes.InvalidArgument, + CommandErrorCodes.Interrupted => CommandExitCodes.CancelledBySignal, + _ => CommandExitCodes.DatabaseError, + }; + + internal static int MapUnhandledExceptionExitCode(Exception ex) + { + var sqliteException = FindSqliteException(ex); + if (sqliteException is null) + return CommandExitCodes.UnhandledException; + + return sqliteException.SqliteErrorCode switch + { + 5 or 6 or 8 => CommandExitCodes.TransientDatabaseError, + _ => CommandExitCodes.DatabaseError, + }; + } + + private static SqliteException? FindSqliteException(Exception ex) + { + if (ex is SqliteException sqliteException) + return sqliteException; + if (ex is AggregateException aggregate) + { + foreach (var inner in aggregate.InnerExceptions) + { + var found = FindSqliteException(inner); + if (found is not null) + return found; + } + } + + return ex.InnerException is null ? null : FindSqliteException(ex.InnerException); + } + + private sealed class QuietStderrScope : IDisposable + { + private readonly TextWriter _originalError; + private readonly TextWriter _replacementError; + private readonly IDisposable _ownership; + + private QuietStderrScope( + TextWriter originalError, + TextWriter replacementError, + IDisposable ownership) + { + _originalError = originalError; + _replacementError = replacementError; + _ownership = ownership; + } + + public static QuietStderrScope Start() + { + var ownership = ConsoleStreamOwnership.Enter(); + try + { + var originalError = Console.Error; + var replacementError = new ErrorOnlyTextWriter(originalError); + Console.SetError(replacementError); + return new QuietStderrScope(originalError, replacementError, ownership); + } + catch + { + ownership.Dispose(); + throw; + } + } + + public void Dispose() + { + try + { + _replacementError.Flush(); + ConsoleStreamOwnership.RestoreError(_originalError); + } + finally + { + _ownership.Dispose(); + } + } + } + + private sealed class ErrorOnlyTextWriter(TextWriter inner) : TextWriter + { + private readonly StringBuilder _lineBuffer = new(); + + public override Encoding Encoding => inner.Encoding; + + public override void Write(char value) + { + if (value == '\r') + return; + + if (value == '\n') + { + FlushBufferedLine(); + return; + } + + _lineBuffer.Append(value); + } + + public override void Write(string? value) + { + if (value == null) + return; + + foreach (var ch in value) + Write(ch); + } + + public override void WriteLine(string? value) + { + Write(value); + FlushBufferedLine(); + } + + public override void Flush() + { + FlushBufferedLine(); + inner.Flush(); + } + + private void FlushBufferedLine() + { + if (_lineBuffer.Length == 0) + return; + + var line = _lineBuffer.ToString(); + _lineBuffer.Clear(); + if (IsErrorLine(line)) + inner.WriteLine(line); + } + + private static bool IsErrorLine(string line) + => line.StartsWith("Error", StringComparison.Ordinal); + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.Lsp.cs b/src/CodeIndex/Cli/ProgramRunner.Lsp.cs new file mode 100644 index 000000000..4f165a390 --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.Lsp.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + private static int RunLsp( + string[] cmdArgs, + string appVersion, + JsonSerializerOptions jsonOptions, + CancellationToken cancellationToken = default) + { + var options = QueryCommandRunner.ParseArgs(cmdArgs, jsonDefault: true); + if (options.ParseError != null) + { + CommandErrorWriter.WriteStderr(options.ParseError); + PrintLspUsage(); + return CommandExitCodes.UsageError; + } + + for (var i = 0; i < cmdArgs.Length; i++) + { + if (cmdArgs[i].StartsWith("--db=", StringComparison.Ordinal)) + continue; + if (cmdArgs[i] == "--db") + { + i++; + continue; + } + + CommandErrorWriter.WriteStderr($"Error: {cmdArgs[i]} is not supported for lsp."); + CommandErrorWriter.WriteStderr("Hint: use `--db ` to point at a specific index."); + PrintLspUsage(); + return CommandExitCodes.UsageError; + } + + try + { + if (string.IsNullOrWhiteSpace(options.DbPath)) + { + CommandErrorWriter.WriteStderr("Error: database path could not be resolved."); + PrintLspUsage(); + return CommandExitCodes.UsageError; + } + + if (!options.DbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + && !File.Exists(LongPath.EnsureWindowsPrefix(options.DbPath))) + { + var resolvedPath = Path.GetFullPath(options.DbPath); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {resolvedPath}"); + CommandErrorWriter.WriteStderr("Hint: create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun `cdidx lsp`."); + return CommandExitCodes.DatabaseError; + } + + using var db = new DbContext(DbOpenIntent.QueryOnly, options.DbPath); + if (!db.TryValidateIsCodeIndexDb(out var validationReason)) + { + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbError}]: invalid CodeIndex database: {validationReason}"); + return CommandExitCodes.DatabaseError; + } + + var indexedProjectRoot = db.GetMetaString(DbContext.IndexedProjectRootMetaKey); + if (!string.IsNullOrWhiteSpace(indexedProjectRoot) + && bool.TryParse(db.GetMetaString(DbContext.WorkspacePathCaseSensitiveMetaKey), out var pathCaseSensitive)) + { + PathCasing.SeedFromWorkspace(indexedProjectRoot, ignoreCase: !pathCaseSensitive); + } + + using var server = new LspServer(db, options.DbPath, appVersion, jsonOptions, indexedProjectRoot); + return server.Run(Console.OpenStandardInput(), Console.OpenStandardOutput(), cancellationToken); + } + catch (OperationCanceledException) + { + Console.Out.Flush(); + Console.Error.Flush(); + return CommandExitCodes.CancelledBySignal; + } + catch (Exception ex) + { + GlobalToolLog.Error("lsp_server_failed " + GlobalToolLog.FormatExceptionChain(ex)); + CommandErrorWriter.WriteStderr($"Error: LSP server failed ({FormatSanitizedExceptionSummary(ex)})."); + Console.Out.Flush(); + Console.Error.Flush(); + return CommandExitCodes.DatabaseError; + } + } + + private static void PrintLspUsage() + { + CommandErrorWriter.WriteStderr("Usage: cdidx lsp [--db ]"); + CommandErrorWriter.WriteStderr("Runs a read-only Language Server Protocol server over stdio using an existing CodeIndex database."); + CommandErrorWriter.WriteStderr("Protocol: LSP stdio uses Content-Length framing; unsupported optional methods are not advertised and return JSON-RPC -32601."); + CommandErrorWriter.WriteStderr("Completion: index-backed symbol completion only, resolveProvider=false; unmatched or no-token positions return an empty item list."); + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.Mcp.cs b/src/CodeIndex/Cli/ProgramRunner.Mcp.cs new file mode 100644 index 000000000..b3910149b --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.Mcp.cs @@ -0,0 +1,844 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + private sealed record McpRunOptions( + QueryCommandOptions QueryOptions, + string Transport, + string? ListenSpec, + bool AllowUnauthenticatedHttp, + AuditLogOptions AuditOptions, + IReadOnlyDictionary EnvironmentOverrides); + + private static int RunMcp(string[] cmdArgs, string appVersion) + { + if (!TryPrepareMcpRun(cmdArgs, out var runOptions, out var exitCode)) + return exitCode; + + AuditLogSink? auditLog = null; + using var mcpEnvironment = CdidxEnvironment.Push(runOptions.EnvironmentOverrides); + if (!TryOpenMcpAuditLog(runOptions.AuditOptions, out auditLog, out exitCode)) + return exitCode; + + var auditFlushCompleted = true; + try + { + // Pick the JSON-RPC authenticator for the selected transport. Stdio keeps the + // historical `CDIDX_MCP_AUTH_TOKEN` / `params.auth.token` gate (#1559). HTTP uses + // its bearer header gate instead, with `CDIDX_MCP_HTTP_TOKEN` taking precedence over + // `CDIDX_MCP_AUTH_TOKEN` as a fallback (#3156), so clients never need both header and + // body tokens for one HTTP request. The tool-enablement gate (#1561) is wired + // automatically by the McpServer ctor via `McpToolFilter.FromEnvironment()`. + // 選択済み transport に応じて JSON-RPC authenticator を選ぶ。stdio は従来通り + // `CDIDX_MCP_AUTH_TOKEN` / `params.auth.token` ゲートを使う (#1559)。HTTP は bearer + // header ゲートへ一本化し、`CDIDX_MCP_HTTP_TOKEN` を優先、未設定なら + // `CDIDX_MCP_AUTH_TOKEN` を fallback として使う (#3156)。そのため HTTP では同一 + // リクエストに header token と body token の両方を要求しない。ツール有効化ゲート + // (#1561) は McpServer のコンストラクタ内部で `McpToolFilter.FromEnvironment()` + // から自動取得される。 + IMcpAuthenticator? authenticator = null; + try + { + authenticator = CreateMcpAuthenticatorForTransport(runOptions.Transport); + } + catch (FormatException ex) + { + CommandErrorWriter.WriteStderr($"Error: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); + PrintMcpUsage(); + exitCode = CommandExitCodes.UsageError; + } + + if (authenticator is not null) + { + using var server = new McpServer(runOptions.QueryOptions.DbPath, appVersion, runOptions.QueryOptions.DbPathExplicit, authenticator, auditLog); + exitCode = RunMcpServer(server, runOptions.Transport, runOptions.ListenSpec, runOptions.AllowUnauthenticatedHttp); + } + } + finally + { + if (auditLog is not null) + { + var explicitShutdownCompleted = false; + try + { + auditFlushCompleted = auditLog.Shutdown().FlushCompleted; + explicitShutdownCompleted = true; + } + finally + { + // Avoid a second bounded wait after a completed Shutdown call. Dispose + // remains the fallback only if explicit shutdown exits unexpectedly. + // 完了済み Shutdown の後に bounded wait を重ねない。明示 shutdown が + // 予期せず終了した場合だけ Dispose を fallback として使う。 + if (!explicitShutdownCompleted) + auditLog.Dispose(); + } + } + } + + // RunDispatchedCommand emits the outer MCP command metric from this returned value, + // so resolve strict shutdown only after the sink has reached its final state. + // 外側 MCP command metric はこの戻り値を記録するため、sink の最終状態確定後に + // strict shutdown の終了コードを解決する。 + return ResolveMcpAuditShutdownExitCode(exitCode, runOptions.AuditOptions.Strict, auditFlushCompleted); + } + + internal static int ResolveMcpAuditShutdownExitCode(int serverExitCode, bool strict, bool flushCompleted) + => strict && !flushCompleted && serverExitCode == CommandExitCodes.Success + ? CommandExitCodes.RuntimeError + : serverExitCode; + + private static bool TryPrepareMcpRun(string[] cmdArgs, out McpRunOptions runOptions, out int exitCode) + { + // Strip audit-log opt-in flags first so the strict mcp parser below does not see them + // and raise an unknown-flag error. Keeps `--db` and `--` passthrough intact (#1562). + // audit-log オプションフラグは厳格パーサに渡る前に除去し、未知フラグ扱いされるのを防ぐ (#1562)。 + runOptions = null!; + exitCode = CommandExitCodes.Success; + if (!TryConsumeAuditLogFlags(ref cmdArgs, out var auditOptions, out var auditError)) + { + CommandErrorWriter.WriteStderr(auditError); + PrintMcpUsage(); + exitCode = CommandExitCodes.UsageError; + return false; + } + + if (!TryConsumeSuggestionDedupThresholdFlag(ref cmdArgs, out var suggestionDedupThreshold, out var thresholdError)) + { + CommandErrorWriter.WriteStderr(thresholdError); + PrintMcpUsage(); + exitCode = CommandExitCodes.UsageError; + return false; + } + + if (!TryExtractMcpTransportFlags( + cmdArgs, + out var transportSpec, + out var listenSpec, + out var allowUnauthenticatedHttp, + out var transportError)) + { + CommandErrorWriter.WriteStderr(transportError); + PrintMcpUsage(); + exitCode = CommandExitCodes.UsageError; + return false; + } + + // Strip the transport flags from the args before delegating to QueryCommandRunner.ParseArgs + // and the unknown-flag guard below, both of which only understand the historic `--db` shape. + // Transport フラグは ParseArgs / 未知フラグガードが知らないため、両者に渡す前に除去する。 + var residualArgs = RemoveMcpTransportFlags(cmdArgs); + + var options = QueryCommandRunner.ParseArgs(residualArgs, jsonDefault: true); + if (options.ParseError != null) + { + CommandErrorWriter.WriteStderr(options.ParseError); + PrintMcpUsage(); + exitCode = CommandExitCodes.UsageError; + return false; + } + + if (!TryValidateMcpResidualArgs(residualArgs, out exitCode)) + return false; + + if (!TryResolveMcpTransport( + transportSpec, + listenSpec, + allowUnauthenticatedHttp, + out var transport, + out exitCode)) + return false; + + var environmentOverrides = new Dictionary(StringComparer.Ordinal); + if (suggestionDedupThreshold is not null) + environmentOverrides[SuggestionStore.DedupThresholdEnvironmentVariable] = suggestionDedupThreshold; + + runOptions = new McpRunOptions( + options, + transport, + listenSpec, + allowUnauthenticatedHttp, + auditOptions, + environmentOverrides); + return true; + } + + private static bool TryValidateMcpResidualArgs(string[] residualArgs, out int exitCode) + { + for (var i = 0; i < residualArgs.Length; i++) + { + if (residualArgs[i].StartsWith("--db=", StringComparison.Ordinal)) + continue; + + if (residualArgs[i] == "--db") + { + i++; + continue; + } + + if (residualArgs[i] == "--json") + CommandErrorWriter.WriteStderr("Error: --json is not supported for mcp; MCP already speaks JSON-RPC over the selected transport."); + else + CommandErrorWriter.WriteStderr($"Error: {residualArgs[i]} is not supported for mcp."); + CommandErrorWriter.WriteStderr($"Hint: use `--db ` to point at a specific index, `--transport stdio|http` to pick a transport, `--http-listen host:port` for HTTP, `{AllowUnauthenticatedHttpFlag}` for explicit unsafe loopback operation, or `--audit-log ` to enable per-call auditing."); + PrintMcpUsage(); + exitCode = CommandExitCodes.UsageError; + return false; + } + + exitCode = CommandExitCodes.Success; + return true; + } + + private static bool TryResolveMcpTransport( + string? transportSpec, + string? listenSpec, + bool allowUnauthenticatedHttp, + out string transport, + out int exitCode) + { + transport = transportSpec ?? "stdio"; + if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase) + && !string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)) + { + CommandErrorWriter.WriteStderr($"Error: --transport '{transport}' is not supported. Use `stdio` (default) or `http`."); + PrintMcpUsage(); + exitCode = CommandExitCodes.UsageError; + return false; + } + + if (listenSpec != null && !string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)) + { + CommandErrorWriter.WriteStderr("Error: --http-listen requires `--transport http`."); + PrintMcpUsage(); + exitCode = CommandExitCodes.UsageError; + return false; + } + + if (allowUnauthenticatedHttp && !string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)) + { + CommandErrorWriter.WriteStderr($"Error: {AllowUnauthenticatedHttpFlag} requires `--transport http`."); + PrintMcpUsage(); + exitCode = CommandExitCodes.UsageError; + return false; + } + + exitCode = CommandExitCodes.Success; + return true; + } + + private static bool TryOpenMcpAuditLog(AuditLogOptions auditOptions, out AuditLogSink? auditLog, out int exitCode) + { + auditLog = null; + if (auditOptions.Path == null) + { + exitCode = CommandExitCodes.Success; + return true; + } + + try + { + auditLog = new AuditLogSink(auditOptions.Path, auditOptions.MaxBytes, auditOptions.IncludeValues); + exitCode = CommandExitCodes.Success; + return true; + } + catch (Exception ex) when (IsExpectedAuditLogOpenException(ex)) + { + var displayPath = DiagnosticSanitizer.ForPath(auditOptions.Path); + CommandErrorWriter.WriteStderr($"Error: failed to open audit log '{displayPath}' ({FormatSanitizedExceptionSummary(ex)})."); + CommandErrorWriter.WriteStderr("Hint: pick a writable path or omit --audit-log to disable per-call auditing."); + exitCode = CommandExitCodes.UsageError; + return false; + } + } + + private static string FormatSanitizedExceptionSummary(Exception ex) + { + var exceptionType = CommandErrorWriter.FormatSanitizedException(ex); + var message = CommandErrorWriter.FormatSanitizedExceptionMessage(ex); + return string.IsNullOrEmpty(message) ? exceptionType : $"{exceptionType}: {message}"; + } + + private static bool IsExpectedAuditLogOpenException(Exception ex) + => ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException; + + private static int RunMcpServer( + McpServer server, + string transport, + string? listenSpec, + bool allowUnauthenticatedHttp) + { + if (string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)) + return RunMcpHttp(server, listenSpec ?? DefaultMcpHttpListen, allowUnauthenticatedHttp); + + try + { + server.RunAsync().GetAwaiter().GetResult(); + return CommandExitCodes.Success; + } + catch (OperationCanceledException) + { + Console.Out.Flush(); + Console.Error.Flush(); + return CommandExitCodes.CancelledBySignal; + } + catch (Exception ex) + { + GlobalToolLog.Error("mcp_server_failed " + GlobalToolLog.FormatExceptionChain(ex)); + CommandErrorWriter.WriteStderr($"Error: MCP server failed ({FormatSanitizedExceptionSummary(ex)})."); + Console.Out.Flush(); + Console.Error.Flush(); + return CommandExitCodes.DatabaseError; + } + } + + internal static IMcpAuthenticator CreateMcpAuthenticatorForTransport(string transport) + => string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase) + ? LocalStdioAuthenticator.Instance + : McpAuthenticatorFactory.FromEnvironment(); + + internal static string? ResolveMcpHttpBearerTokenFromEnvironment() + { + var httpToken = McpEnvironment.GetOptionalToken(McpHttpTokenEnvVar); + if (httpToken is not null) + return httpToken; + + return McpEnvironment.GetOptionalToken(McpAuthenticatorFactory.AuthTokenEnvVar); + } + + private static int RunMcpHttp(McpServer server, string listenSpec, bool allowUnauthenticatedHttp) + { + HttpMcpTransport.HttpListenSpec resolved; + try + { + resolved = HttpMcpTransport.ResolveListenSpec(listenSpec); + } + catch (FormatException ex) + { + CommandErrorWriter.WriteStderr($"Error: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); + PrintMcpUsage(); + return CommandExitCodes.UsageError; + } + + // Require a shared-secret bearer token for every HTTP listener by default. HTTP resolves that + // bearer token from `CDIDX_MCP_HTTP_TOKEN` first, then falls back to the generic + // `CDIDX_MCP_AUTH_TOKEN` so setting the generic auth token also protects HTTP without + // forcing clients to send both `Authorization` and `params.auth.token` (#3156). Only the + // explicit CLI opt-in permits an unauthenticated loopback listener; non-loopback binds + // always require the token (#4549). + // すべての HTTP listener で既定では共有秘密 bearer token を必須にする。HTTP はまず + // `CDIDX_MCP_HTTP_TOKEN` を使い、未設定なら汎用の + // `CDIDX_MCP_AUTH_TOKEN` を bearer token として使うため、汎用 token を設定しただけでも + // HTTP は保護され、クライアントに `Authorization` と `params.auth.token` の両方を + // 要求しない (#3156)。明示 CLI opt-in だけが unauthenticated loopback を許可し、 + // non-loopback bind は常に token を必須とする (#4549)。 + string? bearerToken; + try + { + bearerToken = ResolveMcpHttpBearerTokenFromEnvironment(); + } + catch (FormatException ex) + { + CommandErrorWriter.WriteStderr($"Error: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); + PrintMcpUsage(); + return CommandExitCodes.UsageError; + } + + if (allowUnauthenticatedHttp && !resolved.IsLoopback) + { + CommandErrorWriter.WriteStderr($"Error: {AllowUnauthenticatedHttpFlag} is limited to loopback listeners; '{resolved.Host}' is not loopback."); + PrintMcpUsage(); + return CommandExitCodes.UsageError; + } + + if (bearerToken is null && !allowUnauthenticatedHttp) + { + CommandErrorWriter.WriteStderr($"Error: --transport http requires bearer authentication for '{resolved.Host}'. Set the `{McpHttpTokenEnvVar}` or `{McpAuthenticatorFactory.AuthTokenEnvVar}` environment variable. For explicitly unsafe loopback-only operation, pass {AllowUnauthenticatedHttpFlag}."); + PrintMcpUsage(); + return CommandExitCodes.UsageError; + } + + HttpMcpTransport transport; + try + { + transport = new HttpMcpTransport( + resolved.Prefix, + resolved.Host, + resolved.Port, + bearerToken, + requestLogger: LogHttpMcpRequest, + allowUnauthenticatedLoopback: allowUnauthenticatedHttp); + } + catch (FormatException ex) + { + CommandErrorWriter.WriteStderr($"Error: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); + PrintMcpUsage(); + return CommandExitCodes.UsageError; + } + catch (ArgumentOutOfRangeException ex) + { + CommandErrorWriter.WriteStderr($"Error: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); + PrintMcpUsage(); + return CommandExitCodes.UsageError; + } + catch (HttpListenerException ex) + { + CommandErrorWriter.WriteStderr($"Error: {HttpMcpTransport.FormatBindFailureDiagnostic(resolved, ex)}"); + return CommandExitCodes.UsageError; + } + + try + { + using var cts = new CancellationTokenSource(); + // Treat SIGINT (Ctrl+C) AND SIGTERM as graceful shutdown signals so orchestrators + // (systemd, launchd, supervisord) can drain the listener and release the HTTP socket + // instead of force-killing the process (#1573). + // SIGINT (Ctrl+C) と SIGTERM を graceful shutdown として扱い、systemd / launchd / + // supervisord が socket を解放して再起動できるようにする(#1573)。 + using (McpServer.RegisterShutdownHandlers(cts)) + { + if (transport.AuthDisabledWarning is { } authWarning) + { + CommandErrorWriter.WriteStderr($"[cdidx-mcp] Warning: {authWarning} Remove {AllowUnauthenticatedHttpFlag} and set `{McpHttpTokenEnvVar}` or `{McpAuthenticatorFactory.AuthTokenEnvVar}` to require bearer auth."); + CommandErrorWriter.WriteStderr($"[cdidx-mcp] HTTP transport listening on {resolved.Prefix} (loopback, explicit unsafe no-auth mode)."); + GlobalToolLog.Info("mcp_http_auth_disabled_warning loopback=true"); + } + else + { + CommandErrorWriter.WriteStderr($"[cdidx-mcp] HTTP transport listening on {resolved.Prefix} (bearer auth required)."); + } + CommandErrorWriter.WriteStderr( + $"[cdidx-mcp] HTTP request deadlines: body_idle_ms={transport.RequestBodyIdleTimeout.TotalMilliseconds.ToString("0", CultureInfo.InvariantCulture)}, total_ms={transport.RequestLifetimeTimeout.TotalMilliseconds.ToString("0", CultureInfo.InvariantCulture)}."); + + try + { + server.RunAsync(transport, cts.Token).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + Console.Out.Flush(); + Console.Error.Flush(); + return CommandExitCodes.CancelledBySignal; + } + catch (Exception ex) + { + GlobalToolLog.Error("mcp_http_server_failed " + GlobalToolLog.FormatExceptionChain(ex)); + CommandErrorWriter.WriteStderr($"Error: MCP HTTP server failed ({FormatSanitizedExceptionSummary(ex)})."); + Console.Out.Flush(); + Console.Error.Flush(); + return CommandExitCodes.DatabaseError; + } + } + } + finally + { + DisposeMcpHttpTransport(transport); + } + + return CommandExitCodes.Success; + } + + private static void DisposeMcpHttpTransport(HttpMcpTransport transport) + { + try + { + var disposeTask = transport.DisposeAsync().AsTask(); + if (disposeTask.Wait(McpHttpDisposeTimeout)) + return; + + var message = $"MCP HTTP transport disposal did not finish within {FormatDuration(McpHttpDisposeTimeout)}."; + GlobalToolLog.Error("mcp_http_transport_dispose_timeout " + message); + CommandErrorWriter.WriteStderr("Warning: " + message); + } + catch (AggregateException ex) + { + var inner = ex.Flatten().InnerExceptions.FirstOrDefault() ?? ex; + GlobalToolLog.Error("mcp_http_transport_dispose_failed " + GlobalToolLog.FormatExceptionChain(inner)); + CommandErrorWriter.WriteStderr($"Warning: MCP HTTP transport disposal failed ({FormatSanitizedExceptionSummary(inner)})."); + } + catch (Exception ex) + { + GlobalToolLog.Error("mcp_http_transport_dispose_failed " + GlobalToolLog.FormatExceptionChain(ex)); + CommandErrorWriter.WriteStderr($"Warning: MCP HTTP transport disposal failed ({FormatSanitizedExceptionSummary(ex)})."); + } + } + + private static void LogHttpMcpRequest(HttpMcpTransport.HttpRequestLogRecord record) + { + GlobalToolLog.Info(FormatHttpMcpRequestLogRecord(record)); + } + + internal static string FormatHttpMcpRequestLogRecord(HttpMcpTransport.HttpRequestLogRecord record) + => "mcp_http_request" + + $" correlation_id={record.CorrelationId}" + + $" request_id={FormatLogValue(record.RequestId)}" + + $" request_id_type={FormatLogValue(record.RequestIdType)}" + + $" request_id_length={(record.RequestIdLength?.ToString(CultureInfo.InvariantCulture) ?? "-")}" + + $" remote_peer={FormatLogValue(record.RemotePeer)}" + + $" method={FormatLogValue(record.Method)}" + + $" path={FormatLogValue(record.Path)}" + + $" status={record.StatusCode.ToString(CultureInfo.InvariantCulture)}" + + $" duration_ms={record.DurationMs.ToString("0.###", CultureInfo.InvariantCulture)}" + + $" auth={FormatLogValue(record.AuthOutcome)}" + + $" rejection={FormatLogValue(record.RejectionReason)}" + + $" diagnostic={FormatLogValue(record.Diagnostic)}"; + + private static string FormatLogValue(string? value) + { + var limited = HttpMcpTransport.LimitRequestLogField(value); + if (string.IsNullOrEmpty(limited)) + return "-"; + + return limited + .Replace('\\', '/') + .Replace('\r', '_') + .Replace('\n', '_') + .Replace('\t', '_') + .Replace(' ', '_'); + } + + private static void PrintMcpUsage() + { + CommandErrorWriter.WriteStderr($"Usage: cdidx mcp [--db ] [--transport stdio|http] [--http-listen ] [{AllowUnauthenticatedHttpFlag}] [--audit-log ] [--audit-log-include-values] [--audit-log-max-bytes ] [--audit-log-strict] [--suggestion-dedup-threshold <0..1>]"); + CommandErrorWriter.WriteStderr("Note: --json is not supported; MCP requests and responses are JSON-RPC over the selected transport."); + CommandErrorWriter.WriteStderr("stdio transport: one UTF-8 JSON-RPC object per LF-delimited line, not LSP Content-Length framing; lifecycle diagnostics are written to stderr."); + CommandErrorWriter.WriteStderr($"HTTP security: bearer auth is required by default; {AllowUnauthenticatedHttpFlag} is an explicit unsafe loopback-only opt-in. Native clients omit Origin; POST requires UTF-8 application/json."); + CommandErrorWriter.WriteStderr($"HTTP limits: {HttpMcpTransport.MaxRequestBodyBytesEnvVar}= (1..{HttpMcpTransport.MaxConfiguredRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxInFlightRequestBodyBytesEnvVar}= (1..{HttpMcpTransport.MaxConfiguredInFlightRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxInFlightRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}, must be >= {HttpMcpTransport.MaxRequestBodyBytesEnvVar}), {HttpMcpTransport.MaxResponseBodyBytesEnvVar}= (1..{HttpMcpTransport.MaxConfiguredResponseBodyBytes.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxResponseBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}= (1..{HttpMcpTransport.MaxConfiguredQueuedRequests.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxQueuedRequests.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxConcurrentHandlersEnvVar}= (1..{HttpMcpTransport.MaxConfiguredConcurrentHandlers.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxConcurrentHandlers.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxEventStreamsEnvVar}= (1..{HttpMcpTransport.MaxConfiguredEventStreams.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxEventStreams.ToString(CultureInfo.InvariantCulture)})."); + CommandErrorWriter.WriteStderr($"HTTP deadlines: {HttpMcpTransport.RequestBodyIdleTimeoutMillisecondsEnvVar}= (1..{HttpMcpTransport.MaxRequestBodyIdleTimeoutMilliseconds.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultRequestBodyIdleTimeoutMilliseconds.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.RequestLifetimeTimeoutMillisecondsEnvVar}= (1..{HttpMcpTransport.MaxRequestLifetimeTimeoutMilliseconds.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultRequestLifetimeTimeoutMilliseconds.ToString(CultureInfo.InvariantCulture)}, must be >= {HttpMcpTransport.RequestBodyIdleTimeoutMillisecondsEnvVar})."); + CommandErrorWriter.WriteStderr("Every present HTTP limit or deadline environment variable must be a positive integer in its displayed range; only an absent variable uses the default. POST handlers and SSE event streams use independent capacity gates."); + } + + internal static bool TryConsumeSuggestionDedupThresholdFlag(ref string[] args, out string? thresholdValue, out string error) + { + thresholdValue = null; + error = string.Empty; + if (args.Length == 0) + return true; + + var kept = new List(args.Length); + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + + string? value = null; + if (arg == "--suggestion-dedup-threshold") + { + if (i + 1 >= args.Length) + { + error = "Error: --suggestion-dedup-threshold requires a value between 0 and 1."; + return false; + } + value = args[++i]; + } + else if (arg.StartsWith("--suggestion-dedup-threshold=", StringComparison.Ordinal)) + { + value = arg.Substring("--suggestion-dedup-threshold=".Length); + } + else + { + kept.Add(arg); + continue; + } + + if (!double.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var threshold) + || threshold < 0 + || threshold > 1) + { + error = "Error: --suggestion-dedup-threshold must be a value between 0 and 1."; + return false; + } + + thresholdValue = value; + } + + args = kept.ToArray(); + return true; + } + + internal static bool TryExtractMcpTransportFlags( + string[] cmdArgs, + out string? transport, + out string? listen, + out bool allowUnauthenticatedHttp, + out string error) + { + transport = null; + listen = null; + allowUnauthenticatedHttp = false; + error = string.Empty; + for (var i = 0; i < cmdArgs.Length; i++) + { + var arg = cmdArgs[i]; + if (arg == "--transport") + { + if (i + 1 >= cmdArgs.Length) + { + error = "Error: --transport requires a value (`stdio` or `http`)."; + return false; + } + transport = cmdArgs[++i]; + } + else if (arg.StartsWith("--transport=", StringComparison.Ordinal)) + { + transport = arg.Substring("--transport=".Length); + } + else if (arg == "--http-listen") + { + if (i + 1 >= cmdArgs.Length) + { + error = "Error: --http-listen requires a host:port value."; + return false; + } + listen = cmdArgs[++i]; + } + else if (arg.StartsWith("--http-listen=", StringComparison.Ordinal)) + { + listen = arg.Substring("--http-listen=".Length); + } + else if (arg == AllowUnauthenticatedHttpFlag) + { + allowUnauthenticatedHttp = true; + } + } + return true; + } + + private static string[] RemoveMcpTransportFlags(string[] cmdArgs) + { + var kept = new List(cmdArgs.Length); + for (var i = 0; i < cmdArgs.Length; i++) + { + var arg = cmdArgs[i]; + if (arg == "--transport" || arg == "--http-listen") + { + if (i + 1 < cmdArgs.Length) + i++; + continue; + } + if (arg.StartsWith("--transport=", StringComparison.Ordinal) + || arg.StartsWith("--http-listen=", StringComparison.Ordinal)) + { + continue; + } + if (arg == AllowUnauthenticatedHttpFlag) + continue; + kept.Add(arg); + } + return kept.ToArray(); + } + + /// + /// Strip the MCP audit-log opt-in flags (`--audit-log[=]`, + /// `--audit-log-include-values`, `--audit-log-max-bytes[=]`, `--audit-log-strict`) from `cmdArgs` before + /// the strict `cdidx mcp` parser runs. Keeps `--db` and everything after `--` + /// untouched so existing escape semantics survive (#1562). + /// `cdidx mcp` の厳格パーサが走る前に audit-log 用フラグを取り除く。`--db` と + /// `--` 以降はそのまま残し既存意味論を保つ (#1562)。 + /// + internal static bool TryConsumeAuditLogFlags(ref string[] args, out AuditLogOptions options, out string error) + { + options = new AuditLogOptions(null, AuditLogSink.DefaultMaxBytes, false, false); + error = string.Empty; + if (args.Length == 0) + return true; + + var state = new AuditLogFlagParseState(args.Length); + for (var i = 0; i < args.Length; i++) + { + if (!TryConsumeAuditLogArgument(args, ref i, state, out error)) + return false; + } + + if (state.IncludeValues && state.Path == null) + { + error = "Error: --audit-log-include-values requires --audit-log ."; + return false; + } + + if (state.Strict && state.Path == null) + { + error = "Error: --audit-log-strict requires --audit-log ."; + return false; + } + + options = state.ToOptions(); + args = state.Kept.ToArray(); + return true; + } + + private sealed class AuditLogFlagParseState + { + internal AuditLogFlagParseState(int capacity) + { + Kept = new List(capacity); + } + + internal List Kept { get; } + internal string? Path { get; set; } + internal long MaxBytes { get; set; } = AuditLogSink.DefaultMaxBytes; + internal bool IncludeValues { get; set; } + internal bool Strict { get; set; } + internal bool Passthrough { get; set; } + + internal AuditLogOptions ToOptions() => new(Path, MaxBytes, IncludeValues, Strict); + } + + private static bool TryConsumeAuditLogArgument( + string[] args, + ref int index, + AuditLogFlagParseState state, + out string error) + { + error = string.Empty; + var arg = args[index]; + if (state.Passthrough) + { + state.Kept.Add(arg); + return true; + } + + if (arg == "--") + { + state.Passthrough = true; + state.Kept.Add(arg); + return true; + } + + // Pass `--db` and its value through together so a dash-prefixed DB path + // (e.g. `cdidx mcp --db --some-uri`) is not mis-consumed as the start of + // an audit-log flag. The strict mcp parser downstream supports both + // `--db ` and `--db=value`; here we only need to guard the spaced form. + // `--db` とその値はまとめて通過させ、ダッシュ始まりの DB パス + // (例: `cdidx mcp --db --some-uri`) を audit-log フラグの先頭と + // 誤認しないようにする。`--db=value` 形式は値が同じトークンに含まれるため + // 既存ループでそのまま `kept` に流れる。 + if (arg == "--db") + { + state.Kept.Add(arg); + if (index + 1 < args.Length) + state.Kept.Add(args[++index]); + return true; + } + + if (arg == "--audit-log") + return TryConsumeAuditLogPathValue(args, ref index, state, out error); + + if (arg.StartsWith("--audit-log=", StringComparison.Ordinal)) + return TrySetAuditLogPath(arg.Substring("--audit-log=".Length), state, out error); + + if (arg == "--audit-log-include-values") + { + state.IncludeValues = true; + return true; + } + + if (arg == "--audit-log-strict") + { + state.Strict = true; + return true; + } + + if (arg == "--audit-log-max-bytes" || arg.StartsWith("--audit-log-max-bytes=", StringComparison.Ordinal)) + return TryConsumeAuditLogMaxBytes(args, ref index, state, out error); + + state.Kept.Add(arg); + return true; + } + + private static bool TryConsumeAuditLogPathValue( + string[] args, + ref int index, + AuditLogFlagParseState state, + out string error) + { + if (index + 1 >= args.Length) + { + error = "Error: --audit-log requires a path value (use `--audit-log ` or `--audit-log=`)."; + return false; + } + + return TrySetAuditLogPath(args[++index], state, out error); + } + + private static bool TrySetAuditLogPath(string path, AuditLogFlagParseState state, out string error) + { + if (string.IsNullOrWhiteSpace(path)) + { + error = "Error: --audit-log requires a non-empty path value."; + return false; + } + + state.Path = path; + error = string.Empty; + return true; + } + + private static bool TryConsumeAuditLogMaxBytes( + string[] args, + ref int index, + AuditLogFlagParseState state, + out string error) + { + var arg = args[index]; + string raw; + if (arg == "--audit-log-max-bytes") + { + if (index + 1 >= args.Length) + { + error = "Error: --audit-log-max-bytes requires a byte count."; + return false; + } + raw = args[++index]; + } + else + { + raw = arg.Substring("--audit-log-max-bytes=".Length); + } + + if (!long.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) + || parsed < AuditLogSink.MinMaxBytes + || parsed > AuditLogSink.MaxMaxBytes) + { + error = $"Error: --audit-log-max-bytes must be an integer between {AuditLogSink.MinMaxBytes} and {AuditLogSink.MaxMaxBytes}."; + return false; + } + + state.MaxBytes = parsed; + error = string.Empty; + return true; + } + + internal readonly record struct AuditLogOptions(string? Path, long MaxBytes, bool IncludeValues, bool Strict); +} diff --git a/src/CodeIndex/Cli/ProgramRunner.Metrics.cs b/src/CodeIndex/Cli/ProgramRunner.Metrics.cs new file mode 100644 index 000000000..a0a3e33fb --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.Metrics.cs @@ -0,0 +1,487 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + internal static bool TryConsumeMetricsFlag(ref string[] args, out string? path, out string error) + { + path = null; + error = string.Empty; + if (args.Length == 0) + return true; + + var kept = new List(args.Length); + string? requested = null; + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (ShouldPreserveQueryCommandToken(args, i)) + { + kept.Add(arg); + continue; + } + + string? rawValue = null; + if (arg == "--metrics") + { + if (i + 1 >= args.Length) + { + error = "Error: --metrics requires a path value (use `--metrics ` or `--metrics=`)."; + return false; + } + rawValue = args[++i]; + } + else if (arg.StartsWith("--metrics=", StringComparison.Ordinal)) + { + rawValue = arg.Substring("--metrics=".Length); + } + else + { + kept.Add(arg); + continue; + } + + if (string.IsNullOrWhiteSpace(rawValue)) + { + error = "Error: --metrics requires a non-empty path value."; + return false; + } + requested = rawValue; + } + + path = requested; + args = kept.ToArray(); + return true; + } + + internal static bool TryConsumeQueryTraceFlag(ref string[] args, out string traceMode, out string error) + => TryConsumeQueryTraceFlag(commandName: null, ref args, out traceMode, out error); + + internal static bool TryConsumeQueryTraceFlag(string? commandName, ref string[] args, out string traceMode, out string error) + { + traceMode = "none"; + error = string.Empty; + if (args.Length == 0) + return true; + + var kept = new List(args.Length); + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (commandName is not null && ShouldPreserveQueryCommandToken(commandName, args, i)) + { + kept.Add(arg); + continue; + } + + string? rawValue = null; + if (arg == "--trace") + { + if (i + 1 >= args.Length) + { + error = "Error: --trace requires a value (use `--trace stderr`, `--trace file`, `--trace none`, or `--trace=`)."; + return false; + } + rawValue = args[++i]; + } + else if (arg.StartsWith("--trace=", StringComparison.Ordinal)) + { + rawValue = arg.Substring("--trace=".Length); + } + else + { + kept.Add(arg); + continue; + } + + if (string.IsNullOrWhiteSpace(rawValue)) + { + error = "Error: --trace requires a non-empty value."; + return false; + } + if (rawValue is not ("none" or "stderr" or "file")) + { + error = $"Error: --trace must be one of `none`, `stderr`, or `file`, got `{ConsoleUi.FormatBoundedValue(rawValue)}`."; + return false; + } + traceMode = rawValue; + } + + args = kept.ToArray(); + return true; + } + + private static void EmitQueryTrace(string mode, string commandName, string[] subArgs, DateTimeOffset startTimestamp, Stopwatch stopwatch, int exitCode, int? resultCount) + { + if (mode == "none") + return; + + try + { + var elapsedMs = stopwatch.Elapsed.TotalMilliseconds; + var payload = BuildQueryTraceJson(commandName, subArgs, startTimestamp, elapsedMs, exitCode, resultCount); + if (mode == "stderr") + { + CommandErrorWriter.WriteStderr(payload); + return; + } + + var directory = GlobalToolLog.ResolveLogDirectoryForStatus(); + Directory.CreateDirectory(directory); + PrivateLogFile.HardenExisting(directory, "query-trace-*.jsonl"); + var path = ResolveQueryTracePath(directory); + var encoded = Encoding.UTF8.GetBytes(payload + Environment.NewLine); + using (var stream = PrivateLogFile.OpenAppend(path, FileShare.ReadWrite)) + { + stream.Write(encoded, 0, encoded.Length); + stream.Flush(); + } + PrivateLogFile.TrySetPrivatePermissions(path); + PrivateLogFile.PruneOldFiles(directory, "query-trace-*.jsonl", RetainedQueryTraceFileCount); + } + catch + { + // Best-effort only: trace output must never change query command behavior. + } + } + + private static string ResolveQueryTracePath(string directory) + { + var date = TimeProvider.GetUtcNow().UtcDateTime.ToString("yyyyMMdd", CultureInfo.InvariantCulture); + return Path.Combine(directory, $"query-trace-{date}.jsonl"); + } + + private static string BuildQueryTraceJson(string commandName, string[] subArgs, DateTimeOffset timestamp, double elapsedMs, int exitCode, int? resultCount) + { + var payload = new JsonObject + { + ["timestamp"] = timestamp.ToString("O", CultureInfo.InvariantCulture), + ["tool"] = commandName, + ["source"] = "cli_query", + ["parameters"] = BuildQueryTraceParameters(subArgs), + ["elapsed_ms"] = Math.Round(elapsedMs, 3), + ["result_count"] = resultCount, + ["exit_code"] = exitCode, + }; + if (exitCode != CommandExitCodes.Success) + payload["error"] = "command_failed"; + return payload.ToJsonString(CreateDefaultJsonOptions()); + } + + private static JsonObject BuildQueryTraceParameters(string[] args) + { + var parameters = new JsonObject + { + ["json"] = false, + ["count"] = false, + }; + var paths = new List(); + var excludePaths = new List(); + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + continue; + if (arg == "--") + { + passthrough = true; + continue; + } + + string? inlineValue = null; + var optionName = arg; + var equals = arg.IndexOf('='); + if (equals > 0) + { + optionName = arg[..equals]; + inlineValue = arg[(equals + 1)..]; + } + + string? value = inlineValue; + if (value == null && optionName is "--lang" or "--limit" or "--top" or "--path" or "--exclude-path") + { + if (i + 1 < args.Length) + value = args[++i]; + } + + switch (optionName) + { + case "--json": + parameters["json"] = true; + if (!string.IsNullOrWhiteSpace(value)) + AddQueryTraceString(parameters, "json_format", value); + break; + case "--count": + parameters["count"] = true; + break; + case "--lang" when !string.IsNullOrWhiteSpace(value): + AddQueryTraceString(parameters, "lang", value); + break; + case "--limit" when !string.IsNullOrWhiteSpace(value): + case "--top" when !string.IsNullOrWhiteSpace(value): + AddQueryTraceString(parameters, "limit", value); + break; + case "--path" when !string.IsNullOrWhiteSpace(value): + paths.Add(value); + break; + case "--exclude-path" when !string.IsNullOrWhiteSpace(value): + excludePaths.Add(value); + break; + } + } + AddQueryTraceArray(parameters, "path", paths); + AddQueryTraceArray(parameters, "exclude_path", excludePaths); + return parameters; + } + + private static void AddQueryTraceString(JsonObject parameters, string name, string value) + { + var bounded = ConsoleUi.BoundDisplayText(value, QueryTraceValueMaxChars); + parameters[name] = bounded.Text; + if (bounded.Truncated) + { + parameters[$"{name}_truncated"] = true; + parameters[$"{name}_original_length"] = bounded.OriginalLength; + } + } + + private static void AddQueryTraceArray(JsonObject parameters, string name, List values) + { + if (values.Count == 0) + return; + + var array = new JsonArray(); + var valueTruncated = false; + foreach (var value in values.Take(QueryTraceArrayMaxItems)) + { + var bounded = ConsoleUi.BoundDisplayText(value, QueryTraceValueMaxChars); + valueTruncated |= bounded.Truncated; + array.Add(JsonValue.Create(bounded.Text)); + } + + parameters[name] = array; + if (values.Count > QueryTraceArrayMaxItems) + { + parameters[$"{name}_truncated"] = true; + parameters[$"{name}_original_count"] = values.Count; + } + + if (valueTruncated) + parameters[$"{name}_value_truncated"] = true; + } + + private sealed class QueryTraceOutputCapture : TextWriter + { + private readonly TextWriter _inner; + private readonly IDisposable _ownership; + private readonly bool _countNumericOutput; + private readonly bool _countJsonLines; + private bool _disposed; + + private QueryTraceOutputCapture( + TextWriter inner, + IDisposable ownership, + bool countNumericOutput, + bool countJsonLines) + { + _inner = inner; + _ownership = ownership; + _countNumericOutput = countNumericOutput; + _countJsonLines = countJsonLines; + } + + public override Encoding Encoding => _inner.Encoding; + public int? ResultCount { get; private set; } + + public static QueryTraceOutputCapture? TryStart(string traceMode, string[] args) + { + if (traceMode == "none") + return null; + + var ownership = ConsoleStreamOwnership.Enter(); + try + { + var capture = new QueryTraceOutputCapture( + Console.Out, + ownership, + HasFlag(args, "--count"), + HasFlag(args, "--json") && !HasInlineValue(args, "--json", "array")); + Console.SetOut(capture); + return capture; + } + catch + { + ownership.Dispose(); + throw; + } + } + + public override void Write(char value) => _inner.Write(value); + public override void Write(string? value) => _inner.Write(value); + + public override void WriteLine(string? value) + { + _inner.WriteLine(value); + ObserveLine(value); + } + + public override void WriteLine() + { + _inner.WriteLine(); + ObserveLine(string.Empty); + } + + protected override void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + try + { + ConsoleStreamOwnership.RestoreOut(_inner); + _disposed = true; + } + finally + { + _ownership.Dispose(); + } + } + base.Dispose(disposing); + } + + private void ObserveLine(string? value) + { + if (value == null) + return; + + var trimmed = value.Trim(); + if (_countNumericOutput && int.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count) && count >= 0) + { + ResultCount = count; + return; + } + + if (_countJsonLines && trimmed.StartsWith('{')) + ResultCount = (ResultCount ?? 0) + 1; + } + + private static bool HasFlag(string[] args, string name) + { + var passthrough = false; + foreach (var arg in args) + { + if (passthrough) + continue; + if (arg == "--") + { + passthrough = true; + continue; + } + if (arg == name || arg.StartsWith(name + "=", StringComparison.Ordinal)) + return true; + } + return false; + } + + private static bool HasInlineValue(string[] args, string name, string value) + { + var expected = name + "=" + value; + var passthrough = false; + foreach (var arg in args) + { + if (passthrough) + continue; + if (arg == "--") + { + passthrough = true; + continue; + } + if (arg == expected) + return true; + } + return false; + } + } + + internal static void EmitCommandMetric(string tool, string[] args, DateTimeOffset startTimestamp, Stopwatch stopwatch, int exitCode, string? error = null) + { + if (!MetricsSink.IsActive) + return; + + stopwatch.Stop(); + MetricsSink.Record(new MetricsEvent( + Timestamp: startTimestamp, + Tool: tool, + Source: "cli", + ElapsedMs: stopwatch.Elapsed.TotalMilliseconds, + ExitCode: exitCode, + Language: TryParseLanguageFromArgs(args), + Error: error)); + } + + internal static string? TryParseLanguageFromArgs(string[] args) + { + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "--") + return null; + if (arg == "--lang" && i + 1 < args.Length) + return args[i + 1]; + if (arg.StartsWith("--lang=", StringComparison.Ordinal)) + return arg.Substring("--lang=".Length); + } + return null; + } + + internal static JsonSerializerOptions CreateDefaultJsonOptions() => new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + TypeInfoResolver = CliJsonSerializerContext.Default, + }; +} diff --git a/src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs b/src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs new file mode 100644 index 000000000..c15c0b2c1 --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.QueryArguments.cs @@ -0,0 +1,608 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + internal static void EnsureRedirectedStdoutUsesUtf8() + { + using var ownership = ConsoleStreamOwnership.Enter(); + if (!Console.IsOutputRedirected || Console.Out is StringWriter || Console.Out.GetType().Assembly != typeof(Console).Assembly) + return; + + var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + if (Console.Out.Encoding.CodePage == utf8NoBom.CodePage) + return; + + var writer = new StreamWriter(Console.OpenStandardOutput(), utf8NoBom) + { + AutoFlush = true + }; + Console.SetOut(TextWriter.Synchronized(writer)); + } + + internal static bool ContainsJsonOutputFlag(IEnumerable args) + { + var passthrough = false; + foreach (var arg in args) + { + if (passthrough) + continue; + if (arg == "--") + { + passthrough = true; + continue; + } + if (arg == "--json" + || arg.StartsWith("--json=", StringComparison.Ordinal) + || arg == JsonEnvelopeWrapper.EnvelopeFlag) + return true; + } + + return false; + } + + private enum QueryCommandTokenRole + { + None, + CommandOptionValue, + FirstQueryLiteral, + } + + private static string[] InsertQueryLiteralSentinelForNonLogGlobalOption(string commandName, string[] subArgs) + { + if (!CommandAcceptsQueryLiteral(commandName)) + return subArgs; + + for (var i = 0; i < subArgs.Length; i++) + { + if (subArgs[i] == "--") + return subArgs; + if (!IsNonLogGlobalOptionToken(subArgs[i])) + continue; + if (GetQueryCommandTokenRole(commandName, subArgs, i) != QueryCommandTokenRole.FirstQueryLiteral) + continue; + + var rewritten = new List(subArgs.Length + 1); + for (var j = 0; j < i; j++) + rewritten.Add(subArgs[j]); + rewritten.Add("--"); + for (var j = i; j < subArgs.Length; j++) + rewritten.Add(subArgs[j]); + return rewritten.ToArray(); + } + + return subArgs; + } + + private static bool ShouldPreserveQueryCommandToken(string[] args, int index) + { + var role = GetQueryCommandTokenRole(args, index); + return ShouldPreserveQueryCommandToken(args, index, role); + } + + private static bool ShouldPreserveQueryCommandToken(string commandName, string[] subArgs, int index) + { + var role = GetQueryCommandTokenRole(commandName, subArgs, index); + return ShouldPreserveQueryCommandToken(subArgs, index, role); + } + + private static bool ShouldPreserveQueryCommandToken(string[] args, int index, QueryCommandTokenRole role) + { + if (role == QueryCommandTokenRole.CommandOptionValue) + return true; + if (role != QueryCommandTokenRole.FirstQueryLiteral) + return false; + return !IsSeparatedNonLogGlobalValueOptionWithConsumableValue(args, index); + } + + private static bool IsSeparatedNonLogGlobalValueOptionWithConsumableValue(string[] args, int index) + { + if (index + 1 >= args.Length) + return false; + + var value = args[index + 1]; + return args[index] switch + { + "--color" => ConsoleUi.TryParseColorMode(value, out _), + "--palette" => ConsoleUi.TryParseColorPalette(value, out _), + "--metrics" => !string.IsNullOrWhiteSpace(value) && !value.StartsWith("-", StringComparison.Ordinal), + "--trace" => value is "none" or "stderr" or "file", + _ => false, + }; + } + + private static QueryCommandTokenRole GetQueryCommandTokenRole(string[] args, int index) + { + if (!TryFindCommandBefore(args, index, out var commandIndex, out var commandName)) + return QueryCommandTokenRole.None; + + return GetQueryCommandTokenRole(commandName, args[(commandIndex + 1)..], index - commandIndex - 1); + } + + private static bool TryFindCommandBefore(string[] args, int index, out int commandIndex, out string commandName) + { + commandIndex = -1; + commandName = string.Empty; + + for (var i = 0; i < index; i++) + { + var arg = args[i]; + if (arg == "--") + return false; + if (TryGetInlineOptionName(arg, out var inlineName) && TopLevelValueOptionNames.Contains(inlineName)) + continue; + if (TopLevelValueOptionNames.Contains(arg)) + { + i++; + continue; + } + if (NonLogGlobalOptionNames.Contains(arg)) + continue; + if (!CliFlagSchema.AllCommands.Contains(arg)) + return false; + + commandIndex = i; + commandName = arg; + return true; + } + + return false; + } + + private static QueryCommandTokenRole GetQueryCommandTokenRole(string commandName, string[] subArgs, int targetIndex) + { + var (withValues, flagOnly) = CliFlagSchema.GetParserFlagsPartitionedByValueBearing(commandName); + if (targetIndex > 0) + { + var previousArg = NormalizeCommandOptionToken(subArgs[targetIndex - 1], withValues, flagOnly, out var previousHasInlineValue); + if (!previousHasInlineValue && withValues.Contains(previousArg)) + return QueryCommandTokenRole.CommandOptionValue; + } + + if (!CommandAcceptsQueryLiteral(commandName)) + return QueryCommandTokenRole.None; + + if (IsInspectPathLineMode(commandName, subArgs)) + { + var targetArg = NormalizeCommandOptionToken(subArgs[targetIndex], withValues, flagOnly, out _); + if (withValues.Contains(targetArg) || flagOnly.Contains(targetArg)) + return QueryCommandTokenRole.None; + } + + for (var i = 0; i < targetIndex; i++) + { + var arg = subArgs[i]; + if (arg == "--") + return i + 1 == targetIndex ? QueryCommandTokenRole.FirstQueryLiteral : QueryCommandTokenRole.None; + + var normalizedArg = NormalizeCommandOptionToken(arg, withValues, flagOnly, out var hasInlineValue); + if (withValues.Contains(normalizedArg)) + { + if (hasInlineValue) + { + if (normalizedArg == "--query") + return QueryCommandTokenRole.None; + continue; + } + if (i + 1 == targetIndex) + return QueryCommandTokenRole.CommandOptionValue; + if (normalizedArg == "--query") + return QueryCommandTokenRole.None; + if (i + 1 < targetIndex) + { + i++; + continue; + } + + return QueryCommandTokenRole.None; + } + + if (flagOnly.Contains(normalizedArg)) + continue; + + return QueryCommandTokenRole.None; + } + + return QueryCommandTokenRole.FirstQueryLiteral; + } + + private static bool IsInspectPathLineMode(string commandName, string[] subArgs) + { + if (!string.Equals(commandName, "inspect", StringComparison.Ordinal)) + return false; + + var (withValues, flagOnly) = CliFlagSchema.GetParserFlagsPartitionedByValueBearing(commandName); + var pathSeen = false; + var lineSeen = false; + for (var i = 0; i < subArgs.Length; i++) + { + var arg = subArgs[i]; + if (arg == "--") + break; + + var normalizedArg = NormalizeCommandOptionToken(arg, withValues, flagOnly, out var hasInlineValue); + if (!withValues.Contains(normalizedArg)) + continue; + + pathSeen |= normalizedArg == "--path"; + lineSeen |= normalizedArg == "--line"; + if (!hasInlineValue && i + 1 < subArgs.Length) + i++; + } + + return pathSeen && lineSeen; + } + + private static bool CommandAcceptsQueryLiteral(string commandName) => + CliFlagSchema.GetAcceptedFlagNamesForCommand(commandName).Contains("--query"); + + private static bool IsNonLogGlobalOptionToken(string arg) + { + if (NonLogGlobalOptionNames.Contains(arg)) + return true; + return TryGetInlineOptionName(arg, out var name) && NonLogGlobalOptionNames.Contains(name); + } + + private static string NormalizeCommandOptionToken( + string arg, + IReadOnlySet withValues, + IReadOnlySet flagOnly, + out bool hasInlineValue) + { + hasInlineValue = false; + if (!TryGetInlineOptionName(arg, out var name)) + return arg; + + if (withValues.Contains(name)) + { + hasInlineValue = true; + return name; + } + + if (flagOnly.Contains(name) && string.Equals(name, "--json", StringComparison.Ordinal)) + return name; + + return arg; + } + + private static bool TryGetInlineOptionName(string arg, out string name) + { + var equalsIndex = arg.IndexOf('='); + if (equalsIndex <= 0) + { + name = string.Empty; + return false; + } + + name = arg[..equalsIndex]; + return name.StartsWith("-", StringComparison.Ordinal); + } + + internal static bool TryConsumeQuietFlag(ref string[] args) + { + if (args.Length == 0) + return false; + + var kept = new List(args.Length); + var quiet = false; + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (arg is "--quiet" or "-q" or "--silent" + && GetQueryCommandTokenRole(args, i) != QueryCommandTokenRole.CommandOptionValue) + { + quiet = true; + continue; + } + if (ShouldPreserveQueryCommandToken(args, i)) + { + kept.Add(arg); + continue; + } + + kept.Add(arg); + } + + args = kept.ToArray(); + return quiet; + } + + internal static bool TryConsumePrettyJsonFlag(ref string[] args) + { + if (args.Length == 0) + return false; + + var hasExplicitPrettyJsonOutput = HasExplicitPrettyJsonOutputSelection(args); + var kept = new List(args.Length); + var pretty = false; + var passthrough = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (arg == "--pretty" + && GetQueryCommandTokenRole(args, i) != QueryCommandTokenRole.CommandOptionValue + && hasExplicitPrettyJsonOutput) + { + pretty = true; + continue; + } + if (ShouldPreserveQueryCommandToken(args, i)) + { + kept.Add(arg); + continue; + } + if (arg == "--pretty") + { + pretty = true; + continue; + } + + kept.Add(arg); + } + + args = kept.ToArray(); + return pretty; + } + + internal static bool TryConsumeGlobalLogFlags( + ref string[] args, + out IReadOnlyDictionary environment, + out string error) + { + var overrides = new Dictionary(StringComparer.Ordinal); + environment = overrides; + error = string.Empty; + var kept = new List(args.Length); + var passthrough = false; + var searchCommandSeen = false; + var searchQuerySeen = false; + var pendingSearchOptionValue = false; + var pendingSearchOptionValueIsQuery = false; + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (passthrough) + { + kept.Add(arg); + continue; + } + + if (searchCommandSeen && pendingSearchOptionValue) + { + if (pendingSearchOptionValueIsQuery) + searchQuerySeen = true; + pendingSearchOptionValue = false; + pendingSearchOptionValueIsQuery = false; + kept.Add(arg); + continue; + } + + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + + if (searchCommandSeen && !searchQuerySeen && IsSearchGlobalLogFlagLiteral(args, i, arg)) + { + searchQuerySeen = true; + kept.Add(arg); + continue; + } + + if (TryConsumeValueFlag(args, ref i, arg, "--log-format", out var format)) + { + if (format is not ("text" or "json")) + { + error = "--log-format must be `text` or `json`."; + return false; + } + overrides[GlobalToolLog.LogFormatEnvironmentVariable] = format; + continue; + } + + if (TryConsumeValueFlag(args, ref i, arg, "--log-retain-count", out var retainCount)) + { + if (!int.TryParse(retainCount, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed < 1) + { + error = "--log-retain-count must be a positive integer."; + return false; + } + overrides[GlobalToolLog.LogRetainEnvironmentVariable] = parsed.ToString(CultureInfo.InvariantCulture); + continue; + } + + if (TryConsumeValueFlag(args, ref i, arg, "--log-max-size-mb", out var maxSizeMb)) + { + if (!int.TryParse(maxSizeMb, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + || parsed is < 1 or > GlobalToolLog.MaxLogSizeMb) + { + error = $"--log-max-size-mb must be an integer between 1 and {GlobalToolLog.MaxLogSizeMb}."; + return false; + } + overrides[GlobalToolLog.LogMaxSizeMbEnvironmentVariable] = parsed.ToString(CultureInfo.InvariantCulture); + continue; + } + + if (arg == "search") + { + searchCommandSeen = true; + kept.Add(arg); + continue; + } + kept.Add(arg); + if (searchCommandSeen && !searchQuerySeen) + TrackSearchQueryState(args, i, arg, ref searchQuerySeen, ref pendingSearchOptionValue, ref pendingSearchOptionValueIsQuery); + } + + args = kept.ToArray(); + return true; + } + + private static bool IsSearchGlobalLogFlagLiteral(string[] args, int index, string arg) + { + static bool NextTokenLooksLikeSearchOption(string[] args, int index) + => index + 1 >= args.Length || args[index + 1].StartsWith("-", StringComparison.Ordinal); + + if (arg is "--log-format" or "--log-retain-count" or "--log-max-size-mb") + return NextTokenLooksLikeSearchOption(args, index); + + return (arg.StartsWith("--log-format=", StringComparison.Ordinal) || + arg.StartsWith("--log-retain-count=", StringComparison.Ordinal) || + arg.StartsWith("--log-max-size-mb=", StringComparison.Ordinal)) && + NextTokenLooksLikeSearchOption(args, index); + } + + private static void TrackSearchQueryState( + string[] args, + int index, + string arg, + ref bool searchQuerySeen, + ref bool pendingSearchOptionValue, + ref bool pendingSearchOptionValueIsQuery) + { + if (TryClassifySearchValueTakingOption(arg, out var hasInlineValue, out var valueIsQuery)) + { + if (hasInlineValue) + { + if (valueIsQuery) + searchQuerySeen = true; + } + else if (index + 1 < args.Length) + { + pendingSearchOptionValue = true; + pendingSearchOptionValueIsQuery = valueIsQuery; + } + return; + } + + if (!arg.StartsWith("-", StringComparison.Ordinal)) + searchQuerySeen = true; + } + + private static bool TryClassifySearchValueTakingOption(string arg, out bool hasInlineValue, out bool valueIsQuery) + { + hasInlineValue = false; + valueIsQuery = false; + + var separator = arg.IndexOf('='); + var optionName = separator > 0 ? arg[..separator] : arg; + if (!SearchValueTakingOptions.Contains(optionName)) + return false; + + hasInlineValue = separator > 0; + valueIsQuery = optionName == "--query"; + return true; + } + + private static readonly HashSet SearchValueTakingOptions = + [ + "--db", + "--color", + "--data-dir", + "--metrics", + "--palette", + "--trace", + "--limit", + "--top", + "--lang", + "--kind", + "--visibility", + "--exclude-visibility", + "--since", + "--start", + "--end", + "--before", + "--after", + "--name", + "--snippet-lines", + "--snippet-focus", + "--path", + "--require-before", + "--require-after", + "--reject-before", + "--reject-after", + "--guard-window", + "--guard-scope", + "--project", + "--solution", + "--exclude-path", + "--max-hops", + "--depth", + "--query", + "--group-by", + "--focus-line", + "--focus-column", + "--focus-length", + "--max-line-width", + "--stale-after", + "--explain", + "--rank-by", + "--slow-query-ms", + "--format", + "--min-entrypoint-confidence", + "--sections", + ]; + + private static bool TryConsumeValueFlag(string[] args, ref int index, string arg, string flag, out string value) + { + value = string.Empty; + if (arg.StartsWith(flag + "=", StringComparison.Ordinal)) + { + value = arg[(flag.Length + 1)..].Trim(); + return true; + } + + if (arg != flag) + return false; + + if (index + 1 >= args.Length) + return true; + + value = args[++index].Trim(); + return true; + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.TestExtractor.cs b/src/CodeIndex/Cli/ProgramRunner.TestExtractor.cs new file mode 100644 index 000000000..b539c9410 --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.TestExtractor.cs @@ -0,0 +1,238 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + private static int RunTestExtractor(string[] args, JsonSerializerOptions jsonOptions) + { + string? language = null; + string? file = null; + string? expect = null; + var json = args.Contains("--json", StringComparer.Ordinal); + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (TryConsumeInlineOrNext(args, ref i, arg, "--language", out var value)) + language = value; + else if (TryConsumeInlineOrNext(args, ref i, arg, "--file", out value)) + file = value; + else if (TryConsumeInlineOrNext(args, ref i, arg, "--expect-symbols", out value) || TryConsumeInlineOrNext(args, ref i, arg, "--expect", out value)) + expect = value; + else if (arg == "--json") + continue; + else + return WriteTestExtractorError(json, jsonOptions, $"Unknown test-extractor argument: {arg}", CommandExitCodes.InvalidArgument, "use --language --file [--expect-symbols ] [--json]."); + } + + if (string.IsNullOrWhiteSpace(language) || string.IsNullOrWhiteSpace(file)) + return WriteTestExtractorError(json, jsonOptions, "test-extractor requires --language and --file.", CommandExitCodes.InvalidArgument, "use --language --file [--expect-symbols ] [--json]."); + if (!TryReadTestExtractorFile(file, "source", json, jsonOptions, out var source, out var readExitCode)) + return readExitCode; + + var symbols = Indexer.SymbolExtractor.Extract(1, language, source, file); + if (expect != null) + { + if (!TryReadTestExtractorFile(expect, "expected symbols", json, jsonOptions, out var expected, out readExitCode)) + return readExitCode; + var actual = JsonSerializer.Serialize(symbols); + if (!TryJsonEquivalent(expected, actual, out var jsonError)) + { + if (jsonError is not null) + { + return WriteTestExtractorError( + json, + jsonOptions, + $"test-extractor expected or actual symbols JSON could not be parsed within the {TestExtractorJsonComparisonMaxBytes} byte and {TestExtractorJsonComparisonMaxDepth} depth limits: {jsonError.Message}", + CommandExitCodes.InvalidArgument, + "Use a smaller or shallower expected-symbols JSON fixture."); + } + + if (json) + { + return WriteTestExtractorError( + true, + jsonOptions, + "Expected symbols did not match extracted symbols.", + CommandExitCodes.InvalidArgument, + "Update the expected-symbols fixture or inspect the extracted symbols without --expect-symbols."); + } + CommandErrorWriter.WriteStderr("Expected symbols did not match extracted symbols."); + CommandErrorWriter.WriteStderr(actual); + return CommandExitCodes.InvalidArgument; + } + } + + if (json || expect == null) + { + var result = new TestExtractorJsonResult(JsonSerializer.SerializeToElement(symbols)); + CommandOutputWriter.WriteJson( + result, + CliJsonSerializerContextFactory.Create(jsonOptions).TestExtractorJsonResult); + } + return CommandExitCodes.Success; + } + + private static bool TryReadTestExtractorFile( + string path, + string role, + bool json, + JsonSerializerOptions jsonOptions, + out string content, + out int exitCode) + { + content = string.Empty; + exitCode = CommandExitCodes.Success; + var displayRole = $"test-extractor {role} file"; + if (!File.Exists(LongPath.EnsureWindowsPrefix(path))) + { + exitCode = WriteTestExtractorError(json, jsonOptions, $"{displayRole} not found: {path}", CommandExitCodes.NotFound); + return false; + } + + try + { + using var stream = BoundedFile.OpenReadForLengthCheckedText(path); + if (stream.Length > TestExtractorMaxInputBytes) + { + exitCode = WriteTestExtractorTooLargeError(json, jsonOptions, displayRole, stream.Length); + return false; + } + + TestExtractorFileLengthCheckedForTesting?.Invoke(path); + if (!TryReadTestExtractorStream(stream, displayRole, json, jsonOptions, out content, out exitCode)) + return false; + + return true; + } + catch (IOException ex) + { + exitCode = WriteTestExtractorError(json, jsonOptions, $"{displayRole} could not be read: {FormatSanitizedExceptionSummary(ex)}", CommandExitCodes.InvalidArgument); + return false; + } + catch (UnauthorizedAccessException ex) + { + exitCode = WriteTestExtractorError(json, jsonOptions, $"{displayRole} could not be read: {FormatSanitizedExceptionSummary(ex)}", CommandExitCodes.InvalidArgument); + return false; + } + } + + private static bool TryReadTestExtractorStream( + Stream stream, + string displayRole, + bool json, + JsonSerializerOptions jsonOptions, + out string content, + out int exitCode) + { + content = string.Empty; + exitCode = CommandExitCodes.Success; + using var buffer = new MemoryStream(capacity: (int)Math.Min(TestExtractorMaxInputBytes, Math.Max(0, stream.Length))); + var scratch = new byte[TestExtractorReadBufferBytes]; + long bytesRead = 0; + while (true) + { + var remainingBudget = TestExtractorMaxInputBytes + 1 - bytesRead; + if (remainingBudget <= 0) + { + exitCode = WriteTestExtractorTooLargeError(json, jsonOptions, displayRole, bytesRead); + return false; + } + + var read = stream.Read(scratch, 0, (int)Math.Min(scratch.Length, remainingBudget)); + if (read == 0) + break; + + bytesRead += read; + if (bytesRead > TestExtractorMaxInputBytes) + { + exitCode = WriteTestExtractorTooLargeError(json, jsonOptions, displayRole, bytesRead); + return false; + } + + buffer.Write(scratch, 0, read); + } + + buffer.Position = 0; + using var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + // The loop above rejects streams beyond TestExtractorMaxInputBytes before this materializes text. + content = reader.ReadToEnd(); + return true; + } + + private static int WriteTestExtractorTooLargeError( + bool json, + JsonSerializerOptions jsonOptions, + string displayRole, + long bytes) + => WriteTestExtractorError( + json, + jsonOptions, + $"{displayRole} is too large: {bytes} bytes exceeds the {TestExtractorMaxInputBytes} byte limit.", + CommandExitCodes.InvalidArgument, + "Use a smaller extractor fixture or expectation file."); + + private static int WriteTestExtractorError( + bool json, + JsonSerializerOptions jsonOptions, + string message, + int exitCode, + string? hint = null) + => CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, message, exitCode, hint); + + private static bool TryConsumeInlineOrNext(string[] args, ref int index, string arg, string flag, out string value) + { + value = string.Empty; + if (arg.StartsWith(flag + "=", StringComparison.Ordinal)) + { + value = arg[(flag.Length + 1)..]; + return true; + } + + if (arg != flag || index + 1 >= args.Length) + return false; + + value = args[++index]; + return true; + } + + private static bool TryJsonEquivalent(string expected, string actual, out Exception? error) + { + error = null; + try + { + using var expectedDoc = BoundedJson.ParseDocument( + expected, + TestExtractorJsonComparisonMaxBytes, + TestExtractorJsonComparisonMaxDepth); + using var actualDoc = BoundedJson.ParseDocument( + actual, + TestExtractorJsonComparisonMaxBytes, + TestExtractorJsonComparisonMaxDepth); + return JsonSerializer.Serialize(expectedDoc.RootElement) == JsonSerializer.Serialize(actualDoc.RootElement); + } + catch (Exception ex) when (ex is JsonException or InvalidDataException) + { + error = ex; + return false; + } + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.Upgrade.cs b/src/CodeIndex/Cli/ProgramRunner.Upgrade.cs new file mode 100644 index 000000000..2f2793fa9 --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.Upgrade.cs @@ -0,0 +1,387 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + internal static int RunCheckUpdates( + string[] cmdArgs, + JsonSerializerOptions jsonOptions, + string appVersion, + CancellationToken cancellationToken = default) + { + var wantsJson = cmdArgs.Contains("--json", StringComparer.Ordinal); + foreach (var arg in cmdArgs) + { + if (arg == "--json") + continue; + return CommandErrorWriter.WriteJsonOrHuman( + wantsJson, + jsonOptions, + $"--check-updates does not accept '{arg}'.", + CommandExitCodes.UsageError, + "use `cdidx --check-updates` or `cdidx --check-updates --json`."); + } + + var result = UpdateChecker.Check(appVersion, cancellationToken); + if (wantsJson) + { + CommandOutputWriter.WriteJson( + result, + CliJsonSerializerContextFactory.Create(jsonOptions).UpdateCheckResult); + return CommandExitCodes.Success; + } + + if (result.UpdateAvailable && result.LatestVersion != null) + Console.WriteLine($"A newer cdidx release is available: {result.LatestVersion} (current: {result.CurrentVersion})."); + else if (result.Error != null) + Console.WriteLine($"Could not check for updates; using cached release metadata if available (current: {result.CurrentVersion})."); + else + Console.WriteLine($"cdidx is up to date (current: {result.CurrentVersion})."); + return CommandExitCodes.Success; + } + + internal static int RunUpgrade( + string[] cmdArgs, + JsonSerializerOptions jsonOptions, + string appVersion, + CancellationToken cancellationToken = default) + { + var checkOnly = false; + var wantsJson = cmdArgs.Contains("--json", StringComparer.Ordinal); + var selectedChannel = "stable"; + var includePrerelease = false; + var selectionSource = "latest"; + string? explicitVersion = null; + for (var i = 0; i < cmdArgs.Length; i++) + { + var arg = cmdArgs[i]; + if (arg is "--check-only" or "--check-updates") + { + checkOnly = true; + continue; + } + if (arg == "--json") + { + continue; + } + if (arg == "--prerelease") + { + selectedChannel = "prerelease"; + includePrerelease = true; + selectionSource = "prerelease"; + continue; + } + if (arg == "--channel") + { + if (i + 1 >= cmdArgs.Length) + return WriteUpgradeUsageError("--channel requires a value: stable, latest, or prerelease.", wantsJson, jsonOptions); + + if (!TryApplyUpgradeChannel(cmdArgs[++i], out selectedChannel, out includePrerelease, out var channelError)) + return WriteUpgradeUsageError(channelError, wantsJson, jsonOptions); + + selectionSource = selectedChannel; + continue; + } + if (arg.StartsWith("--channel=", StringComparison.Ordinal)) + { + if (!TryApplyUpgradeChannel(arg["--channel=".Length..], out selectedChannel, out includePrerelease, out var channelError)) + return WriteUpgradeUsageError(channelError, wantsJson, jsonOptions); + + selectionSource = selectedChannel; + continue; + } + if (arg == "--version") + { + if (i + 1 >= cmdArgs.Length) + return WriteUpgradeUsageError("--version requires a release tag such as v1.29.0.", wantsJson, jsonOptions); + + if (!TryNormalizeReleaseTag(cmdArgs[++i], out explicitVersion, out var versionError)) + return WriteUpgradeUsageError(versionError, wantsJson, jsonOptions); + + selectionSource = "explicit_version"; + continue; + } + if (arg.StartsWith("--version=", StringComparison.Ordinal)) + { + if (!TryNormalizeReleaseTag(arg["--version=".Length..], out explicitVersion, out var versionError)) + return WriteUpgradeUsageError(versionError, wantsJson, jsonOptions); + + selectionSource = "explicit_version"; + continue; + } + return WriteUpgradeUsageError($"upgrade does not accept '{arg}'.", wantsJson, jsonOptions); + } + + if (!TryGetUpgradeVerificationPolicy(out var verificationPolicy, out var verificationPolicyError)) + return WriteUpgradeUsageError(verificationPolicyError, wantsJson, jsonOptions); + + if (explicitVersion != null && IsPrereleaseTag(explicitVersion) && selectedChannel == "stable") + { + selectedChannel = "prerelease"; + includePrerelease = true; + } + + var result = explicitVersion != null + ? new UpdateCheckResult( + appVersion, + explicitVersion, + UpdateChecker.IsNewerRelease(explicitVersion, appVersion), + FromCache: false, + Error: null) + : includePrerelease + ? CheckLatestPrerelease(appVersion, cancellationToken) + : UpdateChecker.Check(appVersion, cancellationToken); + + var shouldInstall = result.LatestVersion != null && (explicitVersion != null || result.UpdateAvailable); + if (checkOnly || !shouldInstall) + { + var metadataFailureExitCode = result.Error is null + ? CommandExitCodes.Success + : CommandExitCodes.RuntimeError; + if (wantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult( + result, + selectedChannel, + selectionSource, + includePrerelease, + verificationPolicy, + installAttempted: false, + installExitCode: null, + error: null), + jsonOptions)); + } + else if (result.UpdateAvailable && result.LatestVersion != null) + Console.WriteLine($"A newer cdidx {selectedChannel} release is available: {result.LatestVersion} (current: {result.CurrentVersion})."); + else if (result.Error != null) + Console.WriteLine($"Could not select a cdidx {selectedChannel} release ({result.Error}); current: {result.CurrentVersion}."); + else + Console.WriteLine($"cdidx is up to date (current: {result.CurrentVersion})."); + return metadataFailureExitCode; + } + + var selectedReleaseTag = result.LatestVersion!; + bool? manifestProvenanceVerified = null; + bool? installerProvenanceVerified = null; + + if (OperatingSystem.IsWindows()) + { + var handoff = CreateWindowsUpgradeHandoff(selectedReleaseTag, RuntimeInformation.ProcessArchitecture); + if (wantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult( + result, + selectedChannel, + selectionSource, + includePrerelease, + verificationPolicy, + installAttempted: false, + installExitCode: null, + error: "windows_handoff_required", + handoff: handoff), + jsonOptions)); + } + else + { + CommandErrorWriter.WriteStderr("Error: cdidx upgrade cannot replace the running Windows binary directly."); + CommandErrorWriter.WriteStderr($"Hint: update via NuGet global tool: {handoff.Command}"); + CommandErrorWriter.WriteStderr($"Release page: {handoff.Url}"); + CommandErrorWriter.WriteStderr($"Manual zip asset: {handoff.Asset} ({handoff.AssetUrl})"); + } + return CommandExitCodes.FeatureUnavailable; + } + + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + if (wantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult( + result, + selectedChannel, + selectionSource, + includePrerelease, + verificationPolicy, + installAttempted: false, + installExitCode: null, + error: "unsupported_platform"), + jsonOptions)); + } + else + { + CommandErrorWriter.WriteStderr("Error: cdidx upgrade currently requires a POSIX shell installer on Linux or macOS."); + CommandErrorWriter.WriteStderr("Hint: download the latest release asset manually, or rerun install.sh from a shell environment."); + } + return CommandExitCodes.FeatureUnavailable; + } + + var installDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!TryCheckInstallDirectoryWritable(installDir, out var installDirectoryError)) + { + if (wantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult( + result, + selectedChannel, + selectionSource, + includePrerelease, + verificationPolicy, + installAttempted: false, + installExitCode: null, + error: "install_directory_not_writable", + installDirectoryError: installDirectoryError), + jsonOptions)); + } + else + { + CommandErrorWriter.WriteStderr($"Error: install directory is not writable: {installDir}"); + if (installDirectoryError != null) + CommandErrorWriter.WriteStderr($"Reason: {installDirectoryError}"); + WriteUpgradeInstallerTrustDiagnostic(); + CommandErrorWriter.WriteStderr("Hint: rerun with permissions that can write this directory, or reinstall cdidx into a per-user directory."); + } + return CommandExitCodes.UsageError; + } + + string? scriptDirectory = null; + string? scriptPath = null; + string? checksumManifestPath = null; + try + { + scriptDirectory = DataDirectorySecurity.CreateSensitiveTempDirectory(UpgradeInstallerDirectoryPrefix).FullName; + scriptPath = Path.Combine(scriptDirectory, "install.sh"); + checksumManifestPath = Path.Combine(scriptDirectory, ReleaseChecksumAssetName); + using (var client = UpgradeHttpClientFactory()) + { + DownloadReleaseChecksumManifestToFileAsync( + client, + selectedReleaseTag, + checksumManifestPath, + TimeSpan.FromSeconds(20), + cancellationToken) + .GetAwaiter() + .GetResult(); + RequireUpgradeAssetProvenance( + checksumManifestPath, + ReleaseChecksumAssetName, + selectedReleaseTag, + verificationPolicy, + wantsJson, + cancellationToken, + out manifestProvenanceVerified); + var checksumManifest = File.ReadAllText(checksumManifestPath, Encoding.UTF8); + var expectedInstallerSha256 = GetReleaseAssetChecksum(checksumManifest, InstallerScriptAssetName); + + DownloadInstallerScriptAsync( + client, + selectedReleaseTag, + scriptPath, + TimeSpan.FromSeconds(20), + cancellationToken) + .GetAwaiter() + .GetResult(); + RequireUpgradeAssetProvenance( + scriptPath, + InstallerScriptAssetName, + selectedReleaseTag, + verificationPolicy, + wantsJson, + cancellationToken, + out installerProvenanceVerified); + if (!wantsJson) + CommandErrorWriter.WriteStderr($"Verifying {InstallerScriptAssetName} checksum..."); + VerifyFileSha256(scriptPath, expectedInstallerSha256, InstallerScriptAssetName, cancellationToken); + if (!wantsJson) + CommandErrorWriter.WriteStderr($"Verified {InstallerScriptAssetName} checksum."); + } + + var startInfo = CreateInstallerProcessStartInfo(scriptPath, selectedReleaseTag, installDir); + var installerResult = RunInstallerProcessDetailed( + startInfo, + InstallerRunTimeout, + cancellationToken, + suppressOutput: wantsJson); + var installExitCode = installerResult.ExitCode; + if (wantsJson) + { + var error = installExitCode == CommandExitCodes.Success + ? null + : $"installer_exit_code_{installExitCode.ToString(CultureInfo.InvariantCulture)}"; + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult( + result, + selectedChannel, + selectionSource, + includePrerelease, + verificationPolicy, + installAttempted: true, + installExitCode: installExitCode, + error: error, + installerResult: installerResult, + manifestProvenanceVerified: manifestProvenanceVerified, + installerProvenanceVerified: installerProvenanceVerified), + jsonOptions)); + } + return installExitCode; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + if (wantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult( + result, + selectedChannel, + selectionSource, + includePrerelease, + verificationPolicy, + installAttempted: false, + installExitCode: null, + error: ex.GetType().Name, + manifestProvenanceVerified: manifestProvenanceVerified, + installerProvenanceVerified: installerProvenanceVerified), + jsonOptions)); + } + else + { + CommandErrorWriter.WriteStderr($"Error: upgrade failed before install.sh completed ({FormatSanitizedExceptionSummary(ex)})."); + WriteUpgradeInstallerTrustDiagnostic(); + CommandErrorWriter.WriteStderr("Hint: rerun `install.sh` manually for the desired release."); + } + return CommandExitCodes.InstallError; + } + finally + { + if (scriptPath != null) + TryDeleteUpgradeInstallerScript(scriptPath); + if (scriptDirectory != null) + TryDeleteUpgradeInstallerDirectory(scriptDirectory); + } + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.UpgradeDownloads.cs b/src/CodeIndex/Cli/ProgramRunner.UpgradeDownloads.cs new file mode 100644 index 000000000..6158a11a2 --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.UpgradeDownloads.cs @@ -0,0 +1,259 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + private static UpgradeJsonResult CreateUpgradeJsonResult( + UpdateCheckResult result, + string selectedChannel, + string selectionSource, + bool includePrerelease, + string verificationPolicy, + bool installAttempted, + int? installExitCode, + string? error, + UpgradeHandoff? handoff = null, + InstallerProcessResult? installerResult = null, + string? installDirectoryError = null, + bool? manifestProvenanceVerified = null, + bool? installerProvenanceVerified = null) + => new( + result.CurrentVersion, + result.LatestVersion, + result.UpdateAvailable, + result.FromCache, + result.LatestVersion, + selectedChannel, + selectionSource, + includePrerelease, + error ?? result.Error, + error is null ? result.ErrorCategory : null, + error is null ? result.ErrorHint : null, + installAttempted, + installExitCode, + installExitCode is null ? null : installExitCode == CommandExitCodes.Success, + handoff?.Command, + handoff?.Url, + handoff?.Asset, + handoff?.AssetUrl, + result.LatestVersion is null ? null : UpgradeInstallerVerification, + result.LatestVersion is null ? null : UpgradeInstallerTrustBoundary, + installerResult is { ExitCode: not CommandExitCodes.Success } ? installerResult.StdoutTail : null, + installerResult is { ExitCode: not CommandExitCodes.Success } ? installerResult.StderrTail : null, + installerResult is { ExitCode: not CommandExitCodes.Success } ? installerResult.OutputTruncated : null, + installDirectoryError, + result.LatestVersion is null ? null : verificationPolicy, + manifestProvenanceVerified, + installerProvenanceVerified, + GetUpgradeVerificationStatus( + result.LatestVersion, + verificationPolicy, + manifestProvenanceVerified, + installerProvenanceVerified), + string.Equals(verificationPolicy, "compat", StringComparison.Ordinal) + && (manifestProvenanceVerified == false || installerProvenanceVerified == false) + ? "compat_provenance_bypass" + : null); + + private static string? GetUpgradeVerificationStatus( + string? selectedVersion, + string verificationPolicy, + bool? manifestProvenanceVerified, + bool? installerProvenanceVerified) + { + if (selectedVersion is null) + return null; + if (manifestProvenanceVerified == true && installerProvenanceVerified == true) + return "verified"; + if (manifestProvenanceVerified == false || installerProvenanceVerified == false) + return string.Equals(verificationPolicy, "compat", StringComparison.Ordinal) + ? "compat_bypass" + : "verification_failed"; + return "not_attempted"; + } + + internal static string BuildReleasePageUrl(string releaseTag) + => string.Format( + CultureInfo.InvariantCulture, + ReleasePageUrlTemplate, + Uri.EscapeDataString(releaseTag.Trim())); + + internal static string BuildInstallerScriptUrl(string releaseTag) + => BuildReleaseAssetUrl(releaseTag, InstallerScriptAssetName); + + internal static string BuildReleaseAssetUrl(string releaseTag, string assetName) + => string.Format( + CultureInfo.InvariantCulture, + ReleaseAssetUrlTemplate, + Uri.EscapeDataString(releaseTag.Trim()), + Uri.EscapeDataString(assetName)); + + private static HttpClient CreateUpgradeHttpClient() + => GitHubHttpClientFactory.CreateReleaseDownloadHttpClient(TimeSpan.FromSeconds(20)); + + internal static async Task DownloadReleaseChecksumManifestAsync( + HttpClient client, + string releaseTag, + TimeSpan timeout, + CancellationToken cancellationToken) + { + using var downloadScope = OperationTimeoutScope.Create( + OperationTimeoutCategories.UpgradeDownload, + timeout, + cancellationToken); + using var response = await GitHubHttpClientFactory.SendWithRetryAsync( + client, + () => + { + var request = new HttpRequestMessage(HttpMethod.Get, BuildReleaseAssetUrl(releaseTag, ReleaseChecksumAssetName)); + GitHubHttpClientFactory.ApplyReleaseDownloadHeaders(request.Headers); + return request; + }, + HttpCompletionOption.ResponseHeadersRead, + downloadScope.Token).ConfigureAwait(false); + await GitHubHttpClientFactory.EnsureSuccessStatusCodeWithBoundedDiagnosticsAsync( + response, + ReleaseChecksumAssetName, + downloadScope.Token).ConfigureAwait(false); + var bytes = await BoundedHttpContentReader.ReadAsByteArrayAsync( + response.Content, + MaxReleaseChecksumBytes, + downloadScope.Token).ConfigureAwait(false); + return Encoding.UTF8.GetString(bytes); + } + + internal static async Task DownloadReleaseChecksumManifestToFileAsync( + HttpClient client, + string releaseTag, + string manifestPath, + TimeSpan timeout, + CancellationToken cancellationToken) + { + using var downloadScope = OperationTimeoutScope.Create( + OperationTimeoutCategories.UpgradeDownload, + timeout, + cancellationToken); + using var response = await GitHubHttpClientFactory.SendWithRetryAsync( + client, + () => + { + var request = new HttpRequestMessage(HttpMethod.Get, BuildReleaseAssetUrl(releaseTag, ReleaseChecksumAssetName)); + GitHubHttpClientFactory.ApplyReleaseDownloadHeaders(request.Headers); + return request; + }, + HttpCompletionOption.ResponseHeadersRead, + downloadScope.Token).ConfigureAwait(false); + await GitHubHttpClientFactory.EnsureSuccessStatusCodeWithBoundedDiagnosticsAsync( + response, + ReleaseChecksumAssetName, + downloadScope.Token).ConfigureAwait(false); + await BoundedHttpContentReader.WriteToPrivateFileAsync( + response.Content, + manifestPath, + MaxReleaseChecksumBytes, + downloadScope.Token).ConfigureAwait(false); + } + + internal static string GetReleaseAssetChecksum(string checksumManifest, string assetName) + { + foreach (var rawLine in checksumManifest.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + if (line.Length < 66) + continue; + + var checksum = line[..64]; + if (!IsSha256Hex(checksum) || !char.IsWhiteSpace(line[64])) + continue; + + var fileName = line[65..].TrimStart(); + if (fileName.StartsWith('*')) + fileName = fileName[1..]; + if (string.Equals(fileName, assetName, StringComparison.Ordinal)) + return checksum.ToLowerInvariant(); + } + + throw new InvalidDataException($"Release checksum manifest does not contain {assetName}."); + } + + internal static void VerifyFileSha256( + string path, + string expectedSha256Hex, + string assetName, + CancellationToken cancellationToken = default) + { + if (!IsSha256Hex(expectedSha256Hex)) + throw new InvalidDataException($"Release checksum for {assetName} is not a valid SHA-256 digest."); + + using var stream = BoundedFile.OpenReadForHash(path); + var actual = Sha256StreamHasher.ComputeHex(stream, cancellationToken); + if (!string.Equals(actual, expectedSha256Hex, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException( + $"Downloaded {assetName} checksum mismatch: expected {expectedSha256Hex}, got {actual}."); + } + + private static bool IsSha256Hex(string value) + { + if (value.Length != 64) + return false; + + foreach (var ch in value) + { + if (!Uri.IsHexDigit(ch)) + return false; + } + + return true; + } + + internal static async Task DownloadInstallerScriptAsync( + HttpClient client, + string releaseTag, + string scriptPath, + TimeSpan timeout, + CancellationToken cancellationToken) + { + using var downloadScope = OperationTimeoutScope.Create( + OperationTimeoutCategories.UpgradeDownload, + timeout, + cancellationToken); + using var response = await GitHubHttpClientFactory.SendWithRetryAsync( + client, + () => + { + var request = new HttpRequestMessage(HttpMethod.Get, BuildInstallerScriptUrl(releaseTag)); + GitHubHttpClientFactory.ApplyReleaseDownloadHeaders(request.Headers); + return request; + }, + HttpCompletionOption.ResponseHeadersRead, + downloadScope.Token).ConfigureAwait(false); + await GitHubHttpClientFactory.EnsureSuccessStatusCodeWithBoundedDiagnosticsAsync( + response, + InstallerScriptAssetName, + downloadScope.Token).ConfigureAwait(false); + await BoundedHttpContentReader.WriteToPrivateFileAsync( + response.Content, + scriptPath, + MaxInstallerScriptBytes, + downloadScope.Token).ConfigureAwait(false); + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.UpgradeInstallDirectory.cs b/src/CodeIndex/Cli/ProgramRunner.UpgradeInstallDirectory.cs new file mode 100644 index 000000000..5ec235530 --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.UpgradeInstallDirectory.cs @@ -0,0 +1,198 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + internal static bool CanWriteDirectory(string directory) + => TryCheckInstallDirectoryWritable(directory, out _); + + internal static bool TryCheckInstallDirectoryWritable(string directory, out string? diagnostic) + { + diagnostic = null; + string? probe = null; + var createdProbe = false; + try + { + if (!TryResolveUpgradeInstallDirectory(directory, out var fullDirectory, out diagnostic)) + return false; + + Directory.CreateDirectory(fullDirectory); + if (!TryValidateExistingUpgradeInstallDirectory(fullDirectory, out diagnostic)) + return false; + + probe = Path.GetFullPath(Path.Combine(fullDirectory, $".cdidx-write-test-{Guid.NewGuid():N}")); + if (!IsPathEqualOrChildNoProbe(fullDirectory, probe) || string.Equals(fullDirectory, probe, InstallDirectoryPathComparison)) + { + diagnostic = "install directory write probe escaped the install directory."; + return false; + } + + FileWriteProbe.WriteEmptyFile(probe, Encoding.UTF8); + createdProbe = true; + return true; + } + catch (Exception ex) + { + diagnostic = CommandErrorWriter.FormatSanitizedException(ex); + return false; + } + finally + { + if (createdProbe && probe != null) + TryDeleteInstallDirectoryWriteProbe(probe); + } + } + + private static bool TryResolveUpgradeInstallDirectory(string directory, out string fullDirectory, out string? diagnostic) + { + fullDirectory = string.Empty; + diagnostic = null; + if (string.IsNullOrWhiteSpace(directory)) + { + diagnostic = "install directory is empty."; + return false; + } + + try + { + fullDirectory = NormalizeDirectoryBoundaryPath(Path.GetFullPath(directory)); + var root = Path.GetPathRoot(fullDirectory); + if (!string.IsNullOrEmpty(root) && string.Equals(fullDirectory, NormalizeDirectoryBoundaryPath(root), InstallDirectoryPathComparison)) + { + diagnostic = "install directory must not be the filesystem root."; + return false; + } + + return true; + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + diagnostic = CommandErrorWriter.FormatSanitizedException(ex); + return false; + } + } + + private static bool TryValidateExistingUpgradeInstallDirectory(string fullDirectory, out string? diagnostic) + { + diagnostic = null; + try + { + var directoryInfo = new DirectoryInfo(fullDirectory); + directoryInfo.Refresh(); + if (!directoryInfo.Exists) + { + diagnostic = "install directory does not exist after creation."; + return false; + } + + if ((directoryInfo.Attributes & FileAttributes.ReparsePoint) != 0 || !string.IsNullOrEmpty(directoryInfo.LinkTarget)) + { + diagnostic = "install directory must not be a symbolic link or reparse point."; + return false; + } + + if (!OperatingSystem.IsWindows()) + { + var mode = File.GetUnixFileMode(fullDirectory); + if ((mode & (UnixFileMode.GroupWrite | UnixFileMode.OtherWrite)) != 0) + { + diagnostic = "install directory must not be group- or world-writable."; + return false; + } + } + + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) + { + diagnostic = CommandErrorWriter.FormatSanitizedException(ex); + return false; + } + } + + private static string NormalizeDirectoryBoundaryPath(string path) + { + var fullPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(fullPath); + if (!string.IsNullOrEmpty(root) && string.Equals(fullPath, root, InstallDirectoryPathComparison)) + return fullPath; + return fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + private static StringComparison InstallDirectoryPathComparison + => OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + private static bool IsPathEqualOrChildNoProbe(string normalizedParent, string normalizedChild) + { + if (string.Equals(normalizedParent, normalizedChild, InstallDirectoryPathComparison)) + return true; + + var trimmedParent = normalizedParent.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return normalizedChild.StartsWith(trimmedParent + Path.DirectorySeparatorChar, InstallDirectoryPathComparison) + || normalizedChild.StartsWith(trimmedParent + Path.AltDirectorySeparatorChar, InstallDirectoryPathComparison); + } + + private static void TryDeleteInstallDirectoryWriteProbe(string probePath) + { + try + { + if (!File.Exists(probePath)) + return; + + if (DeleteInstallDirectoryWriteProbeForTesting != null) + DeleteInstallDirectoryWriteProbeForTesting(probePath); + else + File.Delete(probePath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + CommandErrorWriter.WriteStderr($"Warning: failed to delete install directory write probe {ConsoleUi.FormatBoundedValue(probePath)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + } + } + + private static int ToWaitMilliseconds(TimeSpan timeout) + { + if (timeout <= TimeSpan.Zero) + return 1; + if (timeout.TotalMilliseconds >= int.MaxValue) + return int.MaxValue; + return Math.Max(1, (int)Math.Ceiling(timeout.TotalMilliseconds)); + } + + private static string FormatDuration(TimeSpan timeout) + => timeout.TotalSeconds.ToString("0.###", CultureInfo.InvariantCulture) + "s"; + + private static void TryKillProcessTree(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + // Best-effort cleanup only; callers receive the timeout diagnostic. + } + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.UpgradeOptions.cs b/src/CodeIndex/Cli/ProgramRunner.UpgradeOptions.cs new file mode 100644 index 000000000..4d4c1fe9c --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.UpgradeOptions.cs @@ -0,0 +1,177 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + private static int WriteUpgradeUsageError( + string message, + bool wantsJson, + JsonSerializerOptions jsonOptions) + => CommandErrorWriter.WriteJsonOrHuman( + wantsJson, + jsonOptions, + message, + CommandExitCodes.UsageError, + "use `cdidx upgrade [--check-only] [--channel stable|latest|prerelease] [--prerelease] [--version vX.Y.Z]`."); + + private static bool TryApplyUpgradeChannel( + string rawChannel, + out string selectedChannel, + out bool includePrerelease, + out string error) + { + selectedChannel = "stable"; + includePrerelease = false; + error = string.Empty; + + switch (rawChannel.Trim().ToLowerInvariant()) + { + case "stable": + selectedChannel = "stable"; + return true; + case "latest": + selectedChannel = "latest"; + return true; + case "prerelease": + case "preview": + selectedChannel = "prerelease"; + includePrerelease = true; + return true; + default: + error = $"unsupported upgrade channel '{rawChannel}'."; + return false; + } + } + + private static bool TryNormalizeReleaseTag(string rawVersion, out string? normalizedVersion, out string error) + { + normalizedVersion = null; + error = string.Empty; + + var trimmed = rawVersion.Trim(); + if (trimmed.Length == 0) + { + error = "--version requires a non-empty release tag."; + return false; + } + + normalizedVersion = trimmed[0] is 'v' or 'V' + ? "v" + trimmed[1..] + : "v" + trimmed; + if (!IsValidUpgradeReleaseTag(normalizedVersion)) + { + error = "--version must be a release tag shaped like vX.Y.Z or vX.Y.Z-prerelease."; + normalizedVersion = null; + return false; + } + + return true; + } + + internal static bool IsValidUpgradeReleaseTag(string releaseTag) + { + if (string.IsNullOrWhiteSpace(releaseTag) || releaseTag[0] != 'v') + return false; + + var rest = releaseTag[1..]; + var prereleaseStart = rest.IndexOf('-'); + var core = prereleaseStart >= 0 ? rest[..prereleaseStart] : rest; + var prerelease = prereleaseStart >= 0 ? rest[(prereleaseStart + 1)..] : null; + var parts = core.Split('.'); + if (parts.Length != 3 || parts.Any(part => part.Length == 0 || !part.All(char.IsDigit))) + return false; + + if (prerelease == null) + return true; + + var identifiers = prerelease.Split('.'); + return identifiers.Length > 0 + && identifiers.All(identifier => + identifier.Length > 0 + && identifier.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '-')); + } + + private static bool IsPrereleaseTag(string releaseTag) + => releaseTag.Contains('-', StringComparison.Ordinal); + + private static void WriteUpgradeInstallerTrustDiagnostic() + => CommandErrorWriter.WriteStderr($"Installer verification: {UpgradeInstallerVerification}; {UpgradeInstallerTrustBoundary}"); + + internal static UpgradeHandoff CreateWindowsUpgradeHandoff(string releaseTag, Architecture processArchitecture) + { + var normalizedTag = releaseTag.Trim(); + var nugetVersion = normalizedTag.Length > 0 && (normalizedTag[0] is 'v' or 'V') + ? normalizedTag[1..] + : normalizedTag; + var asset = processArchitecture == Architecture.Arm64 + ? "CodeIndex-win-arm64.zip" + : "CodeIndex-win-x64.zip"; + return new UpgradeHandoff( + $"dotnet tool update -g cdidx --version {nugetVersion}", + BuildReleasePageUrl(normalizedTag), + asset, + BuildReleaseAssetUrl(normalizedTag, asset)); + } + + private static UpdateCheckResult CheckLatestPrerelease(string appVersion, CancellationToken cancellationToken) + { + if (UpdateChecker.IsDisabled()) + return UpdateChecker.CreateDisabledResult(appVersion); + + try + { + using var client = UpgradeHttpClientFactory(); + var tag = UpdateChecker.FetchLatestPrereleaseTagAsync( + client, + TimeSpan.FromSeconds(20), + cancellationToken) + .GetAwaiter() + .GetResult(); + return new UpdateCheckResult( + appVersion, + tag, + UpdateChecker.IsNewerRelease(tag, appVersion), + FromCache: false, + Error: tag is null ? "prerelease_not_found" : null, + ErrorCategory: tag is null ? "release_metadata" : null, + ErrorHint: tag is null + ? "Retry later, omit --prerelease, or pass --version to use a known prerelease tag." + : null); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + var failure = UpdateChecker.ClassifyFailure(ex); + return new UpdateCheckResult( + appVersion, + null, + false, + FromCache: false, + Error: failure.Code, + ErrorCategory: failure.Category, + ErrorHint: failure.Hint); + } + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.UpgradeProcess.cs b/src/CodeIndex/Cli/ProgramRunner.UpgradeProcess.cs new file mode 100644 index 000000000..6fad2dcde --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.UpgradeProcess.cs @@ -0,0 +1,295 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + internal static int RunInstallerProcess( + ProcessStartInfo startInfo, + TimeSpan timeout, + CancellationToken cancellationToken = default, + bool suppressOutput = false) + => RunInstallerProcessDetailed(startInfo, timeout, cancellationToken, suppressOutput).ExitCode; + + internal static InstallerProcessResult RunInstallerProcessDetailed( + ProcessStartInfo startInfo, + TimeSpan timeout, + CancellationToken cancellationToken = default, + bool suppressOutput = false) + { + if (suppressOutput) + { + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + } + + Process? process; + try + { + process = Process.Start(startInfo); + } + catch (Exception ex) when (IsInstallerProcessStartException(ex)) + { + if (!suppressOutput) + { + CommandErrorWriter.WriteStderr($"Error: failed to start install.sh for upgrade ({CommandErrorWriter.FormatSanitizedException(ex)})."); + CommandErrorWriter.WriteStderr("Hint: rerun `install.sh` manually for the desired release."); + } + return InstallerProcessResult.Failure(CommandExitCodes.InstallError); + } + + if (process == null) + { + if (!suppressOutput) + { + CommandErrorWriter.WriteStderr("Error: failed to start install.sh for upgrade."); + CommandErrorWriter.WriteStderr("Hint: rerun `install.sh` manually for the desired release."); + } + return InstallerProcessResult.Failure(CommandExitCodes.InstallError); + } + + using (process) + { + var outputDrainTask = suppressOutput + ? DrainSuppressedInstallerOutputAsync(process) + : Task.FromResult(SuppressedInstallerOutputResult.Empty); + + try + { + var waitTask = process.WaitForExitAsync(cancellationToken); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var timeoutTask = Task.Delay(ToWaitMilliseconds(timeout), timeoutCts.Token); + var completedTask = Task.WhenAny(waitTask, timeoutTask).GetAwaiter().GetResult(); + if (completedTask == waitTask) + { + timeoutCts.Cancel(); + waitTask.GetAwaiter().GetResult(); + var output = outputDrainTask.GetAwaiter().GetResult(); + return new InstallerProcessResult( + process.ExitCode, + output.StdoutTail, + output.StderrTail, + output.Truncated); + } + + if (cancellationToken.IsCancellationRequested) + waitTask.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + TryKillProcessTree(process); + if (!process.WaitForExit(ToWaitMilliseconds(InstallerKillWaitTimeout))) + { + if (!suppressOutput) + CommandErrorWriter.WriteStderr("Error: install.sh was cancelled and did not exit after cancellation."); + } + else + { + outputDrainTask.GetAwaiter().GetResult(); + } + throw; + } + + if (process.HasExited) + { + var output = outputDrainTask.GetAwaiter().GetResult(); + return new InstallerProcessResult( + process.ExitCode, + output.StdoutTail, + output.StderrTail, + output.Truncated); + } + + TryKillProcessTree(process); + if (!process.WaitForExit(ToWaitMilliseconds(InstallerKillWaitTimeout))) + { + if (!suppressOutput) + CommandErrorWriter.WriteStderr("Error: install.sh timed out and did not exit after cancellation."); + } + else + { + outputDrainTask.GetAwaiter().GetResult(); + if (!suppressOutput) + CommandErrorWriter.WriteStderr($"Error: install.sh timed out after {FormatDuration(timeout)}."); + } + if (!suppressOutput) + CommandErrorWriter.WriteStderr("Hint: rerun `install.sh` manually for the desired release."); + var timeoutOutput = outputDrainTask.IsCompletedSuccessfully + ? outputDrainTask.GetAwaiter().GetResult() + : SuppressedInstallerOutputResult.Empty; + return new InstallerProcessResult( + CommandExitCodes.InstallError, + timeoutOutput.StdoutTail, + timeoutOutput.StderrTail, + timeoutOutput.Truncated); + } + } + + private static bool IsInstallerProcessStartException(Exception ex) + => ex is Win32Exception + or InvalidOperationException + or FileNotFoundException + or DirectoryNotFoundException + or UnauthorizedAccessException; + + private static async Task DrainSuppressedInstallerOutputAsync(Process process) + { + var outputs = await Task.WhenAll( + DrainSuppressedInstallerOutputAsync(process.StandardOutput), + DrainSuppressedInstallerOutputAsync(process.StandardError)).ConfigureAwait(false); + return new SuppressedInstallerOutputResult( + outputs[0].Tail, + outputs[1].Tail, + outputs[0].Truncated || outputs[1].Truncated); + } + + private static async Task DrainSuppressedInstallerOutputAsync(TextReader reader) + { + var buffer = new char[InstallerSuppressedOutputDrainBufferChars]; + var tail = new SuppressedOutputTail(InstallerSuppressedOutputTailChars); + while (true) + { + var read = await reader.ReadAsync(buffer.AsMemory()).ConfigureAwait(false); + if (read == 0) + break; + + tail.Append(buffer.AsSpan(0, read)); + } + + return new SuppressedInstallerOutput(tail.Value, tail.Truncated); + } + + internal sealed record InstallerProcessResult( + int ExitCode, + string? StdoutTail, + string? StderrTail, + bool OutputTruncated) + { + internal static InstallerProcessResult Failure(int exitCode) => new(exitCode, null, null, false); + } + + private sealed record SuppressedInstallerOutputResult( + string? StdoutTail, + string? StderrTail, + bool Truncated) + { + internal static SuppressedInstallerOutputResult Empty { get; } = new(null, null, false); + } + + private sealed record SuppressedInstallerOutput(string? Tail, bool Truncated); + + private sealed class SuppressedOutputTail(int maxChars) + { + private readonly StringBuilder _builder = new(maxChars); + private long _totalChars; + + internal bool Truncated { get; private set; } + + internal string? Value => _builder.Length == 0 ? null : _builder.ToString(); + + internal void Append(ReadOnlySpan value) + { + _totalChars += value.Length; + if (_totalChars > maxChars) + Truncated = true; + + if (value.Length >= maxChars) + { + _builder.Clear(); + _builder.Append(value[^maxChars..]); + return; + } + + _builder.Append(value); + if (_builder.Length > maxChars) + _builder.Remove(0, _builder.Length - maxChars); + } + } + + private static void TryDeleteUpgradeInstallerScript(string scriptPath) + { + try + { + if (!File.Exists(scriptPath)) + return; + + if (DeleteUpgradeInstallerScriptForTesting != null) + DeleteUpgradeInstallerScriptForTesting(scriptPath); + else + File.Delete(scriptPath); + } + catch (Exception ex) when (IsExpectedCleanupException(ex)) + { + CommandErrorWriter.WriteStderr($"Warning: failed to delete upgrade installer script {ConsoleUi.FormatBoundedValue(scriptPath)} ({FormatSanitizedExceptionSummary(ex)})."); + } + } + + private static void TryDeleteUpgradeInstallerDirectory(string scriptDirectory) + { + try + { + if (!TryValidateUpgradeInstallerDirectoryCleanupTarget(scriptDirectory, out var fullPath, out var validationFailure)) + { + CommandErrorWriter.WriteStderr($"Warning: skipped deleting upgrade installer temporary directory {ConsoleUi.FormatBoundedValue(scriptDirectory)} ({validationFailure})."); + return; + } + + if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath))) + return; + + if (!TryValidateUpgradeInstallerDirectoryCleanupTarget(fullPath, out fullPath, out validationFailure)) + { + CommandErrorWriter.WriteStderr($"Warning: skipped deleting upgrade installer temporary directory {ConsoleUi.FormatBoundedValue(scriptDirectory)} ({validationFailure})."); + return; + } + + if (DeleteUpgradeInstallerDirectoryForTesting != null) + DeleteUpgradeInstallerDirectoryForTesting(fullPath); + else + Directory.Delete(LongPath.EnsureWindowsPrefix(fullPath), recursive: true); + } + catch (Exception ex) when (IsExpectedCleanupException(ex)) + { + CommandErrorWriter.WriteStderr($"Warning: failed to delete upgrade installer temporary directory {ConsoleUi.FormatBoundedValue(scriptDirectory)} ({FormatSanitizedExceptionSummary(ex)})."); + } + } + + private static bool IsExpectedCleanupException(Exception ex) + => ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException; + + internal static bool TryValidateUpgradeInstallerDirectoryCleanupTarget( + string path, + out string fullPath, + out string failureReason) + { + var options = new DirectoryCleanupBoundaryOptions( + UpgradeInstallerDirectoryPrefix, + "target is outside the expected cleanup root", + "target name does not match the expected upgrade temporary-directory prefix", + "target is a symbolic link, reparse point, or device"); + return FileSystemBoundary.TryValidateDirectoryCleanupTarget( + path, + Path.GetTempPath(), + options, + out fullPath, + out failureReason); + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.UpgradeTrust.cs b/src/CodeIndex/Cli/ProgramRunner.UpgradeTrust.cs new file mode 100644 index 000000000..b81107649 --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.UpgradeTrust.cs @@ -0,0 +1,129 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + internal static ProcessStartInfo CreateInstallerProcessStartInfo(string scriptPath, string releaseTag, string installDir) + { + var fullScriptPath = Path.GetFullPath(scriptPath); + var startInfo = CodeIndex.ProcessLaunchPolicy.CreateNoShellStartInfo( + fileName: ResolveTrustedBashPath(), + workingDirectory: Path.GetDirectoryName(fullScriptPath) ?? string.Empty); + CodeIndex.ProcessLaunchPolicy.AddArguments(startInfo, fullScriptPath, releaseTag); + CodeIndex.SubprocessEnvironmentPolicy.ApplyUpgradeInstallerEnvironment(startInfo); + startInfo.Environment["CDIDX_INSTALL_DIR"] = installDir; + return startInfo; + } + + private static bool TryGetUpgradeVerificationPolicy(out string verificationPolicy, out string error) + { + var policy = EnvironmentAccess.GetProcessEnvironmentVariable("CDIDX_VERIFY_POLICY"); + if (string.IsNullOrEmpty(policy) || string.Equals(policy, "strict", StringComparison.Ordinal)) + { + verificationPolicy = "strict"; + error = string.Empty; + return true; + } + if (string.Equals(policy, "compat", StringComparison.Ordinal)) + { + verificationPolicy = "compat"; + error = string.Empty; + return true; + } + + verificationPolicy = string.Empty; + error = $"CDIDX_VERIFY_POLICY must be 'compat' or 'strict' (got '{policy}')."; + return false; + } + + private static void RequireUpgradeAssetProvenance( + string assetPath, + string assetName, + string releaseTag, + string verificationPolicy, + bool suppressOutput, + CancellationToken cancellationToken, + out bool? verified) + { + var compat = string.Equals(verificationPolicy, "compat", StringComparison.Ordinal); + verified = UpgradeAssetProvenanceVerifier(assetPath, releaseTag, cancellationToken); + + if (verified == true) + { + if (!suppressOutput) + CommandErrorWriter.WriteStderr($"Verified independent release provenance for {assetName}."); + return; + } + + if (!compat) + throw new InvalidDataException($"Independent release provenance verification failed for {assetName}; installer execution is blocked."); + + if (!suppressOutput) + CommandErrorWriter.WriteStderr($"Warning: AUDIT: CDIDX_VERIFY_POLICY=compat permits {assetName} without independent release provenance verification."); + } + + private static bool VerifyUpgradeAssetProvenance(string assetPath, string releaseTag, CancellationToken cancellationToken) + { + var startInfo = CreateUpgradeAttestationStartInfo(assetPath, releaseTag); + var result = RunInstallerProcessDetailed( + startInfo, + TimeSpan.FromSeconds(30), + cancellationToken, + suppressOutput: true); + return result.ExitCode == CommandExitCodes.Success; + } + + internal static ProcessStartInfo CreateUpgradeAttestationStartInfo(string assetPath, string releaseTag) + { + var fullAssetPath = Path.GetFullPath(assetPath); + var startInfo = CodeIndex.ProcessLaunchPolicy.CreateNoShellStartInfo( + fileName: "gh", + workingDirectory: Path.GetDirectoryName(fullAssetPath) ?? string.Empty); + CodeIndex.ProcessLaunchPolicy.AddArguments( + startInfo, + "attestation", + "verify", + fullAssetPath, + "-R", + "Widthdom/CodeIndex", + "--signer-workflow", + ReleaseAttestationSignerWorkflow, + "--source-ref", + $"refs/tags/{releaseTag}"); + CodeIndex.SubprocessEnvironmentPolicy.ApplyUpgradeInstallerEnvironment(startInfo); + return startInfo; + } + + internal static string ResolveTrustedBashPath() + { + if (OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("The install.sh upgrade path requires a POSIX bash executable."); + + foreach (var candidate in new[] { "/bin/bash", "/usr/bin/bash" }) + { + if (File.Exists(candidate)) + return candidate; + } + + throw new FileNotFoundException("Could not find a trusted absolute bash path for running install.sh."); + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.Version.cs b/src/CodeIndex/Cli/ProgramRunner.Version.cs new file mode 100644 index 000000000..ab4cc6325 --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.Version.cs @@ -0,0 +1,174 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + // `--version` is now build-aware so dev builds from main are not + // indistinguishable from tagged releases in bug reports (#1550). Human + // output stays on a single line — `cdidx v` optionally followed by + // ` (commit , built , )` — so the install.sh + // reinstall validator can stay anchored against trailing diagnostic spam. + // バグ報告で dev ビルドとリリースタグを区別できるよう `--version` を + // ビルド情報付きにする (#1550)。人間出力は 1 行に保ち、install.sh の + // reinstall validator が末尾診断文を誤って許容しないよう、括弧で囲った + // メタデータ以外を許さない形に揃える。 + internal static int RunVersion( + string[] cmdArgs, + JsonSerializerOptions jsonOptions, + string? appVersion = null, + CancellationToken cancellationToken = default) + { + var wantsJson = false; + foreach (var arg in cmdArgs) + { + if (arg is "--json") + { + wantsJson = true; + continue; + } + CommandErrorWriter.WriteStderr($"Error: --version does not accept '{arg}'."); + CommandErrorWriter.WriteStderr("Hint: use `cdidx --version` or `cdidx --version --json`."); + return CommandExitCodes.UsageError; + } + + var baseMetadata = ConsoleUi.LoadBuildMetadata(); + // Honour the caller-provided appVersion (overrides version.json so + // tests and embedded hosts can pin a specific semver) while keeping + // the assembly-stamped commit/build-date/dirty fields. + // 呼び出し元が appVersion を渡した場合はそれを優先する(テストや + // 組み込みホストが semver を固定できるよう)一方、commit / build + // date / dirty は刻印された値をそのまま使う。 + var metadata = string.IsNullOrWhiteSpace(appVersion) + ? baseMetadata + : baseMetadata with { Version = appVersion! }; + if (wantsJson) + { + var payload = new VersionInfoJsonResult( + Name: "cdidx", + Version: metadata.Version, + Commit: metadata.Commit, + BuildDate: metadata.BuildDate, + Dirty: metadata.Dirty); + var json = JsonSerializer.Serialize(payload, CliJsonSerializerContextFactory.Create(jsonOptions).VersionInfoJsonResult); + Console.WriteLine(json); + return CommandExitCodes.Success; + } + + var updateHint = UpdateChecker.GetNewerReleaseHint(metadata.Version, cancellationToken); + Console.WriteLine(FormatVersionLine(metadata, updateHint)); + return CommandExitCodes.Success; + } + + internal static string FormatVersionLine(ConsoleUi.BuildMetadata metadata, string? updateHint = null) + { + var commit = string.IsNullOrWhiteSpace(metadata.Commit) ? "unknown" : metadata.Commit; + var buildDate = string.IsNullOrWhiteSpace(metadata.BuildDate) ? "unknown" : metadata.BuildDate; + var dirty = string.IsNullOrWhiteSpace(metadata.Dirty) ? "unknown" : metadata.Dirty; + var suffix = string.IsNullOrWhiteSpace(updateHint) ? string.Empty : $" [{updateHint}]"; + + // Suppress the metadata suffix only when every component is "unknown", + // so legacy callers that depend on the exact `cdidx v` shape keep + // working when no build stamp is present (e.g. mocked binaries). + // 全項目が unknown のときだけ末尾メタデータを省略し、ビルド刻印が + // 無い旧バイナリ/モックでも `cdidx v` 形式を保つ。 + if (commit == "unknown" && buildDate == "unknown" && dirty == "unknown") + return $"cdidx v{metadata.Version}{suffix}"; + + return $"cdidx v{metadata.Version} (commit {commit}, built {buildDate}, {dirty}){suffix}"; + } + + private static int RunCompletions(string[] cmdArgs, JsonSerializerOptions jsonOptions, string commandName = "--completions") + { + var usage = $"cdidx {commandName} "; + var wantsJson = ContainsJsonOutputFlag(cmdArgs); + if (wantsJson) + return CommandErrorWriter.WriteJsonOrHuman( + true, + jsonOptions, + "--json is not supported for completions.", + CommandExitCodes.UsageError, + "rerun with one of `bash`, `zsh`, `fish`, or `powershell`; completions output is already a shell script.", + usage); + + if (cmdArgs.Length == 0) + return CommandErrorWriter.Write( + $"{commandName} requires a shell value.", + CommandExitCodes.UsageError, + "rerun with one of `bash`, `zsh`, `fish`, or `powershell`.", + usage); + + if (cmdArgs[0].StartsWith("-", StringComparison.Ordinal)) + return CommandErrorWriter.Write( + $"{commandName} requires a shell value, got option-like token '{cmdArgs[0]}'.", + CommandExitCodes.UsageError, + "rerun with one of `bash`, `zsh`, `fish`, or `powershell`.", + usage); + + if (cmdArgs.Length > 1) + return CommandErrorWriter.Write( + $"{commandName} accepts exactly one shell value, got extra {ConsoleUi.Counted(cmdArgs.Length - 1, "argument")}: {string.Join(", ", cmdArgs.Skip(1).Select(arg => $"`{arg}`"))}.", + CommandExitCodes.UsageError, + "rerun with exactly one shell name: `bash`, `zsh`, `fish`, or `powershell`.", + usage); + + if (ConsoleUi.PrintCompletions(cmdArgs[0])) + return CommandExitCodes.Success; + + return CommandErrorWriter.Write( + $"unsupported completion shell `{cmdArgs[0]}`.", + CommandExitCodes.UsageError, + "rerun with one of `bash`, `zsh`, `fish`, or `powershell`.", + usage); + } + + private static string StripErrorPrefix(string message) + { + const string prefix = "Error: "; + return message.StartsWith(prefix, StringComparison.Ordinal) ? message[prefix.Length..] : message; + } + + private static int ShowError(string[] args, string message, JsonSerializerOptions jsonOptions) + { + if (ContainsJsonOutputFlag(args)) + { + return CommandErrorWriter.WriteJsonOrHuman( + true, + jsonOptions, + message, + CommandExitCodes.UsageError, + "run `cdidx --help` to list available commands."); + } + + CommandErrorWriter.WriteStderr($"Error: {message}"); + + var input = args[0]; + if (!input.StartsWith('-')) + { + var best = ConsoleUi.FindClosestCommand(input); + if (best != null) + CommandErrorWriter.WriteStderr($"Did you mean: cdidx {best}?"); + } + + CommandErrorWriter.WriteStderr("Run 'cdidx --help' for usage information."); + return CommandExitCodes.UsageError; + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.VersionPin.cs b/src/CodeIndex/Cli/ProgramRunner.VersionPin.cs new file mode 100644 index 000000000..361cb9e69 --- /dev/null +++ b/src/CodeIndex/Cli/ProgramRunner.VersionPin.cs @@ -0,0 +1,199 @@ +using System.Diagnostics; +using System.Globalization; +using System.ComponentModel; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Lsp; +using CodeIndex.Mcp; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ProgramRunner +{ + private static int CheckWorkspaceVersionPin(string appVersion, string startDirectory, bool strictVersion) + { + var pinPath = FindWorkspaceVersionPin(startDirectory); + if (pinPath == null) + return CommandExitCodes.Success; + + if (!TryReadWorkspaceVersionPin(pinPath, out var required, out var warning)) + { + CommandErrorWriter.WriteStderr(warning); + return CommandExitCodes.Success; + } + + if (string.IsNullOrWhiteSpace(required) || VersionsMatch(required, appVersion)) + return CommandExitCodes.Success; + + var message = $"workspace requires cdidx v{NormalizeVersion(required)}, but this binary is v{NormalizeVersion(appVersion)} ({pinPath})."; + if (!strictVersion) + { + CommandErrorWriter.WriteStderr($"Warning: {message}"); + return CommandExitCodes.Success; + } + + CommandErrorWriter.WriteStderr($"Error: {message}"); + CommandErrorWriter.WriteStderr("Hint: rerun without --strict-version to warn only, or install the pinned cdidx version for this workspace."); + return CommandExitCodes.ExUsage; + } + + private static bool TryReadWorkspaceVersionPin(string pinPath, out string required, out string warning) + { + required = string.Empty; + warning = string.Empty; + + try + { + var bytes = ReadWorkspaceVersionPinBytes(pinPath); + if (bytes.Length > WorkspaceVersionPinMaxBytes) + { + warning = BuildWorkspaceVersionPinWarning($"file exceeds {WorkspaceVersionPinMaxBytes} bytes"); + return false; + } + + return TryParseWorkspaceVersionPin(DecodeWorkspaceVersionPinBytes(bytes), out required, out warning); + } + catch (Exception ex) + { + warning = BuildWorkspaceVersionPinReadWarning(ex); + return false; + } + } + + private static byte[] ReadWorkspaceVersionPinBytes(string pinPath) + { + var buffer = new byte[WorkspaceVersionPinMaxBytes + 1]; + var totalRead = 0; + + using var stream = new FileStream( + pinPath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + bufferSize: Math.Min(1024, buffer.Length), + FileOptions.SequentialScan); + + while (totalRead < buffer.Length) + { + var read = stream.Read(buffer, totalRead, buffer.Length - totalRead); + if (read == 0) + break; + totalRead += read; + } + + if (totalRead == buffer.Length) + return buffer; + + var result = new byte[totalRead]; + Array.Copy(buffer, result, totalRead); + return result; + } + + private static string DecodeWorkspaceVersionPinBytes(byte[] bytes) + { + using var stream = new MemoryStream(bytes, writable: false); + using var reader = new StreamReader( + stream, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + bufferSize: Math.Min(1024, Math.Max(1, bytes.Length))); + return reader.ReadToEnd(); + } + + private static bool TryParseWorkspaceVersionPin(string content, out string required, out string warning) + { + required = string.Empty; + warning = string.Empty; + + using var reader = new StringReader(content); + var skippedBlankLines = 0; + var lineNumber = 0; + string? line; + while ((line = reader.ReadLine()) != null) + { + lineNumber++; + if (line.Length > WorkspaceVersionPinMaxLineChars) + { + warning = BuildWorkspaceVersionPinWarning($"line {lineNumber} exceeds {WorkspaceVersionPinMaxLineChars} characters"); + return false; + } + + if (string.IsNullOrWhiteSpace(line)) + { + skippedBlankLines++; + if (skippedBlankLines > WorkspaceVersionPinMaxSkippedBlankLines) + { + warning = BuildWorkspaceVersionPinWarning($"more than {WorkspaceVersionPinMaxSkippedBlankLines} leading blank lines"); + return false; + } + + continue; + } + + required = line.Trim(); + return true; + } + + return true; + } + + internal static string BuildWorkspaceVersionPinReadWarningForTesting(Exception exception) + => BuildWorkspaceVersionPinReadWarning(exception); + + private static string BuildWorkspaceVersionPinWarning(string reason) + => $"Warning: ignoring .cdidx-version: {ConsoleUi.FormatBoundedValue(reason)}."; + + private static string BuildWorkspaceVersionPinReadWarning(Exception exception) + { + var reason = exception switch + { + UnauthorizedAccessException => "permission denied", + ArgumentException or NotSupportedException or PathTooLongException => "invalid path", + IOException => "read failed", + _ => "read failed", + }; + return $"Warning: could not read .cdidx-version: {reason}."; + } + + internal static string? FindWorkspaceVersionPin(string startDirectory) + { + var current = Path.GetFullPath(startDirectory); + if (File.Exists(current)) + current = Path.GetDirectoryName(current) ?? current; + + while (!string.IsNullOrWhiteSpace(current)) + { + var candidate = Path.Combine(current, ".cdidx-version"); + if (File.Exists(candidate)) + return candidate; + + var parent = Directory.GetParent(current); + if (parent == null) + return null; + current = parent.FullName; + } + + return null; + } + + private static bool VersionsMatch(string required, string actual) + => string.Equals(NormalizeVersion(required), NormalizeVersion(actual), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeVersion(string value) + { + var trimmed = value.Trim(); + return trimmed.StartsWith('v') || trimmed.StartsWith('V') ? trimmed[1..] : trimmed; + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 1e89d8a03..457d7d22c 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -321,4869 +321,30 @@ private static bool IsValidateConfigCommand(IReadOnlyList args) && string.Equals(args[commandIndex], "validate-config", StringComparison.Ordinal); } - private static int RunTestExtractor(string[] args, JsonSerializerOptions jsonOptions) - { - string? language = null; - string? file = null; - string? expect = null; - var json = args.Contains("--json", StringComparer.Ordinal); - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (TryConsumeInlineOrNext(args, ref i, arg, "--language", out var value)) - language = value; - else if (TryConsumeInlineOrNext(args, ref i, arg, "--file", out value)) - file = value; - else if (TryConsumeInlineOrNext(args, ref i, arg, "--expect-symbols", out value) || TryConsumeInlineOrNext(args, ref i, arg, "--expect", out value)) - expect = value; - else if (arg == "--json") - continue; - else - return WriteTestExtractorError(json, jsonOptions, $"Unknown test-extractor argument: {arg}", CommandExitCodes.InvalidArgument, "use --language --file [--expect-symbols ] [--json]."); - } - - if (string.IsNullOrWhiteSpace(language) || string.IsNullOrWhiteSpace(file)) - return WriteTestExtractorError(json, jsonOptions, "test-extractor requires --language and --file.", CommandExitCodes.InvalidArgument, "use --language --file [--expect-symbols ] [--json]."); - if (!TryReadTestExtractorFile(file, "source", json, jsonOptions, out var source, out var readExitCode)) - return readExitCode; - - var symbols = Indexer.SymbolExtractor.Extract(1, language, source, file); - if (expect != null) - { - if (!TryReadTestExtractorFile(expect, "expected symbols", json, jsonOptions, out var expected, out readExitCode)) - return readExitCode; - var actual = JsonSerializer.Serialize(symbols); - if (!TryJsonEquivalent(expected, actual, out var jsonError)) - { - if (jsonError is not null) - { - return WriteTestExtractorError( - json, - jsonOptions, - $"test-extractor expected or actual symbols JSON could not be parsed within the {TestExtractorJsonComparisonMaxBytes} byte and {TestExtractorJsonComparisonMaxDepth} depth limits: {jsonError.Message}", - CommandExitCodes.InvalidArgument, - "Use a smaller or shallower expected-symbols JSON fixture."); - } - - if (json) - { - return WriteTestExtractorError( - true, - jsonOptions, - "Expected symbols did not match extracted symbols.", - CommandExitCodes.InvalidArgument, - "Update the expected-symbols fixture or inspect the extracted symbols without --expect-symbols."); - } - CommandErrorWriter.WriteStderr("Expected symbols did not match extracted symbols."); - CommandErrorWriter.WriteStderr(actual); - return CommandExitCodes.InvalidArgument; - } - } - - if (json || expect == null) - { - var result = new TestExtractorJsonResult(JsonSerializer.SerializeToElement(symbols)); - CommandOutputWriter.WriteJson( - result, - CliJsonSerializerContextFactory.Create(jsonOptions).TestExtractorJsonResult); - } - return CommandExitCodes.Success; - } - - private static bool TryReadTestExtractorFile( - string path, - string role, - bool json, - JsonSerializerOptions jsonOptions, - out string content, - out int exitCode) - { - content = string.Empty; - exitCode = CommandExitCodes.Success; - var displayRole = $"test-extractor {role} file"; - if (!File.Exists(LongPath.EnsureWindowsPrefix(path))) - { - exitCode = WriteTestExtractorError(json, jsonOptions, $"{displayRole} not found: {path}", CommandExitCodes.NotFound); - return false; - } - - try - { - using var stream = BoundedFile.OpenReadForLengthCheckedText(path); - if (stream.Length > TestExtractorMaxInputBytes) - { - exitCode = WriteTestExtractorTooLargeError(json, jsonOptions, displayRole, stream.Length); - return false; - } - - TestExtractorFileLengthCheckedForTesting?.Invoke(path); - if (!TryReadTestExtractorStream(stream, displayRole, json, jsonOptions, out content, out exitCode)) - return false; - - return true; - } - catch (IOException ex) - { - exitCode = WriteTestExtractorError(json, jsonOptions, $"{displayRole} could not be read: {FormatSanitizedExceptionSummary(ex)}", CommandExitCodes.InvalidArgument); - return false; - } - catch (UnauthorizedAccessException ex) - { - exitCode = WriteTestExtractorError(json, jsonOptions, $"{displayRole} could not be read: {FormatSanitizedExceptionSummary(ex)}", CommandExitCodes.InvalidArgument); - return false; - } - } - - private static bool TryReadTestExtractorStream( - Stream stream, - string displayRole, - bool json, - JsonSerializerOptions jsonOptions, - out string content, - out int exitCode) - { - content = string.Empty; - exitCode = CommandExitCodes.Success; - using var buffer = new MemoryStream(capacity: (int)Math.Min(TestExtractorMaxInputBytes, Math.Max(0, stream.Length))); - var scratch = new byte[TestExtractorReadBufferBytes]; - long bytesRead = 0; - while (true) - { - var remainingBudget = TestExtractorMaxInputBytes + 1 - bytesRead; - if (remainingBudget <= 0) - { - exitCode = WriteTestExtractorTooLargeError(json, jsonOptions, displayRole, bytesRead); - return false; - } - - var read = stream.Read(scratch, 0, (int)Math.Min(scratch.Length, remainingBudget)); - if (read == 0) - break; - - bytesRead += read; - if (bytesRead > TestExtractorMaxInputBytes) - { - exitCode = WriteTestExtractorTooLargeError(json, jsonOptions, displayRole, bytesRead); - return false; - } - - buffer.Write(scratch, 0, read); - } - - buffer.Position = 0; - using var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - // The loop above rejects streams beyond TestExtractorMaxInputBytes before this materializes text. - content = reader.ReadToEnd(); - return true; - } - - private static int WriteTestExtractorTooLargeError( - bool json, - JsonSerializerOptions jsonOptions, - string displayRole, - long bytes) - => WriteTestExtractorError( - json, - jsonOptions, - $"{displayRole} is too large: {bytes} bytes exceeds the {TestExtractorMaxInputBytes} byte limit.", - CommandExitCodes.InvalidArgument, - "Use a smaller extractor fixture or expectation file."); - - private static int WriteTestExtractorError( - bool json, - JsonSerializerOptions jsonOptions, - string message, - int exitCode, - string? hint = null) - => CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, message, exitCode, hint); - - private static bool TryConsumeInlineOrNext(string[] args, ref int index, string arg, string flag, out string value) - { - value = string.Empty; - if (arg.StartsWith(flag + "=", StringComparison.Ordinal)) - { - value = arg[(flag.Length + 1)..]; - return true; - } - - if (arg != flag || index + 1 >= args.Length) - return false; - - value = args[++index]; - return true; - } - - private static bool TryJsonEquivalent(string expected, string actual, out Exception? error) - { - error = null; - try - { - using var expectedDoc = BoundedJson.ParseDocument( - expected, - TestExtractorJsonComparisonMaxBytes, - TestExtractorJsonComparisonMaxDepth); - using var actualDoc = BoundedJson.ParseDocument( - actual, - TestExtractorJsonComparisonMaxBytes, - TestExtractorJsonComparisonMaxDepth); - return JsonSerializer.Serialize(expectedDoc.RootElement) == JsonSerializer.Serialize(actualDoc.RootElement); - } - catch (Exception ex) when (ex is JsonException or InvalidDataException) - { - error = ex; - return false; - } - } - - private static int RunDoctor(string[] args, string appVersion, JsonSerializerOptions jsonOptions) - { - var wantsJson = args.Any(static arg => arg == "--json" || arg.StartsWith("--json=", StringComparison.Ordinal)); - var json = false; - bool? redactPaths = null; - var envInventory = DoctorEnvironmentInventoryMode.None; - string? envDomain = null; - string? envCategory = null; - string? envSensitivity = null; - int? maxJsonBytes = null; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (arg == "--env-domain" || arg.StartsWith("--env-domain=", StringComparison.Ordinal)) - { - _ = TryReadDoctorValueOption(args, ref i, arg, "--env-domain", wantsJson, jsonOptions, out envDomain, out var optionExitCode); - if (optionExitCode.HasValue) - return optionExitCode.Value; - continue; - } - if (arg == "--env-category" || arg.StartsWith("--env-category=", StringComparison.Ordinal)) - { - _ = TryReadDoctorValueOption(args, ref i, arg, "--env-category", wantsJson, jsonOptions, out envCategory, out var optionExitCode); - if (optionExitCode.HasValue) - return optionExitCode.Value; - continue; - } - if (arg == "--env-sensitivity" || arg.StartsWith("--env-sensitivity=", StringComparison.Ordinal)) - { - _ = TryReadDoctorValueOption(args, ref i, arg, "--env-sensitivity", wantsJson, jsonOptions, out envSensitivity, out var optionExitCode); - if (optionExitCode.HasValue) - return optionExitCode.Value; - continue; - } - if (arg == "--max-json-bytes" || arg.StartsWith("--max-json-bytes=", StringComparison.Ordinal)) - { - if (!TryConsumeInlineOrNext(args, ref i, arg, "--max-json-bytes", out var value) - || !int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) - || parsed <= 0) - { - return CommandErrorWriter.WriteJsonOrHuman( - wantsJson, - jsonOptions, - "--max-json-bytes requires a positive integer.", - CommandExitCodes.InvalidArgument, - "pass a positive UTF-8 byte limit, for example `--max-json-bytes 16384`.", - usage: GetDoctorUsage()); - } - maxJsonBytes = parsed; - continue; - } - - switch (arg) - { - case "--json": - json = true; - break; - case "--redact-paths": - redactPaths = true; - break; - case "--show-paths": - redactPaths = false; - break; - case "--env-inventory": - case "--env-inventory=compact": - envInventory = DoctorEnvironmentInventoryMode.Compact; - break; - case "--env-inventory=full": - envInventory = DoctorEnvironmentInventoryMode.Full; - break; - default: - return CommandErrorWriter.WriteJsonOrHuman( - wantsJson, - jsonOptions, - arg.StartsWith("--json=", StringComparison.Ordinal) - ? "doctor supports --json only; --json= is not supported." - : $"Unknown doctor argument: {arg}", - CommandExitCodes.InvalidArgument, - $"use `{GetDoctorUsage()}`."); - } - } - - var filtersRequested = envDomain is not null || envCategory is not null || envSensitivity is not null; - if (filtersRequested && envInventory != DoctorEnvironmentInventoryMode.Full) - { - return CommandErrorWriter.WriteJsonOrHuman( - wantsJson, - jsonOptions, - "doctor environment inventory filters require --env-inventory=full.", - CommandExitCodes.InvalidArgument, - "add `--env-inventory=full` before filtering by domain, category, or sensitivity.", - usage: GetDoctorUsage()); - } - if (maxJsonBytes.HasValue && (!json || envInventory != DoctorEnvironmentInventoryMode.Full)) - { - return CommandErrorWriter.WriteJsonOrHuman( - wantsJson, - jsonOptions, - "doctor --max-json-bytes requires --json and --env-inventory=full.", - CommandExitCodes.InvalidArgument, - "use `cdidx doctor --json --env-inventory=full --max-json-bytes `.", - usage: GetDoctorUsage()); - } - - if (!TryFilterDoctorEnvironmentInventory( - envDomain, - envCategory, - envSensitivity, - wantsJson, - jsonOptions, - out var filteredInventory, - out var filterExitCode)) - { - return filterExitCode; - } - - if (json) - { - return WriteDoctorJson( - appVersion, - jsonOptions, - redactPaths ?? true, - envInventory == DoctorEnvironmentInventoryMode.Full, - filteredInventory, - maxJsonBytes); - } - - if (envInventory == DoctorEnvironmentInventoryMode.Full) - { - WriteEnvironmentInventory(filteredInventory); - return CommandExitCodes.Success; - } - - if (envInventory == DoctorEnvironmentInventoryMode.Compact) - { - WriteEnvironmentInventorySummary(); - return CommandExitCodes.Success; - } - - var dbResolution = DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null); - Console.WriteLine("cdidx doctor"); - Console.WriteLine(ConsoleUi.FormatSummaryLine("version", appVersion)); - Console.WriteLine(ConsoleUi.FormatSummaryLine("commit", ConsoleUi.LoadBuildMetadata().Commit)); - Console.WriteLine(ConsoleUi.FormatSummaryLine("rid", RuntimeInformation.RuntimeIdentifier)); - Console.WriteLine(ConsoleUi.FormatSummaryLine("os", RuntimeInformation.OSDescription)); - Console.WriteLine(ConsoleUi.FormatSummaryLine("kernel", Environment.OSVersion.VersionString)); - Console.WriteLine(ConsoleUi.FormatSummaryLine("dotnet", RuntimeInformation.FrameworkDescription)); - Console.WriteLine(ConsoleUi.FormatSummaryLine("process", Environment.ProcessPath ?? "")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("base_dir", AppContext.BaseDirectory)); - Console.WriteLine(ConsoleUi.FormatSummaryLine("cwd", Environment.CurrentDirectory)); - Console.WriteLine(); - Console.WriteLine("terminal:"); - Console.WriteLine(ConsoleUi.FormatSummaryLine("stdout_tty", !Console.IsOutputRedirected, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("stderr_tty", !Console.IsErrorRedirected, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("columns", FormatDoctorEnvironmentValue(CdidxEnvironment.GetProcessEnvironmentVariable("COLUMNS")), indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("no_color", FormatDoctorEnvironmentValue(CdidxEnvironment.GetProcessEnvironmentVariable("NO_COLOR")), indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("term", FormatDoctorEnvironmentValue(CdidxEnvironment.GetProcessEnvironmentVariable("TERM")), indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("locale", CultureInfo.CurrentCulture.Name, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("ui_locale", CultureInfo.CurrentUICulture.Name, indent: " ")); - Console.WriteLine(); - var display = BuildDoctorDisplayJson(); - Console.WriteLine("display:"); - Console.WriteLine(ConsoleUi.FormatSummaryLine("color", display.Color.Enabled, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("color_source", display.Color.Source, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("terminal_hint", display.TerminalHint.HasHint, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("progress", display.Progress.Enabled, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("progress_source", display.Progress.Source, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("max_line_width", display.MaxLineWidth.Value, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("max_line_width_source", display.MaxLineWidth.Source, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("ambiguous_width", display.AmbiguousWidth.Wide, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("ambiguous_locale", display.AmbiguousWidth.Locale, indent: " ")); - Console.WriteLine(); - Console.WriteLine("paths:"); - Console.WriteLine(ConsoleUi.FormatSummaryLine("db", dbResolution.DbPath, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("data_dir", dbResolution.DataDir ?? "", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("data_source", dbResolution.DataDirSource ?? "explicit-db", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("log_dir", GlobalToolLog.ResolveLogDirectoryForStatus(), indent: " ")); - Console.WriteLine(); - Console.WriteLine("config:"); - Console.WriteLine(ConsoleUi.FormatSummaryLine(CdidxConfigFile.FileName, File.Exists(Path.Combine(Environment.CurrentDirectory, CdidxConfigFile.FileName)) ? "present" : "not found", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine(CdidxConfigFile.DisableEnvVar, FormatDoctorEnvironmentValue(CdidxEnvironment.GetProcessEnvironmentVariable(CdidxConfigFile.DisableEnvVar)), indent: " ")); - Console.WriteLine(); - Console.WriteLine("github:"); - Console.WriteLine(ConsoleUi.FormatSummaryLine("proxy_default_credentials", GitHubHttpClientFactory.FormatProxyDefaultCredentialsStatus(), indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("max_request_timeout_s", GitHubHttpClientFactory.MaxRequestTimeout.TotalSeconds.ToString("0", CultureInfo.InvariantCulture), indent: " ")); - Console.WriteLine(); - Console.WriteLine("cdidx_env:"); - foreach (var (key, value) in EnumerateCdidxEnvironment()) - Console.WriteLine(ConsoleUi.FormatSummaryLine(key, value, indent: " ")); - return CommandExitCodes.Success; - } - - private enum DoctorEnvironmentInventoryMode - { - None, - Compact, - Full, - } - - private static string GetDoctorUsage() - => "cdidx doctor [--json] [--redact-paths|--show-paths] [--env-inventory[=compact|full]] [--env-domain ] [--env-category ] [--env-sensitivity ] [--max-json-bytes ]"; - - private static bool TryReadDoctorValueOption( - string[] args, - ref int index, - string arg, - string flag, - bool wantsJson, - JsonSerializerOptions jsonOptions, - out string? value, - out int? exitCode) - { - value = null; - exitCode = null; - if (arg != flag && !arg.StartsWith(flag + "=", StringComparison.Ordinal)) - return false; - - if (!TryConsumeInlineOrNext(args, ref index, arg, flag, out var parsed) - || string.IsNullOrWhiteSpace(parsed)) - { - exitCode = CommandErrorWriter.WriteJsonOrHuman( - wantsJson, - jsonOptions, - $"{flag} requires a non-empty value.", - CommandExitCodes.InvalidArgument, - $"pass one value reported by `cdidx doctor --env-inventory` for {flag}.", - usage: GetDoctorUsage()); - return true; - } - - value = parsed; - return true; - } - - private static bool TryFilterDoctorEnvironmentInventory( - string? domain, - string? category, - string? sensitivity, - bool wantsJson, - JsonSerializerOptions jsonOptions, - out IReadOnlyList filtered, - out int exitCode) - { - filtered = []; - exitCode = CommandExitCodes.Success; - foreach (var (flag, value, selector) in new (string Flag, string? Value, Func Selector)[] - { - ("--env-domain", domain, static item => item.Domain), - ("--env-category", category, static item => item.Category), - ("--env-sensitivity", sensitivity, static item => item.Sensitivity), - }) - { - if (value is null) - continue; - if (EnvironmentVariableInventory.Items.Any(item => string.Equals(selector(item), value, StringComparison.OrdinalIgnoreCase))) - continue; - - var allowed = string.Join( - ", ", - EnvironmentVariableInventory.Items - .Select(selector) - .Distinct(StringComparer.Ordinal) - .OrderBy(static candidate => candidate, StringComparer.Ordinal)); - exitCode = CommandErrorWriter.WriteJsonOrHuman( - wantsJson, - jsonOptions, - $"Unknown {flag} value: {value}", - CommandExitCodes.InvalidArgument, - $"choose one of: {allowed}.", - usage: GetDoctorUsage()); - return false; - } - - filtered = EnvironmentVariableInventory.Items - .Where(item => domain is null || string.Equals(item.Domain, domain, StringComparison.OrdinalIgnoreCase)) - .Where(item => category is null || string.Equals(item.Category, category, StringComparison.OrdinalIgnoreCase)) - .Where(item => sensitivity is null || string.Equals(item.Sensitivity, sensitivity, StringComparison.OrdinalIgnoreCase)) - .OrderBy(static item => item.Name, StringComparer.Ordinal) - .ToArray(); - return true; - } - - private static int WriteDoctorJson( - string appVersion, - JsonSerializerOptions jsonOptions, - bool redactPaths, - bool includeFullEnvironmentInventory, - IReadOnlyList environmentInventory, - int? maxJsonBytes) - { - var dbResolution = DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null); - var build = ConsoleUi.LoadBuildMetadata(); - var payload = new DoctorJsonResult( - ApiVersion: "1", - Version: appVersion, - Commit: build.Commit, - Rid: RuntimeInformation.RuntimeIdentifier, - Os: RuntimeInformation.OSDescription, - Kernel: Environment.OSVersion.VersionString, - Dotnet: RuntimeInformation.FrameworkDescription, - Process: RedactDoctorPath(Environment.ProcessPath ?? "", redactPaths), - BaseDir: RedactDoctorPath(AppContext.BaseDirectory, redactPaths), - Cwd: RedactDoctorPath(Environment.CurrentDirectory, redactPaths), - Terminal: new DoctorTerminalJsonResult( - StdoutTty: !Console.IsOutputRedirected, - StderrTty: !Console.IsErrorRedirected, - Columns: FormatDoctorJsonEnvironmentValue("COLUMNS", redactPaths), - NoColor: FormatDoctorJsonEnvironmentValue("NO_COLOR", redactPaths), - Term: FormatDoctorJsonEnvironmentValue("TERM", redactPaths), - Locale: CultureInfo.CurrentCulture.Name, - UiLocale: CultureInfo.CurrentUICulture.Name), - Display: BuildDoctorDisplayJson(), - Paths: new DoctorPathsJsonResult( - Db: RedactDoctorPath(dbResolution.DbPath, redactPaths), - DataDir: RedactDoctorPath(dbResolution.DataDir ?? "", redactPaths), - DataSource: dbResolution.DataDirSource ?? "explicit-db", - LogDir: RedactDoctorPath(GlobalToolLog.ResolveLogDirectoryForStatus(), redactPaths)), - Config: new DoctorConfigJsonResult( - DotCdidxrcJson: File.Exists(Path.Combine(Environment.CurrentDirectory, CdidxConfigFile.FileName)) ? "present" : "not_found", - DisableConfigFile: FormatDoctorJsonEnvironmentValue(CdidxConfigFile.DisableEnvVar, redactPaths)), - CdidxEnv: EnumerateCdidxEnvironmentJson(redactPaths).ToArray(), - EnvironmentInventorySummary: includeFullEnvironmentInventory - ? EnvironmentVariableInventory.BuildSummary(environmentInventory) - : EnvironmentVariableInventory.BuildSummary(), - EnvironmentInventory: includeFullEnvironmentInventory ? environmentInventory : null, - Redaction: new DoctorRedactionJsonResult( - PathsRedacted: redactPaths, - SecretsRedacted: true)); - - var json = JsonSerializer.Serialize(payload, CliJsonSerializerContextFactory.Create(jsonOptions).DoctorJsonResult); - var byteCount = Encoding.UTF8.GetByteCount(json) + Encoding.UTF8.GetByteCount(Environment.NewLine); - if (maxJsonBytes.HasValue && byteCount > maxJsonBytes.Value) - { - return CommandErrorWriter.WriteJsonOrHuman( - true, - jsonOptions, - $"doctor JSON output is {byteCount.ToString(CultureInfo.InvariantCulture)} bytes and exceeds --max-json-bytes {maxJsonBytes.Value.ToString(CultureInfo.InvariantCulture)}.", - CommandExitCodes.UsageError, - "increase --max-json-bytes or narrow the full environment inventory with --env-domain, --env-category, or --env-sensitivity.", - usage: GetDoctorUsage()); - } - - Console.WriteLine(json); - return CommandExitCodes.Success; - } - - private static DoctorDisplayJsonResult BuildDoctorDisplayJson() - { - var maxLineWidth = EnvironmentOptionParser.ReadInt32( - QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable, - LineWidthFormatter.DefaultMaxLineWidth, - minimum: 0, - maximum: LineWidthFormatter.MaxAllowedLineWidth); - - return new DoctorDisplayJsonResult( - Color: BuildDoctorColorDecision(), - Progress: BuildDoctorProgressDecision(), - TerminalHint: BuildDoctorTerminalHint(), - MaxLineWidth: new DoctorDisplayMaxLineWidthJsonResult( - maxLineWidth.Value, - maxLineWidth.SourceKind, - maxLineWidth.Source, - maxLineWidth.Status, - maxLineWidth.UsedFallback, - maxLineWidth.Fallback, - maxLineWidth.Minimum, - maxLineWidth.Maximum, - maxLineWidth.Name, - maxLineWidth.RawValue is null ? "" : ConsoleUi.FormatBoundedValue(maxLineWidth.RawValue)), - AmbiguousWidth: BuildDoctorAmbiguousWidthDecision(), - Truncation: new DoctorDisplayTruncationJsonResult( - LineWidthFormatter.DefaultMaxLineWidth, - LineWidthFormatter.MaxAllowedLineWidth, - ConsoleUi.DefaultDiagnosticValueCharLimit, - "... ")); - } - - private static DoctorDisplayDecisionJsonResult BuildDoctorColorDecision() - { - var enabled = ConsoleUi.ShouldUseColor(); - return ConsoleUi.GetColorModeForDiagnostics() switch - { - ColorMode.Always => new DoctorDisplayDecisionJsonResult(enabled, "flag", "--color=always"), - ColorMode.Never => new DoctorDisplayDecisionJsonResult(enabled, "flag", "--color=never"), - _ when IsDoctorForceColorRequested() => new DoctorDisplayDecisionJsonResult(enabled, "CLICOLOR_FORCE", "forced"), - _ when !string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("NO_COLOR")) => new DoctorDisplayDecisionJsonResult(enabled, "NO_COLOR", "disabled"), - _ when CdidxEnvironment.GetEnvironmentVariable("CLICOLOR") == "0" => new DoctorDisplayDecisionJsonResult(enabled, "CLICOLOR", "disabled"), - _ => new DoctorDisplayDecisionJsonResult(enabled, "terminal", enabled ? "ansi_available" : "not_interactive") - }; - } - - private static DoctorDisplayDecisionJsonResult BuildDoctorProgressDecision() - { - var enabled = ConsoleUi.ShouldUseProgressAnimation(); - var progressOverride = ConsoleUi.GetProgressAnimationOverrideForDiagnostics(); - if (progressOverride.HasValue) - return new DoctorDisplayDecisionJsonResult(enabled, "flag", progressOverride.Value ? "enabled_override" : "--no-progress"); - if (IsTruthyDoctorEnvironmentValue(CdidxEnvironment.GetEnvironmentVariable(ConsoleUi.DisableProgressEnvironmentVariable))) - return new DoctorDisplayDecisionJsonResult(enabled, ConsoleUi.DisableProgressEnvironmentVariable, "disabled"); - if (IsTruthyDoctorEnvironmentValue(CdidxEnvironment.GetEnvironmentVariable(ConsoleUi.PrefersReducedMotionEnvironmentVariable))) - return new DoctorDisplayDecisionJsonResult(enabled, ConsoleUi.PrefersReducedMotionEnvironmentVariable, "reduced_motion"); - return new DoctorDisplayDecisionJsonResult(enabled, "default", "enabled"); - } - - private static DoctorDisplayTerminalHintJsonResult BuildDoctorTerminalHint() - { - var wtSession = FormatDoctorJsonEnvironmentValue("WT_SESSION", redactPaths: false); - var wtProfile = FormatDoctorJsonEnvironmentValue("WT_PROFILE_ID", redactPaths: false); - return new DoctorDisplayTerminalHintJsonResult( - HasDoctorTerminalEnvironmentHint(), - IsDoctorTerminalEnvironmentDisabled(), - Console.IsOutputRedirected, - Console.Out is StringWriter, - FormatDoctorJsonEnvironmentValue("TERM", redactPaths: false), - FormatDoctorJsonEnvironmentValue("TERM_PROGRAM", redactPaths: false), - FormatDoctorJsonEnvironmentValue("CI", redactPaths: false), - wtSession != "" ? wtSession : wtProfile); - } - - private static DoctorDisplayAmbiguousWidthJsonResult BuildDoctorAmbiguousWidthDecision() - { - var locale = CdidxEnvironment.GetEnvironmentVariable("LC_ALL"); - var source = "LC_ALL"; - if (string.IsNullOrEmpty(locale)) - { - locale = CdidxEnvironment.GetEnvironmentVariable("LC_CTYPE"); - source = "LC_CTYPE"; - } - if (string.IsNullOrEmpty(locale)) - { - locale = CdidxEnvironment.GetEnvironmentVariable("LANG"); - source = "LANG"; - } - if (string.IsNullOrEmpty(locale)) - { - locale = ""; - source = "default"; - } - - var wide = locale.StartsWith("ja", StringComparison.OrdinalIgnoreCase) - || locale.StartsWith("zh", StringComparison.OrdinalIgnoreCase) - || locale.StartsWith("ko", StringComparison.OrdinalIgnoreCase); - return new DoctorDisplayAmbiguousWidthJsonResult(wide, source, ConsoleUi.FormatBoundedValue(locale)); - } - - private static bool HasDoctorTerminalEnvironmentHint() - { - if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("WT_SESSION"))) - return true; - if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("WT_PROFILE_ID"))) - return true; - if (!string.IsNullOrEmpty(CdidxEnvironment.GetEnvironmentVariable("TERM_PROGRAM"))) - return true; - - var term = CdidxEnvironment.GetEnvironmentVariable("TERM"); - return !string.IsNullOrWhiteSpace(term) - && !term.Equals("dumb", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsDoctorTerminalEnvironmentDisabled() - => string.Equals(CdidxEnvironment.GetEnvironmentVariable("TERM"), "dumb", StringComparison.OrdinalIgnoreCase) - || IsDoctorCiEnvironment(); - - private static bool IsDoctorCiEnvironment() - { - var ci = CdidxEnvironment.GetEnvironmentVariable("CI"); - return !string.IsNullOrEmpty(ci) - && !ci.Equals("0", StringComparison.OrdinalIgnoreCase) - && !ci.Equals("false", StringComparison.OrdinalIgnoreCase) - && !ci.Equals("no", StringComparison.OrdinalIgnoreCase) - && !ci.Equals("off", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsDoctorForceColorRequested() - { - var force = CdidxEnvironment.GetEnvironmentVariable("CLICOLOR_FORCE"); - return !string.IsNullOrEmpty(force) && force != "0"; - } - - private static bool IsTruthyDoctorEnvironmentValue(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return false; - - return value.Trim() is not ("0" or "false" or "False" or "FALSE" or "no" or "No" or "NO"); - } - - private static void WriteEnvironmentInventory(IReadOnlyList items) - { - Console.WriteLine("environment_inventory:"); - foreach (var item in items) - { - var firstLocation = item.Locations.FirstOrDefault(); - var location = firstLocation is null - ? "" - : $"{firstLocation.Path}:{firstLocation.Line}"; - Console.WriteLine($" {item.Name}"); - Console.WriteLine(ConsoleUi.FormatSummaryLine("domain", item.Domain, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("category", item.Category, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("sensitivity", item.Sensitivity, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("policy", item.Policy, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("default", item.DefaultBehavior, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("config", item.ConfigFileSupported, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("invalid", item.InvalidValueBehavior, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("location", location, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("description", item.Description, indent: " ")); - } - } - - private static void WriteEnvironmentInventorySummary() - { - var summary = EnvironmentVariableInventory.BuildSummary(); - Console.WriteLine("environment_inventory_summary:"); - Console.WriteLine(ConsoleUi.FormatSummaryLine("total", summary.Total, indent: " ")); - WriteEnvironmentInventorySummaryBuckets("by_domain", summary.ByDomain); - WriteEnvironmentInventorySummaryBuckets("by_sensitivity", summary.BySensitivity); - WriteEnvironmentInventorySummaryBuckets("by_category", summary.ByCategory); - Console.WriteLine(ConsoleUi.FormatSummaryLine("full_detail", "cdidx doctor --env-inventory=full", indent: " ")); - } - - private static void WriteEnvironmentInventorySummaryBuckets( - string title, - IReadOnlyList buckets) - { - Console.WriteLine($" {title}:"); - foreach (var bucket in buckets) - Console.WriteLine(ConsoleUi.FormatSummaryLine(bucket.Name, bucket.Count, indent: " ")); - } - - private static IEnumerable EnumerateCdidxEnvironmentJson(bool redactPaths) - { - var rows = CdidxEnvironment.EnumerateProcessEnvironmentVariables() - .Where(e => e.Key.StartsWith("CDIDX_", StringComparison.Ordinal)) - .OrderBy(e => e.Key, StringComparer.Ordinal); - foreach (var row in rows) - { - var sensitive = IsSensitiveEnvironmentName(row.Key); - var value = sensitive - ? "" - : string.IsNullOrEmpty(row.Value) - ? "" - : RedactDoctorPath(row.Value, redactPaths); - var bounded = ConsoleUi.BoundDisplayText(value); - yield return new DoctorEnvironmentVariableJsonResult(row.Key, bounded.Text, sensitive, bounded.Truncated, bounded.OriginalLength); - } - } - - private static string FormatDoctorJsonEnvironmentValue(string name, bool redactPaths) - { - var value = CdidxEnvironment.GetProcessEnvironmentVariable(name); - return value == null ? "" : ConsoleUi.FormatBoundedValue(RedactDoctorPath(value, redactPaths)); - } - - private static string RedactDoctorPath(string value, bool redactPaths) - => redactPaths ? DiagnosticRedactor.RedactSensitiveText(value, "[redacted]", redactPaths: true) : value; - - private static IEnumerable<(string Key, string Value)> EnumerateCdidxEnvironment() - { - var rows = CdidxEnvironment.EnumerateProcessEnvironmentVariables() - .Where(e => e.Key.StartsWith("CDIDX_", StringComparison.Ordinal)) - .OrderBy(e => e.Key, StringComparer.Ordinal); - var any = false; - foreach (var row in rows) - { - any = true; - yield return (row.Key, IsSensitiveEnvironmentName(row.Key) ? "" : string.IsNullOrEmpty(row.Value) ? "" : ConsoleUi.FormatBoundedValue(row.Value)); - } - - if (!any) - yield return ("", ""); - } - - private static string FormatDoctorEnvironmentValue(string? value) - => value == null ? "" : ConsoleUi.FormatBoundedValue(value); - - private static bool IsSensitiveEnvironmentName(string name) => - name.Contains("TOKEN", StringComparison.OrdinalIgnoreCase) - || name.Contains("PASSWORD", StringComparison.OrdinalIgnoreCase) - || name.Contains("PASSWD", StringComparison.OrdinalIgnoreCase) - || name.Contains("PWD", StringComparison.OrdinalIgnoreCase) - || name.Contains("SECRET", StringComparison.OrdinalIgnoreCase) - || name.Contains("AUTH", StringComparison.OrdinalIgnoreCase) - || name.Contains("APIKEY", StringComparison.OrdinalIgnoreCase) - || name.Contains("API_KEY", StringComparison.OrdinalIgnoreCase) - || name.Contains("PRIVATE_KEY", StringComparison.OrdinalIgnoreCase) - || name.EndsWith("_KEY", StringComparison.OrdinalIgnoreCase) - || name.Contains("CREDENTIAL", StringComparison.OrdinalIgnoreCase); - - internal static void EnsureRedirectedStdoutUsesUtf8() - { - using var ownership = ConsoleStreamOwnership.Enter(); - if (!Console.IsOutputRedirected || Console.Out is StringWriter || Console.Out.GetType().Assembly != typeof(Console).Assembly) - return; - - var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - if (Console.Out.Encoding.CodePage == utf8NoBom.CodePage) - return; - - var writer = new StreamWriter(Console.OpenStandardOutput(), utf8NoBom) - { - AutoFlush = true - }; - Console.SetOut(TextWriter.Synchronized(writer)); - } - - internal static bool ContainsJsonOutputFlag(IEnumerable args) - { - var passthrough = false; - foreach (var arg in args) - { - if (passthrough) - continue; - if (arg == "--") - { - passthrough = true; - continue; - } - if (arg == "--json" - || arg.StartsWith("--json=", StringComparison.Ordinal) - || arg == JsonEnvelopeWrapper.EnvelopeFlag) - return true; - } - - return false; - } - - private enum QueryCommandTokenRole - { - None, - CommandOptionValue, - FirstQueryLiteral, - } - - private static string[] InsertQueryLiteralSentinelForNonLogGlobalOption(string commandName, string[] subArgs) - { - if (!CommandAcceptsQueryLiteral(commandName)) - return subArgs; - - for (var i = 0; i < subArgs.Length; i++) - { - if (subArgs[i] == "--") - return subArgs; - if (!IsNonLogGlobalOptionToken(subArgs[i])) - continue; - if (GetQueryCommandTokenRole(commandName, subArgs, i) != QueryCommandTokenRole.FirstQueryLiteral) - continue; - - var rewritten = new List(subArgs.Length + 1); - for (var j = 0; j < i; j++) - rewritten.Add(subArgs[j]); - rewritten.Add("--"); - for (var j = i; j < subArgs.Length; j++) - rewritten.Add(subArgs[j]); - return rewritten.ToArray(); - } - - return subArgs; - } - - private static bool ShouldPreserveQueryCommandToken(string[] args, int index) - { - var role = GetQueryCommandTokenRole(args, index); - return ShouldPreserveQueryCommandToken(args, index, role); - } - - private static bool ShouldPreserveQueryCommandToken(string commandName, string[] subArgs, int index) - { - var role = GetQueryCommandTokenRole(commandName, subArgs, index); - return ShouldPreserveQueryCommandToken(subArgs, index, role); - } - - private static bool ShouldPreserveQueryCommandToken(string[] args, int index, QueryCommandTokenRole role) - { - if (role == QueryCommandTokenRole.CommandOptionValue) - return true; - if (role != QueryCommandTokenRole.FirstQueryLiteral) - return false; - return !IsSeparatedNonLogGlobalValueOptionWithConsumableValue(args, index); - } - - private static bool IsSeparatedNonLogGlobalValueOptionWithConsumableValue(string[] args, int index) - { - if (index + 1 >= args.Length) - return false; - - var value = args[index + 1]; - return args[index] switch - { - "--color" => ConsoleUi.TryParseColorMode(value, out _), - "--palette" => ConsoleUi.TryParseColorPalette(value, out _), - "--metrics" => !string.IsNullOrWhiteSpace(value) && !value.StartsWith("-", StringComparison.Ordinal), - "--trace" => value is "none" or "stderr" or "file", - _ => false, - }; - } - - private static QueryCommandTokenRole GetQueryCommandTokenRole(string[] args, int index) - { - if (!TryFindCommandBefore(args, index, out var commandIndex, out var commandName)) - return QueryCommandTokenRole.None; - - return GetQueryCommandTokenRole(commandName, args[(commandIndex + 1)..], index - commandIndex - 1); - } - - private static bool TryFindCommandBefore(string[] args, int index, out int commandIndex, out string commandName) - { - commandIndex = -1; - commandName = string.Empty; - - for (var i = 0; i < index; i++) - { - var arg = args[i]; - if (arg == "--") - return false; - if (TryGetInlineOptionName(arg, out var inlineName) && TopLevelValueOptionNames.Contains(inlineName)) - continue; - if (TopLevelValueOptionNames.Contains(arg)) - { - i++; - continue; - } - if (NonLogGlobalOptionNames.Contains(arg)) - continue; - if (!CliFlagSchema.AllCommands.Contains(arg)) - return false; - - commandIndex = i; - commandName = arg; - return true; - } - - return false; - } - - private static QueryCommandTokenRole GetQueryCommandTokenRole(string commandName, string[] subArgs, int targetIndex) - { - var (withValues, flagOnly) = CliFlagSchema.GetParserFlagsPartitionedByValueBearing(commandName); - if (targetIndex > 0) - { - var previousArg = NormalizeCommandOptionToken(subArgs[targetIndex - 1], withValues, flagOnly, out var previousHasInlineValue); - if (!previousHasInlineValue && withValues.Contains(previousArg)) - return QueryCommandTokenRole.CommandOptionValue; - } - - if (!CommandAcceptsQueryLiteral(commandName)) - return QueryCommandTokenRole.None; - - if (IsInspectPathLineMode(commandName, subArgs)) - { - var targetArg = NormalizeCommandOptionToken(subArgs[targetIndex], withValues, flagOnly, out _); - if (withValues.Contains(targetArg) || flagOnly.Contains(targetArg)) - return QueryCommandTokenRole.None; - } - - for (var i = 0; i < targetIndex; i++) - { - var arg = subArgs[i]; - if (arg == "--") - return i + 1 == targetIndex ? QueryCommandTokenRole.FirstQueryLiteral : QueryCommandTokenRole.None; - - var normalizedArg = NormalizeCommandOptionToken(arg, withValues, flagOnly, out var hasInlineValue); - if (withValues.Contains(normalizedArg)) - { - if (hasInlineValue) - { - if (normalizedArg == "--query") - return QueryCommandTokenRole.None; - continue; - } - if (i + 1 == targetIndex) - return QueryCommandTokenRole.CommandOptionValue; - if (normalizedArg == "--query") - return QueryCommandTokenRole.None; - if (i + 1 < targetIndex) - { - i++; - continue; - } - - return QueryCommandTokenRole.None; - } - - if (flagOnly.Contains(normalizedArg)) - continue; - - return QueryCommandTokenRole.None; - } - - return QueryCommandTokenRole.FirstQueryLiteral; - } - - private static bool IsInspectPathLineMode(string commandName, string[] subArgs) - { - if (!string.Equals(commandName, "inspect", StringComparison.Ordinal)) - return false; - - var (withValues, flagOnly) = CliFlagSchema.GetParserFlagsPartitionedByValueBearing(commandName); - var pathSeen = false; - var lineSeen = false; - for (var i = 0; i < subArgs.Length; i++) - { - var arg = subArgs[i]; - if (arg == "--") - break; - - var normalizedArg = NormalizeCommandOptionToken(arg, withValues, flagOnly, out var hasInlineValue); - if (!withValues.Contains(normalizedArg)) - continue; - - pathSeen |= normalizedArg == "--path"; - lineSeen |= normalizedArg == "--line"; - if (!hasInlineValue && i + 1 < subArgs.Length) - i++; - } - - return pathSeen && lineSeen; - } - - private static bool CommandAcceptsQueryLiteral(string commandName) => - CliFlagSchema.GetAcceptedFlagNamesForCommand(commandName).Contains("--query"); - - private static bool IsNonLogGlobalOptionToken(string arg) - { - if (NonLogGlobalOptionNames.Contains(arg)) - return true; - return TryGetInlineOptionName(arg, out var name) && NonLogGlobalOptionNames.Contains(name); - } - - private static string NormalizeCommandOptionToken( - string arg, - IReadOnlySet withValues, - IReadOnlySet flagOnly, - out bool hasInlineValue) - { - hasInlineValue = false; - if (!TryGetInlineOptionName(arg, out var name)) - return arg; - - if (withValues.Contains(name)) - { - hasInlineValue = true; - return name; - } - - if (flagOnly.Contains(name) && string.Equals(name, "--json", StringComparison.Ordinal)) - return name; - - return arg; - } - - private static bool TryGetInlineOptionName(string arg, out string name) - { - var equalsIndex = arg.IndexOf('='); - if (equalsIndex <= 0) - { - name = string.Empty; - return false; - } - - name = arg[..equalsIndex]; - return name.StartsWith("-", StringComparison.Ordinal); - } - - internal static bool TryConsumeQuietFlag(ref string[] args) - { - if (args.Length == 0) - return false; - - var kept = new List(args.Length); - var quiet = false; - var passthrough = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (passthrough) - { - kept.Add(arg); - continue; - } - if (arg == "--") - { - passthrough = true; - kept.Add(arg); - continue; - } - if (arg is "--quiet" or "-q" or "--silent" - && GetQueryCommandTokenRole(args, i) != QueryCommandTokenRole.CommandOptionValue) - { - quiet = true; - continue; - } - if (ShouldPreserveQueryCommandToken(args, i)) - { - kept.Add(arg); - continue; - } - - kept.Add(arg); - } - - args = kept.ToArray(); - return quiet; - } - - internal static bool TryConsumePrettyJsonFlag(ref string[] args) - { - if (args.Length == 0) - return false; - - var hasExplicitPrettyJsonOutput = HasExplicitPrettyJsonOutputSelection(args); - var kept = new List(args.Length); - var pretty = false; - var passthrough = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (passthrough) - { - kept.Add(arg); - continue; - } - if (arg == "--") - { - passthrough = true; - kept.Add(arg); - continue; - } - if (arg == "--pretty" - && GetQueryCommandTokenRole(args, i) != QueryCommandTokenRole.CommandOptionValue - && hasExplicitPrettyJsonOutput) - { - pretty = true; - continue; - } - if (ShouldPreserveQueryCommandToken(args, i)) - { - kept.Add(arg); - continue; - } - if (arg == "--pretty") - { - pretty = true; - continue; - } - - kept.Add(arg); - } - - args = kept.ToArray(); - return pretty; - } - - internal static bool TryConsumeGlobalLogFlags( - ref string[] args, - out IReadOnlyDictionary environment, - out string error) - { - var overrides = new Dictionary(StringComparer.Ordinal); - environment = overrides; - error = string.Empty; - var kept = new List(args.Length); - var passthrough = false; - var searchCommandSeen = false; - var searchQuerySeen = false; - var pendingSearchOptionValue = false; - var pendingSearchOptionValueIsQuery = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (passthrough) - { - kept.Add(arg); - continue; - } - - if (searchCommandSeen && pendingSearchOptionValue) - { - if (pendingSearchOptionValueIsQuery) - searchQuerySeen = true; - pendingSearchOptionValue = false; - pendingSearchOptionValueIsQuery = false; - kept.Add(arg); - continue; - } - - if (arg == "--") - { - passthrough = true; - kept.Add(arg); - continue; - } - - if (searchCommandSeen && !searchQuerySeen && IsSearchGlobalLogFlagLiteral(args, i, arg)) - { - searchQuerySeen = true; - kept.Add(arg); - continue; - } - - if (TryConsumeValueFlag(args, ref i, arg, "--log-format", out var format)) - { - if (format is not ("text" or "json")) - { - error = "--log-format must be `text` or `json`."; - return false; - } - overrides[GlobalToolLog.LogFormatEnvironmentVariable] = format; - continue; - } - - if (TryConsumeValueFlag(args, ref i, arg, "--log-retain-count", out var retainCount)) - { - if (!int.TryParse(retainCount, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed < 1) - { - error = "--log-retain-count must be a positive integer."; - return false; - } - overrides[GlobalToolLog.LogRetainEnvironmentVariable] = parsed.ToString(CultureInfo.InvariantCulture); - continue; - } - - if (TryConsumeValueFlag(args, ref i, arg, "--log-max-size-mb", out var maxSizeMb)) - { - if (!int.TryParse(maxSizeMb, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) - || parsed is < 1 or > GlobalToolLog.MaxLogSizeMb) - { - error = $"--log-max-size-mb must be an integer between 1 and {GlobalToolLog.MaxLogSizeMb}."; - return false; - } - overrides[GlobalToolLog.LogMaxSizeMbEnvironmentVariable] = parsed.ToString(CultureInfo.InvariantCulture); - continue; - } - - if (arg == "search") - { - searchCommandSeen = true; - kept.Add(arg); - continue; - } - kept.Add(arg); - if (searchCommandSeen && !searchQuerySeen) - TrackSearchQueryState(args, i, arg, ref searchQuerySeen, ref pendingSearchOptionValue, ref pendingSearchOptionValueIsQuery); - } - - args = kept.ToArray(); - return true; - } - - private static bool IsSearchGlobalLogFlagLiteral(string[] args, int index, string arg) - { - static bool NextTokenLooksLikeSearchOption(string[] args, int index) - => index + 1 >= args.Length || args[index + 1].StartsWith("-", StringComparison.Ordinal); - - if (arg is "--log-format" or "--log-retain-count" or "--log-max-size-mb") - return NextTokenLooksLikeSearchOption(args, index); - - return (arg.StartsWith("--log-format=", StringComparison.Ordinal) || - arg.StartsWith("--log-retain-count=", StringComparison.Ordinal) || - arg.StartsWith("--log-max-size-mb=", StringComparison.Ordinal)) && - NextTokenLooksLikeSearchOption(args, index); - } - - private static void TrackSearchQueryState( - string[] args, - int index, - string arg, - ref bool searchQuerySeen, - ref bool pendingSearchOptionValue, - ref bool pendingSearchOptionValueIsQuery) - { - if (TryClassifySearchValueTakingOption(arg, out var hasInlineValue, out var valueIsQuery)) - { - if (hasInlineValue) - { - if (valueIsQuery) - searchQuerySeen = true; - } - else if (index + 1 < args.Length) - { - pendingSearchOptionValue = true; - pendingSearchOptionValueIsQuery = valueIsQuery; - } - return; - } - - if (!arg.StartsWith("-", StringComparison.Ordinal)) - searchQuerySeen = true; - } - - private static bool TryClassifySearchValueTakingOption(string arg, out bool hasInlineValue, out bool valueIsQuery) - { - hasInlineValue = false; - valueIsQuery = false; - - var separator = arg.IndexOf('='); - var optionName = separator > 0 ? arg[..separator] : arg; - if (!SearchValueTakingOptions.Contains(optionName)) - return false; - - hasInlineValue = separator > 0; - valueIsQuery = optionName == "--query"; - return true; - } - - private static readonly HashSet SearchValueTakingOptions = - [ - "--db", - "--color", - "--data-dir", - "--metrics", - "--palette", - "--trace", - "--limit", - "--top", - "--lang", - "--kind", - "--visibility", - "--exclude-visibility", - "--since", - "--start", - "--end", - "--before", - "--after", - "--name", - "--snippet-lines", - "--snippet-focus", - "--path", - "--require-before", - "--require-after", - "--reject-before", - "--reject-after", - "--guard-window", - "--guard-scope", - "--project", - "--solution", - "--exclude-path", - "--max-hops", - "--depth", - "--query", - "--group-by", - "--focus-line", - "--focus-column", - "--focus-length", - "--max-line-width", - "--stale-after", - "--explain", - "--rank-by", - "--slow-query-ms", - "--format", - "--min-entrypoint-confidence", - "--sections", - ]; - - private static bool TryConsumeValueFlag(string[] args, ref int index, string arg, string flag, out string value) - { - value = string.Empty; - if (arg.StartsWith(flag + "=", StringComparison.Ordinal)) - { - value = arg[(flag.Length + 1)..].Trim(); - return true; - } - - if (arg != flag) - return false; - - if (index + 1 >= args.Length) - return true; - - value = args[++index].Trim(); - return true; - } - - private static bool IsTruthyEnvironmentVariable(string name) - { - var value = CdidxEnvironment.GetEnvironmentVariable(name); - return value != null - && !string.Equals(value, "0", StringComparison.OrdinalIgnoreCase) - && !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) - && !string.Equals(value, "no", StringComparison.OrdinalIgnoreCase) - && !string.Equals(value, "off", StringComparison.OrdinalIgnoreCase); - } - - internal static int MapCodeIndexExceptionExitCode(string code) => code switch - { - CommandErrorCodes.DbNotFound => CommandExitCodes.NotFound, - CommandErrorCodes.CheckpointNotFound => CommandExitCodes.NotFound, - CommandErrorCodes.DbLocked => CommandExitCodes.TransientDatabaseError, - CommandErrorCodes.DbNotWritable => CommandExitCodes.DatabaseError, - CommandErrorCodes.DbIntegrityFailed => CommandExitCodes.DatabaseError, - CommandErrorCodes.SchemaTooNew => CommandExitCodes.DatabaseError, - CommandErrorCodes.TempStoreExhausted => CommandExitCodes.DatabaseError, - CommandErrorCodes.DbError => CommandExitCodes.DatabaseError, - CommandErrorCodes.DirectoryNotFound => CommandExitCodes.NotFound, - CommandErrorCodes.FeatureUnavailable => CommandExitCodes.FeatureUnavailable, - CommandErrorCodes.UsageError => CommandExitCodes.InvalidArgument, - CommandErrorCodes.Interrupted => CommandExitCodes.CancelledBySignal, - _ => CommandExitCodes.DatabaseError, - }; - - internal static int MapUnhandledExceptionExitCode(Exception ex) - { - var sqliteException = FindSqliteException(ex); - if (sqliteException is null) - return CommandExitCodes.UnhandledException; - - return sqliteException.SqliteErrorCode switch - { - 5 or 6 or 8 => CommandExitCodes.TransientDatabaseError, - _ => CommandExitCodes.DatabaseError, - }; - } - - private static SqliteException? FindSqliteException(Exception ex) - { - if (ex is SqliteException sqliteException) - return sqliteException; - if (ex is AggregateException aggregate) - { - foreach (var inner in aggregate.InnerExceptions) - { - var found = FindSqliteException(inner); - if (found is not null) - return found; - } - } - - return ex.InnerException is null ? null : FindSqliteException(ex.InnerException); - } - - private sealed class QuietStderrScope : IDisposable - { - private readonly TextWriter _originalError; - private readonly TextWriter _replacementError; - private readonly IDisposable _ownership; - - private QuietStderrScope( - TextWriter originalError, - TextWriter replacementError, - IDisposable ownership) - { - _originalError = originalError; - _replacementError = replacementError; - _ownership = ownership; - } - - public static QuietStderrScope Start() - { - var ownership = ConsoleStreamOwnership.Enter(); - try - { - var originalError = Console.Error; - var replacementError = new ErrorOnlyTextWriter(originalError); - Console.SetError(replacementError); - return new QuietStderrScope(originalError, replacementError, ownership); - } - catch - { - ownership.Dispose(); - throw; - } - } - - public void Dispose() - { - try - { - _replacementError.Flush(); - ConsoleStreamOwnership.RestoreError(_originalError); - } - finally - { - _ownership.Dispose(); - } - } - } - - private sealed class ErrorOnlyTextWriter(TextWriter inner) : TextWriter - { - private readonly StringBuilder _lineBuffer = new(); - - public override Encoding Encoding => inner.Encoding; - - public override void Write(char value) - { - if (value == '\r') - return; - - if (value == '\n') - { - FlushBufferedLine(); - return; - } - - _lineBuffer.Append(value); - } - - public override void Write(string? value) - { - if (value == null) - return; - - foreach (var ch in value) - Write(ch); - } - - public override void WriteLine(string? value) - { - Write(value); - FlushBufferedLine(); - } - - public override void Flush() - { - FlushBufferedLine(); - inner.Flush(); - } - - private void FlushBufferedLine() - { - if (_lineBuffer.Length == 0) - return; - - var line = _lineBuffer.ToString(); - _lineBuffer.Clear(); - if (IsErrorLine(line)) - inner.WriteLine(line); - } - - private static bool IsErrorLine(string line) - => line.StartsWith("Error", StringComparison.Ordinal); - } - - internal static bool TryConsumeColorFlag(ref string[] args, out string error) - { - error = string.Empty; - ConsoleUi.SetColorMode(ColorMode.Auto); - if (args.Length == 0) - return true; - - var kept = new List(args.Length); - ColorMode? requested = null; - var passthrough = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - - // After a `--` token, leave everything alone so subcommands keep - // their query-escape semantics (e.g. `cdidx search -- --color=auto`). - if (passthrough) - { - kept.Add(arg); - continue; - } - if (arg == "--") - { - passthrough = true; - kept.Add(arg); - continue; - } - if (ShouldPreserveQueryCommandToken(args, i)) - { - kept.Add(arg); - continue; - } - - string? rawValue = null; - if (arg == "--color") - { - if (i + 1 >= args.Length) - { - error = "Error: --color requires a value (one of `auto`, `always`, `never`)."; - return false; - } - rawValue = args[++i]; - } - else if (arg.StartsWith("--color=", StringComparison.Ordinal)) - { - rawValue = arg.Substring("--color=".Length); - } - else - { - kept.Add(arg); - continue; - } - - if (!ConsoleUi.TryParseColorMode(rawValue, out var mode)) - { - error = $"Error: invalid --color value `{rawValue}`."; - return false; - } - requested = mode; - } - - if (requested.HasValue) - ConsoleUi.SetColorMode(requested.Value); - args = kept.ToArray(); - return true; - } - - internal static void TryConsumeAsciiFlag(ref string[] args) - { - ConsoleUi.SetAsciiOutput(false); - if (args.Length == 0) - return; - - var kept = new List(args.Length); - var passthrough = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (passthrough) - { - kept.Add(arg); - continue; - } - if (arg == "--") - { - passthrough = true; - kept.Add(arg); - continue; - } - if (ShouldPreserveQueryCommandToken(args, i)) - { - kept.Add(arg); - continue; - } - if (arg == "--ascii") - { - ConsoleUi.SetAsciiOutput(true); - continue; - } - - kept.Add(arg); - } - - args = kept.ToArray(); - } - - internal static void TryConsumeNoProgressFlag(ref string[] args) - { - ConsoleUi.SetProgressAnimationEnabled(null); - if (args.Length == 0) - return; - - var kept = new List(args.Length); - var passthrough = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (passthrough) - { - kept.Add(arg); - continue; - } - if (arg == "--") - { - passthrough = true; - kept.Add(arg); - continue; - } - if (ShouldPreserveQueryCommandToken(args, i)) - { - kept.Add(arg); - continue; - } - if (arg == "--no-progress") - { - ConsoleUi.SetProgressAnimationEnabled(false); - continue; - } - - kept.Add(arg); - } - - args = kept.ToArray(); - } - - // Strip `--palette ` / `--palette=` from `args` before - // subcommand parsing. Mirrors `TryConsumeColorFlag` so any subcommand - // (CLI or MCP) inherits the chosen ANSI palette without re-parsing. - // Anything after `--` is passed through verbatim so subcommand - // query-escape semantics are preserved (#1569). - internal static bool TryConsumePaletteFlag(ref string[] args, out string error) - { - error = string.Empty; - ConsoleUi.SetColorPalette(null); - if (args.Length == 0) - return true; - - var kept = new List(args.Length); - ColorPalette? requested = null; - var passthrough = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - - if (passthrough) - { - kept.Add(arg); - continue; - } - if (arg == "--") - { - passthrough = true; - kept.Add(arg); - continue; - } - if (ShouldPreserveQueryCommandToken(args, i)) - { - kept.Add(arg); - continue; - } - - string? rawValue = null; - if (arg == "--palette") - { - if (i + 1 >= args.Length) - { - error = "Error: --palette requires a value (one of `basic`, `256`, `truecolor`)."; - return false; - } - rawValue = args[++i]; - } - else if (arg.StartsWith("--palette=", StringComparison.Ordinal)) - { - rawValue = arg.Substring("--palette=".Length); - } - else - { - kept.Add(arg); - continue; - } - - if (!ConsoleUi.TryParseColorPalette(rawValue, out var palette)) - { - error = $"Error: invalid --palette value `{rawValue}`."; - return false; - } - requested = palette; - } - - if (requested.HasValue) - ConsoleUi.SetColorPalette(requested.Value); - args = kept.ToArray(); - return true; - } - - // Strip the `--debug-unsafe` opt-in from `args` before subcommand parsing. - // The flag must be passed every command invocation (not via env var) so a stale - // CDIDX_DEBUG=unsafe in a shell profile or CI env cannot quietly leak indexed - // source content (#1530). Anything after `--` is left untouched so subcommand - // query strings keep their literal semantics. - // サブコマンド処理前に `--debug-unsafe` を取り除く。環境変数 CDIDX_DEBUG=unsafe が - // シェルプロファイル / CI に残った状態で索引済みソースが漏れないよう、明示的にフラグを - // 毎回渡す運用にする(#1530)。`--` 以降はサブコマンドのクエリ文字列を保つため触らない。 - internal static bool TryConsumeDebugUnsafeFlag(ref string[] args) - { - if (args.Length == 0) - return false; - - var kept = new List(args.Length); - var passthrough = false; - var seen = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (passthrough) - { - kept.Add(arg); - continue; - } - if (arg == "--") - { - passthrough = true; - kept.Add(arg); - continue; - } - if (ShouldPreserveQueryCommandToken(args, i)) - { - kept.Add(arg); - continue; - } - if (arg == "--debug-unsafe") - { - seen = true; - continue; - } - kept.Add(arg); - } - - if (seen) - { - DbDebug.EnableUnsafeForProcess(); - args = kept.ToArray(); - } - return seen; - } - - internal static bool TryConsumeStrictVersionFlag(ref string[] args, out bool strictVersion, out string error) - { - strictVersion = IsTruthyEnvironmentVariable("CDIDX_STRICT_VERSION"); - error = string.Empty; - if (args.Length == 0) - return true; - - var kept = new List(args.Length); - var passthrough = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (passthrough) - { - kept.Add(arg); - continue; - } - if (arg == "--") - { - passthrough = true; - kept.Add(arg); - continue; - } - if (ShouldPreserveQueryCommandToken(args, i)) - { - kept.Add(arg); - continue; - } - if (arg == "--strict-version") - { - strictVersion = true; - continue; - } - if (arg.StartsWith("--strict-version=", StringComparison.Ordinal)) - { - error = "Error: --strict-version does not accept a value."; - return false; - } - kept.Add(arg); - } - - args = kept.ToArray(); - return true; - } - - private static int CheckWorkspaceVersionPin(string appVersion, string startDirectory, bool strictVersion) - { - var pinPath = FindWorkspaceVersionPin(startDirectory); - if (pinPath == null) - return CommandExitCodes.Success; - - if (!TryReadWorkspaceVersionPin(pinPath, out var required, out var warning)) - { - CommandErrorWriter.WriteStderr(warning); - return CommandExitCodes.Success; - } - - if (string.IsNullOrWhiteSpace(required) || VersionsMatch(required, appVersion)) - return CommandExitCodes.Success; - - var message = $"workspace requires cdidx v{NormalizeVersion(required)}, but this binary is v{NormalizeVersion(appVersion)} ({pinPath})."; - if (!strictVersion) - { - CommandErrorWriter.WriteStderr($"Warning: {message}"); - return CommandExitCodes.Success; - } - - CommandErrorWriter.WriteStderr($"Error: {message}"); - CommandErrorWriter.WriteStderr("Hint: rerun without --strict-version to warn only, or install the pinned cdidx version for this workspace."); - return CommandExitCodes.ExUsage; - } - - private static bool TryReadWorkspaceVersionPin(string pinPath, out string required, out string warning) - { - required = string.Empty; - warning = string.Empty; - - try - { - var bytes = ReadWorkspaceVersionPinBytes(pinPath); - if (bytes.Length > WorkspaceVersionPinMaxBytes) - { - warning = BuildWorkspaceVersionPinWarning($"file exceeds {WorkspaceVersionPinMaxBytes} bytes"); - return false; - } - - return TryParseWorkspaceVersionPin(DecodeWorkspaceVersionPinBytes(bytes), out required, out warning); - } - catch (Exception ex) - { - warning = BuildWorkspaceVersionPinReadWarning(ex); - return false; - } - } - - private static byte[] ReadWorkspaceVersionPinBytes(string pinPath) - { - var buffer = new byte[WorkspaceVersionPinMaxBytes + 1]; - var totalRead = 0; - - using var stream = new FileStream( - pinPath, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite | FileShare.Delete, - bufferSize: Math.Min(1024, buffer.Length), - FileOptions.SequentialScan); - - while (totalRead < buffer.Length) - { - var read = stream.Read(buffer, totalRead, buffer.Length - totalRead); - if (read == 0) - break; - totalRead += read; - } - - if (totalRead == buffer.Length) - return buffer; - - var result = new byte[totalRead]; - Array.Copy(buffer, result, totalRead); - return result; - } - - private static string DecodeWorkspaceVersionPinBytes(byte[] bytes) - { - using var stream = new MemoryStream(bytes, writable: false); - using var reader = new StreamReader( - stream, - Encoding.UTF8, - detectEncodingFromByteOrderMarks: true, - bufferSize: Math.Min(1024, Math.Max(1, bytes.Length))); - return reader.ReadToEnd(); - } - - private static bool TryParseWorkspaceVersionPin(string content, out string required, out string warning) - { - required = string.Empty; - warning = string.Empty; - - using var reader = new StringReader(content); - var skippedBlankLines = 0; - var lineNumber = 0; - string? line; - while ((line = reader.ReadLine()) != null) - { - lineNumber++; - if (line.Length > WorkspaceVersionPinMaxLineChars) - { - warning = BuildWorkspaceVersionPinWarning($"line {lineNumber} exceeds {WorkspaceVersionPinMaxLineChars} characters"); - return false; - } - - if (string.IsNullOrWhiteSpace(line)) - { - skippedBlankLines++; - if (skippedBlankLines > WorkspaceVersionPinMaxSkippedBlankLines) - { - warning = BuildWorkspaceVersionPinWarning($"more than {WorkspaceVersionPinMaxSkippedBlankLines} leading blank lines"); - return false; - } - - continue; - } - - required = line.Trim(); - return true; - } - - return true; - } - - internal static string BuildWorkspaceVersionPinReadWarningForTesting(Exception exception) - => BuildWorkspaceVersionPinReadWarning(exception); - - private static string BuildWorkspaceVersionPinWarning(string reason) - => $"Warning: ignoring .cdidx-version: {ConsoleUi.FormatBoundedValue(reason)}."; - - private static string BuildWorkspaceVersionPinReadWarning(Exception exception) - { - var reason = exception switch - { - UnauthorizedAccessException => "permission denied", - ArgumentException or NotSupportedException or PathTooLongException => "invalid path", - IOException => "read failed", - _ => "read failed", - }; - return $"Warning: could not read .cdidx-version: {reason}."; - } - - internal static string? FindWorkspaceVersionPin(string startDirectory) - { - var current = Path.GetFullPath(startDirectory); - if (File.Exists(current)) - current = Path.GetDirectoryName(current) ?? current; - - while (!string.IsNullOrWhiteSpace(current)) - { - var candidate = Path.Combine(current, ".cdidx-version"); - if (File.Exists(candidate)) - return candidate; - - var parent = Directory.GetParent(current); - if (parent == null) - return null; - current = parent.FullName; - } - - return null; - } - - private static bool VersionsMatch(string required, string actual) - => string.Equals(NormalizeVersion(required), NormalizeVersion(actual), StringComparison.OrdinalIgnoreCase); - - private static string NormalizeVersion(string value) - { - var trimmed = value.Trim(); - return trimmed.StartsWith('v') || trimmed.StartsWith('V') ? trimmed[1..] : trimmed; - } - - // Strip `--metrics ` / `--metrics=` from the global args before subcommand - // parsing so any command (CLI or MCP) inherits the same JSONL metrics sink without - // each subcommand re-declaring the flag. Falls back to the CDIDX_METRICS env var when - // the explicit flag is absent. Anything after `--` is left untouched to preserve - // subcommand query-escape semantics (#1549). - // サブコマンド解析前に `--metrics ` / `--metrics=` を取り除き、CLI/MCPいずれの - // コマンドでも同じJSONLシンクを継承させる。明示フラグが無い場合は CDIDX_METRICS 環境変数に - // フォールバック。`--` 以降はサブコマンドのクエリエスケープ意味論を保つため触らない (#1549)。 - internal static bool TryConsumeMetricsFlag(ref string[] args, out string? path, out string error) - { - path = null; - error = string.Empty; - if (args.Length == 0) - return true; - - var kept = new List(args.Length); - string? requested = null; - var passthrough = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (passthrough) - { - kept.Add(arg); - continue; - } - if (arg == "--") - { - passthrough = true; - kept.Add(arg); - continue; - } - if (ShouldPreserveQueryCommandToken(args, i)) - { - kept.Add(arg); - continue; - } - - string? rawValue = null; - if (arg == "--metrics") - { - if (i + 1 >= args.Length) - { - error = "Error: --metrics requires a path value (use `--metrics ` or `--metrics=`)."; - return false; - } - rawValue = args[++i]; - } - else if (arg.StartsWith("--metrics=", StringComparison.Ordinal)) - { - rawValue = arg.Substring("--metrics=".Length); - } - else - { - kept.Add(arg); - continue; - } - - if (string.IsNullOrWhiteSpace(rawValue)) - { - error = "Error: --metrics requires a non-empty path value."; - return false; - } - requested = rawValue; - } - - path = requested; - args = kept.ToArray(); - return true; - } - - internal static bool TryConsumeQueryTraceFlag(ref string[] args, out string traceMode, out string error) - => TryConsumeQueryTraceFlag(commandName: null, ref args, out traceMode, out error); - - internal static bool TryConsumeQueryTraceFlag(string? commandName, ref string[] args, out string traceMode, out string error) - { - traceMode = "none"; - error = string.Empty; - if (args.Length == 0) - return true; - - var kept = new List(args.Length); - var passthrough = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (passthrough) - { - kept.Add(arg); - continue; - } - if (arg == "--") - { - passthrough = true; - kept.Add(arg); - continue; - } - if (commandName is not null && ShouldPreserveQueryCommandToken(commandName, args, i)) - { - kept.Add(arg); - continue; - } - - string? rawValue = null; - if (arg == "--trace") - { - if (i + 1 >= args.Length) - { - error = "Error: --trace requires a value (use `--trace stderr`, `--trace file`, `--trace none`, or `--trace=`)."; - return false; - } - rawValue = args[++i]; - } - else if (arg.StartsWith("--trace=", StringComparison.Ordinal)) - { - rawValue = arg.Substring("--trace=".Length); - } - else - { - kept.Add(arg); - continue; - } - - if (string.IsNullOrWhiteSpace(rawValue)) - { - error = "Error: --trace requires a non-empty value."; - return false; - } - if (rawValue is not ("none" or "stderr" or "file")) - { - error = $"Error: --trace must be one of `none`, `stderr`, or `file`, got `{ConsoleUi.FormatBoundedValue(rawValue)}`."; - return false; - } - traceMode = rawValue; - } - - args = kept.ToArray(); - return true; - } - - private static void EmitQueryTrace(string mode, string commandName, string[] subArgs, DateTimeOffset startTimestamp, Stopwatch stopwatch, int exitCode, int? resultCount) - { - if (mode == "none") - return; - - try - { - var elapsedMs = stopwatch.Elapsed.TotalMilliseconds; - var payload = BuildQueryTraceJson(commandName, subArgs, startTimestamp, elapsedMs, exitCode, resultCount); - if (mode == "stderr") - { - CommandErrorWriter.WriteStderr(payload); - return; - } - - var directory = GlobalToolLog.ResolveLogDirectoryForStatus(); - Directory.CreateDirectory(directory); - PrivateLogFile.HardenExisting(directory, "query-trace-*.jsonl"); - var path = ResolveQueryTracePath(directory); - var encoded = Encoding.UTF8.GetBytes(payload + Environment.NewLine); - using (var stream = PrivateLogFile.OpenAppend(path, FileShare.ReadWrite)) - { - stream.Write(encoded, 0, encoded.Length); - stream.Flush(); - } - PrivateLogFile.TrySetPrivatePermissions(path); - PrivateLogFile.PruneOldFiles(directory, "query-trace-*.jsonl", RetainedQueryTraceFileCount); - } - catch - { - // Best-effort only: trace output must never change query command behavior. - } - } - - private static string ResolveQueryTracePath(string directory) - { - var date = TimeProvider.GetUtcNow().UtcDateTime.ToString("yyyyMMdd", CultureInfo.InvariantCulture); - return Path.Combine(directory, $"query-trace-{date}.jsonl"); - } - - private static string BuildQueryTraceJson(string commandName, string[] subArgs, DateTimeOffset timestamp, double elapsedMs, int exitCode, int? resultCount) - { - var payload = new JsonObject - { - ["timestamp"] = timestamp.ToString("O", CultureInfo.InvariantCulture), - ["tool"] = commandName, - ["source"] = "cli_query", - ["parameters"] = BuildQueryTraceParameters(subArgs), - ["elapsed_ms"] = Math.Round(elapsedMs, 3), - ["result_count"] = resultCount, - ["exit_code"] = exitCode, - }; - if (exitCode != CommandExitCodes.Success) - payload["error"] = "command_failed"; - return payload.ToJsonString(CreateDefaultJsonOptions()); - } - - private static JsonObject BuildQueryTraceParameters(string[] args) - { - var parameters = new JsonObject - { - ["json"] = false, - ["count"] = false, - }; - var paths = new List(); - var excludePaths = new List(); - var passthrough = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (passthrough) - continue; - if (arg == "--") - { - passthrough = true; - continue; - } - - string? inlineValue = null; - var optionName = arg; - var equals = arg.IndexOf('='); - if (equals > 0) - { - optionName = arg[..equals]; - inlineValue = arg[(equals + 1)..]; - } - - string? value = inlineValue; - if (value == null && optionName is "--lang" or "--limit" or "--top" or "--path" or "--exclude-path") - { - if (i + 1 < args.Length) - value = args[++i]; - } - - switch (optionName) - { - case "--json": - parameters["json"] = true; - if (!string.IsNullOrWhiteSpace(value)) - AddQueryTraceString(parameters, "json_format", value); - break; - case "--count": - parameters["count"] = true; - break; - case "--lang" when !string.IsNullOrWhiteSpace(value): - AddQueryTraceString(parameters, "lang", value); - break; - case "--limit" when !string.IsNullOrWhiteSpace(value): - case "--top" when !string.IsNullOrWhiteSpace(value): - AddQueryTraceString(parameters, "limit", value); - break; - case "--path" when !string.IsNullOrWhiteSpace(value): - paths.Add(value); - break; - case "--exclude-path" when !string.IsNullOrWhiteSpace(value): - excludePaths.Add(value); - break; - } - } - AddQueryTraceArray(parameters, "path", paths); - AddQueryTraceArray(parameters, "exclude_path", excludePaths); - return parameters; - } - - private static void AddQueryTraceString(JsonObject parameters, string name, string value) - { - var bounded = ConsoleUi.BoundDisplayText(value, QueryTraceValueMaxChars); - parameters[name] = bounded.Text; - if (bounded.Truncated) - { - parameters[$"{name}_truncated"] = true; - parameters[$"{name}_original_length"] = bounded.OriginalLength; - } - } - - private static void AddQueryTraceArray(JsonObject parameters, string name, List values) - { - if (values.Count == 0) - return; - - var array = new JsonArray(); - var valueTruncated = false; - foreach (var value in values.Take(QueryTraceArrayMaxItems)) - { - var bounded = ConsoleUi.BoundDisplayText(value, QueryTraceValueMaxChars); - valueTruncated |= bounded.Truncated; - array.Add(JsonValue.Create(bounded.Text)); - } - - parameters[name] = array; - if (values.Count > QueryTraceArrayMaxItems) - { - parameters[$"{name}_truncated"] = true; - parameters[$"{name}_original_count"] = values.Count; - } - - if (valueTruncated) - parameters[$"{name}_value_truncated"] = true; - } - - private sealed class QueryTraceOutputCapture : TextWriter - { - private readonly TextWriter _inner; - private readonly IDisposable _ownership; - private readonly bool _countNumericOutput; - private readonly bool _countJsonLines; - private bool _disposed; - - private QueryTraceOutputCapture( - TextWriter inner, - IDisposable ownership, - bool countNumericOutput, - bool countJsonLines) - { - _inner = inner; - _ownership = ownership; - _countNumericOutput = countNumericOutput; - _countJsonLines = countJsonLines; - } - - public override Encoding Encoding => _inner.Encoding; - public int? ResultCount { get; private set; } - - public static QueryTraceOutputCapture? TryStart(string traceMode, string[] args) - { - if (traceMode == "none") - return null; - - var ownership = ConsoleStreamOwnership.Enter(); - try - { - var capture = new QueryTraceOutputCapture( - Console.Out, - ownership, - HasFlag(args, "--count"), - HasFlag(args, "--json") && !HasInlineValue(args, "--json", "array")); - Console.SetOut(capture); - return capture; - } - catch - { - ownership.Dispose(); - throw; - } - } - - public override void Write(char value) => _inner.Write(value); - public override void Write(string? value) => _inner.Write(value); - - public override void WriteLine(string? value) - { - _inner.WriteLine(value); - ObserveLine(value); - } - - public override void WriteLine() - { - _inner.WriteLine(); - ObserveLine(string.Empty); - } - - protected override void Dispose(bool disposing) - { - if (!_disposed && disposing) - { - try - { - ConsoleStreamOwnership.RestoreOut(_inner); - _disposed = true; - } - finally - { - _ownership.Dispose(); - } - } - base.Dispose(disposing); - } - - private void ObserveLine(string? value) - { - if (value == null) - return; - - var trimmed = value.Trim(); - if (_countNumericOutput && int.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count) && count >= 0) - { - ResultCount = count; - return; - } - - if (_countJsonLines && trimmed.StartsWith('{')) - ResultCount = (ResultCount ?? 0) + 1; - } - - private static bool HasFlag(string[] args, string name) - { - var passthrough = false; - foreach (var arg in args) - { - if (passthrough) - continue; - if (arg == "--") - { - passthrough = true; - continue; - } - if (arg == name || arg.StartsWith(name + "=", StringComparison.Ordinal)) - return true; - } - return false; - } - - private static bool HasInlineValue(string[] args, string name, string value) - { - var expected = name + "=" + value; - var passthrough = false; - foreach (var arg in args) - { - if (passthrough) - continue; - if (arg == "--") - { - passthrough = true; - continue; - } - if (arg == expected) - return true; - } - return false; - } - } - - internal static void EmitCommandMetric(string tool, string[] args, DateTimeOffset startTimestamp, Stopwatch stopwatch, int exitCode, string? error = null) - { - if (!MetricsSink.IsActive) - return; - - stopwatch.Stop(); - MetricsSink.Record(new MetricsEvent( - Timestamp: startTimestamp, - Tool: tool, - Source: "cli", - ElapsedMs: stopwatch.Elapsed.TotalMilliseconds, - ExitCode: exitCode, - Language: TryParseLanguageFromArgs(args), - Error: error)); - } - - internal static string? TryParseLanguageFromArgs(string[] args) - { - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (arg == "--") - return null; - if (arg == "--lang" && i + 1 < args.Length) - return args[i + 1]; - if (arg.StartsWith("--lang=", StringComparison.Ordinal)) - return arg.Substring("--lang=".Length); - } - return null; - } - - internal static JsonSerializerOptions CreateDefaultJsonOptions() => new() - { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false, - TypeInfoResolver = CliJsonSerializerContext.Default, - }; - - private const string DefaultMcpHttpListen = "127.0.0.1:38080"; - internal const string McpHttpTokenEnvVar = "CDIDX_MCP_HTTP_TOKEN"; - - private static int RunLsp( - string[] cmdArgs, - string appVersion, - JsonSerializerOptions jsonOptions, - CancellationToken cancellationToken = default) - { - var options = QueryCommandRunner.ParseArgs(cmdArgs, jsonDefault: true); - if (options.ParseError != null) - { - CommandErrorWriter.WriteStderr(options.ParseError); - PrintLspUsage(); - return CommandExitCodes.UsageError; - } - - for (var i = 0; i < cmdArgs.Length; i++) - { - if (cmdArgs[i].StartsWith("--db=", StringComparison.Ordinal)) - continue; - if (cmdArgs[i] == "--db") - { - i++; - continue; - } - - CommandErrorWriter.WriteStderr($"Error: {cmdArgs[i]} is not supported for lsp."); - CommandErrorWriter.WriteStderr("Hint: use `--db ` to point at a specific index."); - PrintLspUsage(); - return CommandExitCodes.UsageError; - } - - try - { - if (string.IsNullOrWhiteSpace(options.DbPath)) - { - CommandErrorWriter.WriteStderr("Error: database path could not be resolved."); - PrintLspUsage(); - return CommandExitCodes.UsageError; - } - - if (!options.DbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) - && !File.Exists(LongPath.EnsureWindowsPrefix(options.DbPath))) - { - var resolvedPath = Path.GetFullPath(options.DbPath); - CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {resolvedPath}"); - CommandErrorWriter.WriteStderr("Hint: create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun `cdidx lsp`."); - return CommandExitCodes.DatabaseError; - } - - using var db = new DbContext(DbOpenIntent.QueryOnly, options.DbPath); - if (!db.TryValidateIsCodeIndexDb(out var validationReason)) - { - CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbError}]: invalid CodeIndex database: {validationReason}"); - return CommandExitCodes.DatabaseError; - } - - var indexedProjectRoot = db.GetMetaString(DbContext.IndexedProjectRootMetaKey); - if (!string.IsNullOrWhiteSpace(indexedProjectRoot) - && bool.TryParse(db.GetMetaString(DbContext.WorkspacePathCaseSensitiveMetaKey), out var pathCaseSensitive)) - { - PathCasing.SeedFromWorkspace(indexedProjectRoot, ignoreCase: !pathCaseSensitive); - } - - using var server = new LspServer(db, options.DbPath, appVersion, jsonOptions, indexedProjectRoot); - return server.Run(Console.OpenStandardInput(), Console.OpenStandardOutput(), cancellationToken); - } - catch (OperationCanceledException) - { - Console.Out.Flush(); - Console.Error.Flush(); - return CommandExitCodes.CancelledBySignal; - } - catch (Exception ex) - { - GlobalToolLog.Error("lsp_server_failed " + GlobalToolLog.FormatExceptionChain(ex)); - CommandErrorWriter.WriteStderr($"Error: LSP server failed ({FormatSanitizedExceptionSummary(ex)})."); - Console.Out.Flush(); - Console.Error.Flush(); - return CommandExitCodes.DatabaseError; - } - } - - private static void PrintLspUsage() - { - CommandErrorWriter.WriteStderr("Usage: cdidx lsp [--db ]"); - CommandErrorWriter.WriteStderr("Runs a read-only Language Server Protocol server over stdio using an existing CodeIndex database."); - CommandErrorWriter.WriteStderr("Protocol: LSP stdio uses Content-Length framing; unsupported optional methods are not advertised and return JSON-RPC -32601."); - CommandErrorWriter.WriteStderr("Completion: index-backed symbol completion only, resolveProvider=false; unmatched or no-token positions return an empty item list."); - } - - private sealed record McpRunOptions( - QueryCommandOptions QueryOptions, - string Transport, - string? ListenSpec, - bool AllowUnauthenticatedHttp, - AuditLogOptions AuditOptions, - IReadOnlyDictionary EnvironmentOverrides); - - private static int RunMcp(string[] cmdArgs, string appVersion) - { - if (!TryPrepareMcpRun(cmdArgs, out var runOptions, out var exitCode)) - return exitCode; - - AuditLogSink? auditLog = null; - using var mcpEnvironment = CdidxEnvironment.Push(runOptions.EnvironmentOverrides); - if (!TryOpenMcpAuditLog(runOptions.AuditOptions, out auditLog, out exitCode)) - return exitCode; - - var auditFlushCompleted = true; - try - { - // Pick the JSON-RPC authenticator for the selected transport. Stdio keeps the - // historical `CDIDX_MCP_AUTH_TOKEN` / `params.auth.token` gate (#1559). HTTP uses - // its bearer header gate instead, with `CDIDX_MCP_HTTP_TOKEN` taking precedence over - // `CDIDX_MCP_AUTH_TOKEN` as a fallback (#3156), so clients never need both header and - // body tokens for one HTTP request. The tool-enablement gate (#1561) is wired - // automatically by the McpServer ctor via `McpToolFilter.FromEnvironment()`. - // 選択済み transport に応じて JSON-RPC authenticator を選ぶ。stdio は従来通り - // `CDIDX_MCP_AUTH_TOKEN` / `params.auth.token` ゲートを使う (#1559)。HTTP は bearer - // header ゲートへ一本化し、`CDIDX_MCP_HTTP_TOKEN` を優先、未設定なら - // `CDIDX_MCP_AUTH_TOKEN` を fallback として使う (#3156)。そのため HTTP では同一 - // リクエストに header token と body token の両方を要求しない。ツール有効化ゲート - // (#1561) は McpServer のコンストラクタ内部で `McpToolFilter.FromEnvironment()` - // から自動取得される。 - IMcpAuthenticator? authenticator = null; - try - { - authenticator = CreateMcpAuthenticatorForTransport(runOptions.Transport); - } - catch (FormatException ex) - { - CommandErrorWriter.WriteStderr($"Error: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); - PrintMcpUsage(); - exitCode = CommandExitCodes.UsageError; - } - - if (authenticator is not null) - { - using var server = new McpServer(runOptions.QueryOptions.DbPath, appVersion, runOptions.QueryOptions.DbPathExplicit, authenticator, auditLog); - exitCode = RunMcpServer(server, runOptions.Transport, runOptions.ListenSpec, runOptions.AllowUnauthenticatedHttp); - } - } - finally - { - if (auditLog is not null) - { - var explicitShutdownCompleted = false; - try - { - auditFlushCompleted = auditLog.Shutdown().FlushCompleted; - explicitShutdownCompleted = true; - } - finally - { - // Avoid a second bounded wait after a completed Shutdown call. Dispose - // remains the fallback only if explicit shutdown exits unexpectedly. - // 完了済み Shutdown の後に bounded wait を重ねない。明示 shutdown が - // 予期せず終了した場合だけ Dispose を fallback として使う。 - if (!explicitShutdownCompleted) - auditLog.Dispose(); - } - } - } - - // RunDispatchedCommand emits the outer MCP command metric from this returned value, - // so resolve strict shutdown only after the sink has reached its final state. - // 外側 MCP command metric はこの戻り値を記録するため、sink の最終状態確定後に - // strict shutdown の終了コードを解決する。 - return ResolveMcpAuditShutdownExitCode(exitCode, runOptions.AuditOptions.Strict, auditFlushCompleted); - } - - internal static int ResolveMcpAuditShutdownExitCode(int serverExitCode, bool strict, bool flushCompleted) - => strict && !flushCompleted && serverExitCode == CommandExitCodes.Success - ? CommandExitCodes.RuntimeError - : serverExitCode; - - private static bool TryPrepareMcpRun(string[] cmdArgs, out McpRunOptions runOptions, out int exitCode) - { - // Strip audit-log opt-in flags first so the strict mcp parser below does not see them - // and raise an unknown-flag error. Keeps `--db` and `--` passthrough intact (#1562). - // audit-log オプションフラグは厳格パーサに渡る前に除去し、未知フラグ扱いされるのを防ぐ (#1562)。 - runOptions = null!; - exitCode = CommandExitCodes.Success; - if (!TryConsumeAuditLogFlags(ref cmdArgs, out var auditOptions, out var auditError)) - { - CommandErrorWriter.WriteStderr(auditError); - PrintMcpUsage(); - exitCode = CommandExitCodes.UsageError; - return false; - } - - if (!TryConsumeSuggestionDedupThresholdFlag(ref cmdArgs, out var suggestionDedupThreshold, out var thresholdError)) - { - CommandErrorWriter.WriteStderr(thresholdError); - PrintMcpUsage(); - exitCode = CommandExitCodes.UsageError; - return false; - } - - if (!TryExtractMcpTransportFlags( - cmdArgs, - out var transportSpec, - out var listenSpec, - out var allowUnauthenticatedHttp, - out var transportError)) - { - CommandErrorWriter.WriteStderr(transportError); - PrintMcpUsage(); - exitCode = CommandExitCodes.UsageError; - return false; - } - - // Strip the transport flags from the args before delegating to QueryCommandRunner.ParseArgs - // and the unknown-flag guard below, both of which only understand the historic `--db` shape. - // Transport フラグは ParseArgs / 未知フラグガードが知らないため、両者に渡す前に除去する。 - var residualArgs = RemoveMcpTransportFlags(cmdArgs); - - var options = QueryCommandRunner.ParseArgs(residualArgs, jsonDefault: true); - if (options.ParseError != null) - { - CommandErrorWriter.WriteStderr(options.ParseError); - PrintMcpUsage(); - exitCode = CommandExitCodes.UsageError; - return false; - } - - if (!TryValidateMcpResidualArgs(residualArgs, out exitCode)) - return false; - - if (!TryResolveMcpTransport( - transportSpec, - listenSpec, - allowUnauthenticatedHttp, - out var transport, - out exitCode)) - return false; - - var environmentOverrides = new Dictionary(StringComparer.Ordinal); - if (suggestionDedupThreshold is not null) - environmentOverrides[SuggestionStore.DedupThresholdEnvironmentVariable] = suggestionDedupThreshold; - - runOptions = new McpRunOptions( - options, - transport, - listenSpec, - allowUnauthenticatedHttp, - auditOptions, - environmentOverrides); - return true; - } - - private static bool TryValidateMcpResidualArgs(string[] residualArgs, out int exitCode) - { - for (var i = 0; i < residualArgs.Length; i++) - { - if (residualArgs[i].StartsWith("--db=", StringComparison.Ordinal)) - continue; - - if (residualArgs[i] == "--db") - { - i++; - continue; - } - - if (residualArgs[i] == "--json") - CommandErrorWriter.WriteStderr("Error: --json is not supported for mcp; MCP already speaks JSON-RPC over the selected transport."); - else - CommandErrorWriter.WriteStderr($"Error: {residualArgs[i]} is not supported for mcp."); - CommandErrorWriter.WriteStderr($"Hint: use `--db ` to point at a specific index, `--transport stdio|http` to pick a transport, `--http-listen host:port` for HTTP, `{AllowUnauthenticatedHttpFlag}` for explicit unsafe loopback operation, or `--audit-log ` to enable per-call auditing."); - PrintMcpUsage(); - exitCode = CommandExitCodes.UsageError; - return false; - } - - exitCode = CommandExitCodes.Success; - return true; - } - - private static bool TryResolveMcpTransport( - string? transportSpec, - string? listenSpec, - bool allowUnauthenticatedHttp, - out string transport, - out int exitCode) - { - transport = transportSpec ?? "stdio"; - if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase) - && !string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)) - { - CommandErrorWriter.WriteStderr($"Error: --transport '{transport}' is not supported. Use `stdio` (default) or `http`."); - PrintMcpUsage(); - exitCode = CommandExitCodes.UsageError; - return false; - } - - if (listenSpec != null && !string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)) - { - CommandErrorWriter.WriteStderr("Error: --http-listen requires `--transport http`."); - PrintMcpUsage(); - exitCode = CommandExitCodes.UsageError; - return false; - } - - if (allowUnauthenticatedHttp && !string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)) - { - CommandErrorWriter.WriteStderr($"Error: {AllowUnauthenticatedHttpFlag} requires `--transport http`."); - PrintMcpUsage(); - exitCode = CommandExitCodes.UsageError; - return false; - } - - exitCode = CommandExitCodes.Success; - return true; - } - - private static bool TryOpenMcpAuditLog(AuditLogOptions auditOptions, out AuditLogSink? auditLog, out int exitCode) - { - auditLog = null; - if (auditOptions.Path == null) - { - exitCode = CommandExitCodes.Success; - return true; - } - - try - { - auditLog = new AuditLogSink(auditOptions.Path, auditOptions.MaxBytes, auditOptions.IncludeValues); - exitCode = CommandExitCodes.Success; - return true; - } - catch (Exception ex) when (IsExpectedAuditLogOpenException(ex)) - { - var displayPath = DiagnosticSanitizer.ForPath(auditOptions.Path); - CommandErrorWriter.WriteStderr($"Error: failed to open audit log '{displayPath}' ({FormatSanitizedExceptionSummary(ex)})."); - CommandErrorWriter.WriteStderr("Hint: pick a writable path or omit --audit-log to disable per-call auditing."); - exitCode = CommandExitCodes.UsageError; - return false; - } - } - - private static string FormatSanitizedExceptionSummary(Exception ex) - { - var exceptionType = CommandErrorWriter.FormatSanitizedException(ex); - var message = CommandErrorWriter.FormatSanitizedExceptionMessage(ex); - return string.IsNullOrEmpty(message) ? exceptionType : $"{exceptionType}: {message}"; - } - - private static bool IsExpectedAuditLogOpenException(Exception ex) - => ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException; - - private static int RunMcpServer( - McpServer server, - string transport, - string? listenSpec, - bool allowUnauthenticatedHttp) - { - if (string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase)) - return RunMcpHttp(server, listenSpec ?? DefaultMcpHttpListen, allowUnauthenticatedHttp); - - try - { - server.RunAsync().GetAwaiter().GetResult(); - return CommandExitCodes.Success; - } - catch (OperationCanceledException) - { - Console.Out.Flush(); - Console.Error.Flush(); - return CommandExitCodes.CancelledBySignal; - } - catch (Exception ex) - { - GlobalToolLog.Error("mcp_server_failed " + GlobalToolLog.FormatExceptionChain(ex)); - CommandErrorWriter.WriteStderr($"Error: MCP server failed ({FormatSanitizedExceptionSummary(ex)})."); - Console.Out.Flush(); - Console.Error.Flush(); - return CommandExitCodes.DatabaseError; - } - } - - internal static IMcpAuthenticator CreateMcpAuthenticatorForTransport(string transport) - => string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase) - ? LocalStdioAuthenticator.Instance - : McpAuthenticatorFactory.FromEnvironment(); - - internal static string? ResolveMcpHttpBearerTokenFromEnvironment() - { - var httpToken = McpEnvironment.GetOptionalToken(McpHttpTokenEnvVar); - if (httpToken is not null) - return httpToken; - - return McpEnvironment.GetOptionalToken(McpAuthenticatorFactory.AuthTokenEnvVar); - } - - private static int RunMcpHttp(McpServer server, string listenSpec, bool allowUnauthenticatedHttp) - { - HttpMcpTransport.HttpListenSpec resolved; - try - { - resolved = HttpMcpTransport.ResolveListenSpec(listenSpec); - } - catch (FormatException ex) - { - CommandErrorWriter.WriteStderr($"Error: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); - PrintMcpUsage(); - return CommandExitCodes.UsageError; - } - - // Require a shared-secret bearer token for every HTTP listener by default. HTTP resolves that - // bearer token from `CDIDX_MCP_HTTP_TOKEN` first, then falls back to the generic - // `CDIDX_MCP_AUTH_TOKEN` so setting the generic auth token also protects HTTP without - // forcing clients to send both `Authorization` and `params.auth.token` (#3156). Only the - // explicit CLI opt-in permits an unauthenticated loopback listener; non-loopback binds - // always require the token (#4549). - // すべての HTTP listener で既定では共有秘密 bearer token を必須にする。HTTP はまず - // `CDIDX_MCP_HTTP_TOKEN` を使い、未設定なら汎用の - // `CDIDX_MCP_AUTH_TOKEN` を bearer token として使うため、汎用 token を設定しただけでも - // HTTP は保護され、クライアントに `Authorization` と `params.auth.token` の両方を - // 要求しない (#3156)。明示 CLI opt-in だけが unauthenticated loopback を許可し、 - // non-loopback bind は常に token を必須とする (#4549)。 - string? bearerToken; - try - { - bearerToken = ResolveMcpHttpBearerTokenFromEnvironment(); - } - catch (FormatException ex) - { - CommandErrorWriter.WriteStderr($"Error: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); - PrintMcpUsage(); - return CommandExitCodes.UsageError; - } - - if (allowUnauthenticatedHttp && !resolved.IsLoopback) - { - CommandErrorWriter.WriteStderr($"Error: {AllowUnauthenticatedHttpFlag} is limited to loopback listeners; '{resolved.Host}' is not loopback."); - PrintMcpUsage(); - return CommandExitCodes.UsageError; - } - - if (bearerToken is null && !allowUnauthenticatedHttp) - { - CommandErrorWriter.WriteStderr($"Error: --transport http requires bearer authentication for '{resolved.Host}'. Set the `{McpHttpTokenEnvVar}` or `{McpAuthenticatorFactory.AuthTokenEnvVar}` environment variable. For explicitly unsafe loopback-only operation, pass {AllowUnauthenticatedHttpFlag}."); - PrintMcpUsage(); - return CommandExitCodes.UsageError; - } - - HttpMcpTransport transport; - try - { - transport = new HttpMcpTransport( - resolved.Prefix, - resolved.Host, - resolved.Port, - bearerToken, - requestLogger: LogHttpMcpRequest, - allowUnauthenticatedLoopback: allowUnauthenticatedHttp); - } - catch (FormatException ex) - { - CommandErrorWriter.WriteStderr($"Error: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); - PrintMcpUsage(); - return CommandExitCodes.UsageError; - } - catch (ArgumentOutOfRangeException ex) - { - CommandErrorWriter.WriteStderr($"Error: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}"); - PrintMcpUsage(); - return CommandExitCodes.UsageError; - } - catch (HttpListenerException ex) - { - CommandErrorWriter.WriteStderr($"Error: {HttpMcpTransport.FormatBindFailureDiagnostic(resolved, ex)}"); - return CommandExitCodes.UsageError; - } - - try - { - using var cts = new CancellationTokenSource(); - // Treat SIGINT (Ctrl+C) AND SIGTERM as graceful shutdown signals so orchestrators - // (systemd, launchd, supervisord) can drain the listener and release the HTTP socket - // instead of force-killing the process (#1573). - // SIGINT (Ctrl+C) と SIGTERM を graceful shutdown として扱い、systemd / launchd / - // supervisord が socket を解放して再起動できるようにする(#1573)。 - using (McpServer.RegisterShutdownHandlers(cts)) - { - if (transport.AuthDisabledWarning is { } authWarning) - { - CommandErrorWriter.WriteStderr($"[cdidx-mcp] Warning: {authWarning} Remove {AllowUnauthenticatedHttpFlag} and set `{McpHttpTokenEnvVar}` or `{McpAuthenticatorFactory.AuthTokenEnvVar}` to require bearer auth."); - CommandErrorWriter.WriteStderr($"[cdidx-mcp] HTTP transport listening on {resolved.Prefix} (loopback, explicit unsafe no-auth mode)."); - GlobalToolLog.Info("mcp_http_auth_disabled_warning loopback=true"); - } - else - { - CommandErrorWriter.WriteStderr($"[cdidx-mcp] HTTP transport listening on {resolved.Prefix} (bearer auth required)."); - } - CommandErrorWriter.WriteStderr( - $"[cdidx-mcp] HTTP request deadlines: body_idle_ms={transport.RequestBodyIdleTimeout.TotalMilliseconds.ToString("0", CultureInfo.InvariantCulture)}, total_ms={transport.RequestLifetimeTimeout.TotalMilliseconds.ToString("0", CultureInfo.InvariantCulture)}."); - - try - { - server.RunAsync(transport, cts.Token).GetAwaiter().GetResult(); - } - catch (OperationCanceledException) - { - Console.Out.Flush(); - Console.Error.Flush(); - return CommandExitCodes.CancelledBySignal; - } - catch (Exception ex) - { - GlobalToolLog.Error("mcp_http_server_failed " + GlobalToolLog.FormatExceptionChain(ex)); - CommandErrorWriter.WriteStderr($"Error: MCP HTTP server failed ({FormatSanitizedExceptionSummary(ex)})."); - Console.Out.Flush(); - Console.Error.Flush(); - return CommandExitCodes.DatabaseError; - } - } - } - finally - { - DisposeMcpHttpTransport(transport); - } - - return CommandExitCodes.Success; - } - - private static void DisposeMcpHttpTransport(HttpMcpTransport transport) - { - try - { - var disposeTask = transport.DisposeAsync().AsTask(); - if (disposeTask.Wait(McpHttpDisposeTimeout)) - return; - - var message = $"MCP HTTP transport disposal did not finish within {FormatDuration(McpHttpDisposeTimeout)}."; - GlobalToolLog.Error("mcp_http_transport_dispose_timeout " + message); - CommandErrorWriter.WriteStderr("Warning: " + message); - } - catch (AggregateException ex) - { - var inner = ex.Flatten().InnerExceptions.FirstOrDefault() ?? ex; - GlobalToolLog.Error("mcp_http_transport_dispose_failed " + GlobalToolLog.FormatExceptionChain(inner)); - CommandErrorWriter.WriteStderr($"Warning: MCP HTTP transport disposal failed ({FormatSanitizedExceptionSummary(inner)})."); - } - catch (Exception ex) - { - GlobalToolLog.Error("mcp_http_transport_dispose_failed " + GlobalToolLog.FormatExceptionChain(ex)); - CommandErrorWriter.WriteStderr($"Warning: MCP HTTP transport disposal failed ({FormatSanitizedExceptionSummary(ex)})."); - } - } - - private static void LogHttpMcpRequest(HttpMcpTransport.HttpRequestLogRecord record) - { - GlobalToolLog.Info(FormatHttpMcpRequestLogRecord(record)); - } - - internal static string FormatHttpMcpRequestLogRecord(HttpMcpTransport.HttpRequestLogRecord record) - => "mcp_http_request" - + $" correlation_id={record.CorrelationId}" - + $" request_id={FormatLogValue(record.RequestId)}" - + $" request_id_type={FormatLogValue(record.RequestIdType)}" - + $" request_id_length={(record.RequestIdLength?.ToString(CultureInfo.InvariantCulture) ?? "-")}" - + $" remote_peer={FormatLogValue(record.RemotePeer)}" - + $" method={FormatLogValue(record.Method)}" - + $" path={FormatLogValue(record.Path)}" - + $" status={record.StatusCode.ToString(CultureInfo.InvariantCulture)}" - + $" duration_ms={record.DurationMs.ToString("0.###", CultureInfo.InvariantCulture)}" - + $" auth={FormatLogValue(record.AuthOutcome)}" - + $" rejection={FormatLogValue(record.RejectionReason)}" - + $" diagnostic={FormatLogValue(record.Diagnostic)}"; - - private static string FormatLogValue(string? value) - { - var limited = HttpMcpTransport.LimitRequestLogField(value); - if (string.IsNullOrEmpty(limited)) - return "-"; - - return limited - .Replace('\\', '/') - .Replace('\r', '_') - .Replace('\n', '_') - .Replace('\t', '_') - .Replace(' ', '_'); - } - - private static void PrintMcpUsage() - { - CommandErrorWriter.WriteStderr($"Usage: cdidx mcp [--db ] [--transport stdio|http] [--http-listen ] [{AllowUnauthenticatedHttpFlag}] [--audit-log ] [--audit-log-include-values] [--audit-log-max-bytes ] [--audit-log-strict] [--suggestion-dedup-threshold <0..1>]"); - CommandErrorWriter.WriteStderr("Note: --json is not supported; MCP requests and responses are JSON-RPC over the selected transport."); - CommandErrorWriter.WriteStderr("stdio transport: one UTF-8 JSON-RPC object per LF-delimited line, not LSP Content-Length framing; lifecycle diagnostics are written to stderr."); - CommandErrorWriter.WriteStderr($"HTTP security: bearer auth is required by default; {AllowUnauthenticatedHttpFlag} is an explicit unsafe loopback-only opt-in. Native clients omit Origin; POST requires UTF-8 application/json."); - CommandErrorWriter.WriteStderr($"HTTP limits: {HttpMcpTransport.MaxRequestBodyBytesEnvVar}= (1..{HttpMcpTransport.MaxConfiguredRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxInFlightRequestBodyBytesEnvVar}= (1..{HttpMcpTransport.MaxConfiguredInFlightRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxInFlightRequestBodyBytes.ToString(CultureInfo.InvariantCulture)}, must be >= {HttpMcpTransport.MaxRequestBodyBytesEnvVar}), {HttpMcpTransport.MaxResponseBodyBytesEnvVar}= (1..{HttpMcpTransport.MaxConfiguredResponseBodyBytes.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxResponseBodyBytes.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxQueueDepthEnvVar}= (1..{HttpMcpTransport.MaxConfiguredQueuedRequests.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxQueuedRequests.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxConcurrentHandlersEnvVar}= (1..{HttpMcpTransport.MaxConfiguredConcurrentHandlers.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxConcurrentHandlers.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.MaxEventStreamsEnvVar}= (1..{HttpMcpTransport.MaxConfiguredEventStreams.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultMaxEventStreams.ToString(CultureInfo.InvariantCulture)})."); - CommandErrorWriter.WriteStderr($"HTTP deadlines: {HttpMcpTransport.RequestBodyIdleTimeoutMillisecondsEnvVar}= (1..{HttpMcpTransport.MaxRequestBodyIdleTimeoutMilliseconds.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultRequestBodyIdleTimeoutMilliseconds.ToString(CultureInfo.InvariantCulture)}), {HttpMcpTransport.RequestLifetimeTimeoutMillisecondsEnvVar}= (1..{HttpMcpTransport.MaxRequestLifetimeTimeoutMilliseconds.ToString(CultureInfo.InvariantCulture)}, default {HttpMcpTransport.DefaultRequestLifetimeTimeoutMilliseconds.ToString(CultureInfo.InvariantCulture)}, must be >= {HttpMcpTransport.RequestBodyIdleTimeoutMillisecondsEnvVar})."); - CommandErrorWriter.WriteStderr("Every present HTTP limit or deadline environment variable must be a positive integer in its displayed range; only an absent variable uses the default. POST handlers and SSE event streams use independent capacity gates."); - } - - internal static bool TryConsumeSuggestionDedupThresholdFlag(ref string[] args, out string? thresholdValue, out string error) - { - thresholdValue = null; - error = string.Empty; - if (args.Length == 0) - return true; - - var kept = new List(args.Length); - var passthrough = false; - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (passthrough) - { - kept.Add(arg); - continue; - } - if (arg == "--") - { - passthrough = true; - kept.Add(arg); - continue; - } - - string? value = null; - if (arg == "--suggestion-dedup-threshold") - { - if (i + 1 >= args.Length) - { - error = "Error: --suggestion-dedup-threshold requires a value between 0 and 1."; - return false; - } - value = args[++i]; - } - else if (arg.StartsWith("--suggestion-dedup-threshold=", StringComparison.Ordinal)) - { - value = arg.Substring("--suggestion-dedup-threshold=".Length); - } - else - { - kept.Add(arg); - continue; - } - - if (!double.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var threshold) - || threshold < 0 - || threshold > 1) - { - error = "Error: --suggestion-dedup-threshold must be a value between 0 and 1."; - return false; - } - - thresholdValue = value; - } - - args = kept.ToArray(); - return true; - } - - internal static bool TryExtractMcpTransportFlags( - string[] cmdArgs, - out string? transport, - out string? listen, - out bool allowUnauthenticatedHttp, - out string error) - { - transport = null; - listen = null; - allowUnauthenticatedHttp = false; - error = string.Empty; - for (var i = 0; i < cmdArgs.Length; i++) - { - var arg = cmdArgs[i]; - if (arg == "--transport") - { - if (i + 1 >= cmdArgs.Length) - { - error = "Error: --transport requires a value (`stdio` or `http`)."; - return false; - } - transport = cmdArgs[++i]; - } - else if (arg.StartsWith("--transport=", StringComparison.Ordinal)) - { - transport = arg.Substring("--transport=".Length); - } - else if (arg == "--http-listen") - { - if (i + 1 >= cmdArgs.Length) - { - error = "Error: --http-listen requires a host:port value."; - return false; - } - listen = cmdArgs[++i]; - } - else if (arg.StartsWith("--http-listen=", StringComparison.Ordinal)) - { - listen = arg.Substring("--http-listen=".Length); - } - else if (arg == AllowUnauthenticatedHttpFlag) - { - allowUnauthenticatedHttp = true; - } - } - return true; - } - - private static string[] RemoveMcpTransportFlags(string[] cmdArgs) - { - var kept = new List(cmdArgs.Length); - for (var i = 0; i < cmdArgs.Length; i++) - { - var arg = cmdArgs[i]; - if (arg == "--transport" || arg == "--http-listen") - { - if (i + 1 < cmdArgs.Length) - i++; - continue; - } - if (arg.StartsWith("--transport=", StringComparison.Ordinal) - || arg.StartsWith("--http-listen=", StringComparison.Ordinal)) - { - continue; - } - if (arg == AllowUnauthenticatedHttpFlag) - continue; - kept.Add(arg); - } - return kept.ToArray(); - } - - /// - /// Strip the MCP audit-log opt-in flags (`--audit-log[=]`, - /// `--audit-log-include-values`, `--audit-log-max-bytes[=]`, `--audit-log-strict`) from `cmdArgs` before - /// the strict `cdidx mcp` parser runs. Keeps `--db` and everything after `--` - /// untouched so existing escape semantics survive (#1562). - /// `cdidx mcp` の厳格パーサが走る前に audit-log 用フラグを取り除く。`--db` と - /// `--` 以降はそのまま残し既存意味論を保つ (#1562)。 - /// - internal static bool TryConsumeAuditLogFlags(ref string[] args, out AuditLogOptions options, out string error) - { - options = new AuditLogOptions(null, AuditLogSink.DefaultMaxBytes, false, false); - error = string.Empty; - if (args.Length == 0) - return true; - - var state = new AuditLogFlagParseState(args.Length); - for (var i = 0; i < args.Length; i++) - { - if (!TryConsumeAuditLogArgument(args, ref i, state, out error)) - return false; - } - - if (state.IncludeValues && state.Path == null) - { - error = "Error: --audit-log-include-values requires --audit-log ."; - return false; - } - - if (state.Strict && state.Path == null) - { - error = "Error: --audit-log-strict requires --audit-log ."; - return false; - } - - options = state.ToOptions(); - args = state.Kept.ToArray(); - return true; - } - - private sealed class AuditLogFlagParseState - { - internal AuditLogFlagParseState(int capacity) - { - Kept = new List(capacity); - } - - internal List Kept { get; } - internal string? Path { get; set; } - internal long MaxBytes { get; set; } = AuditLogSink.DefaultMaxBytes; - internal bool IncludeValues { get; set; } - internal bool Strict { get; set; } - internal bool Passthrough { get; set; } - - internal AuditLogOptions ToOptions() => new(Path, MaxBytes, IncludeValues, Strict); - } - - private static bool TryConsumeAuditLogArgument( - string[] args, - ref int index, - AuditLogFlagParseState state, - out string error) - { - error = string.Empty; - var arg = args[index]; - if (state.Passthrough) - { - state.Kept.Add(arg); - return true; - } - - if (arg == "--") - { - state.Passthrough = true; - state.Kept.Add(arg); - return true; - } - - // Pass `--db` and its value through together so a dash-prefixed DB path - // (e.g. `cdidx mcp --db --some-uri`) is not mis-consumed as the start of - // an audit-log flag. The strict mcp parser downstream supports both - // `--db ` and `--db=value`; here we only need to guard the spaced form. - // `--db` とその値はまとめて通過させ、ダッシュ始まりの DB パス - // (例: `cdidx mcp --db --some-uri`) を audit-log フラグの先頭と - // 誤認しないようにする。`--db=value` 形式は値が同じトークンに含まれるため - // 既存ループでそのまま `kept` に流れる。 - if (arg == "--db") - { - state.Kept.Add(arg); - if (index + 1 < args.Length) - state.Kept.Add(args[++index]); - return true; - } - - if (arg == "--audit-log") - return TryConsumeAuditLogPathValue(args, ref index, state, out error); - - if (arg.StartsWith("--audit-log=", StringComparison.Ordinal)) - return TrySetAuditLogPath(arg.Substring("--audit-log=".Length), state, out error); - - if (arg == "--audit-log-include-values") - { - state.IncludeValues = true; - return true; - } - - if (arg == "--audit-log-strict") - { - state.Strict = true; - return true; - } - - if (arg == "--audit-log-max-bytes" || arg.StartsWith("--audit-log-max-bytes=", StringComparison.Ordinal)) - return TryConsumeAuditLogMaxBytes(args, ref index, state, out error); - - state.Kept.Add(arg); - return true; - } - - private static bool TryConsumeAuditLogPathValue( - string[] args, - ref int index, - AuditLogFlagParseState state, - out string error) - { - if (index + 1 >= args.Length) - { - error = "Error: --audit-log requires a path value (use `--audit-log ` or `--audit-log=`)."; - return false; - } - - return TrySetAuditLogPath(args[++index], state, out error); - } - - private static bool TrySetAuditLogPath(string path, AuditLogFlagParseState state, out string error) - { - if (string.IsNullOrWhiteSpace(path)) - { - error = "Error: --audit-log requires a non-empty path value."; - return false; - } - - state.Path = path; - error = string.Empty; - return true; - } - - private static bool TryConsumeAuditLogMaxBytes( - string[] args, - ref int index, - AuditLogFlagParseState state, - out string error) - { - var arg = args[index]; - string raw; - if (arg == "--audit-log-max-bytes") - { - if (index + 1 >= args.Length) - { - error = "Error: --audit-log-max-bytes requires a byte count."; - return false; - } - raw = args[++index]; - } - else - { - raw = arg.Substring("--audit-log-max-bytes=".Length); - } - - if (!long.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) - || parsed < AuditLogSink.MinMaxBytes - || parsed > AuditLogSink.MaxMaxBytes) - { - error = $"Error: --audit-log-max-bytes must be an integer between {AuditLogSink.MinMaxBytes} and {AuditLogSink.MaxMaxBytes}."; - return false; - } - - state.MaxBytes = parsed; - error = string.Empty; - return true; - } - - internal readonly record struct AuditLogOptions(string? Path, long MaxBytes, bool IncludeValues, bool Strict); - - internal static int RunCheckUpdates( - string[] cmdArgs, - JsonSerializerOptions jsonOptions, - string appVersion, - CancellationToken cancellationToken = default) - { - var wantsJson = cmdArgs.Contains("--json", StringComparer.Ordinal); - foreach (var arg in cmdArgs) - { - if (arg == "--json") - continue; - return CommandErrorWriter.WriteJsonOrHuman( - wantsJson, - jsonOptions, - $"--check-updates does not accept '{arg}'.", - CommandExitCodes.UsageError, - "use `cdidx --check-updates` or `cdidx --check-updates --json`."); - } - - var result = UpdateChecker.Check(appVersion, cancellationToken); - if (wantsJson) - { - CommandOutputWriter.WriteJson( - result, - CliJsonSerializerContextFactory.Create(jsonOptions).UpdateCheckResult); - return CommandExitCodes.Success; - } - - if (result.UpdateAvailable && result.LatestVersion != null) - Console.WriteLine($"A newer cdidx release is available: {result.LatestVersion} (current: {result.CurrentVersion})."); - else if (result.Error != null) - Console.WriteLine($"Could not check for updates; using cached release metadata if available (current: {result.CurrentVersion})."); - else - Console.WriteLine($"cdidx is up to date (current: {result.CurrentVersion})."); - return CommandExitCodes.Success; - } - - internal static int RunUpgrade( - string[] cmdArgs, - JsonSerializerOptions jsonOptions, - string appVersion, - CancellationToken cancellationToken = default) - { - var checkOnly = false; - var wantsJson = cmdArgs.Contains("--json", StringComparer.Ordinal); - var selectedChannel = "stable"; - var includePrerelease = false; - var selectionSource = "latest"; - string? explicitVersion = null; - for (var i = 0; i < cmdArgs.Length; i++) - { - var arg = cmdArgs[i]; - if (arg is "--check-only" or "--check-updates") - { - checkOnly = true; - continue; - } - if (arg == "--json") - { - continue; - } - if (arg == "--prerelease") - { - selectedChannel = "prerelease"; - includePrerelease = true; - selectionSource = "prerelease"; - continue; - } - if (arg == "--channel") - { - if (i + 1 >= cmdArgs.Length) - return WriteUpgradeUsageError("--channel requires a value: stable, latest, or prerelease.", wantsJson, jsonOptions); - - if (!TryApplyUpgradeChannel(cmdArgs[++i], out selectedChannel, out includePrerelease, out var channelError)) - return WriteUpgradeUsageError(channelError, wantsJson, jsonOptions); - - selectionSource = selectedChannel; - continue; - } - if (arg.StartsWith("--channel=", StringComparison.Ordinal)) - { - if (!TryApplyUpgradeChannel(arg["--channel=".Length..], out selectedChannel, out includePrerelease, out var channelError)) - return WriteUpgradeUsageError(channelError, wantsJson, jsonOptions); - - selectionSource = selectedChannel; - continue; - } - if (arg == "--version") - { - if (i + 1 >= cmdArgs.Length) - return WriteUpgradeUsageError("--version requires a release tag such as v1.29.0.", wantsJson, jsonOptions); - - if (!TryNormalizeReleaseTag(cmdArgs[++i], out explicitVersion, out var versionError)) - return WriteUpgradeUsageError(versionError, wantsJson, jsonOptions); - - selectionSource = "explicit_version"; - continue; - } - if (arg.StartsWith("--version=", StringComparison.Ordinal)) - { - if (!TryNormalizeReleaseTag(arg["--version=".Length..], out explicitVersion, out var versionError)) - return WriteUpgradeUsageError(versionError, wantsJson, jsonOptions); - - selectionSource = "explicit_version"; - continue; - } - return WriteUpgradeUsageError($"upgrade does not accept '{arg}'.", wantsJson, jsonOptions); - } - - if (!TryGetUpgradeVerificationPolicy(out var verificationPolicy, out var verificationPolicyError)) - return WriteUpgradeUsageError(verificationPolicyError, wantsJson, jsonOptions); - - if (explicitVersion != null && IsPrereleaseTag(explicitVersion) && selectedChannel == "stable") - { - selectedChannel = "prerelease"; - includePrerelease = true; - } - - var result = explicitVersion != null - ? new UpdateCheckResult( - appVersion, - explicitVersion, - UpdateChecker.IsNewerRelease(explicitVersion, appVersion), - FromCache: false, - Error: null) - : includePrerelease - ? CheckLatestPrerelease(appVersion, cancellationToken) - : UpdateChecker.Check(appVersion, cancellationToken); - - var shouldInstall = result.LatestVersion != null && (explicitVersion != null || result.UpdateAvailable); - if (checkOnly || !shouldInstall) - { - var metadataFailureExitCode = result.Error is null - ? CommandExitCodes.Success - : CommandExitCodes.RuntimeError; - if (wantsJson) - { - Console.WriteLine(JsonSerializer.Serialize( - CreateUpgradeJsonResult( - result, - selectedChannel, - selectionSource, - includePrerelease, - verificationPolicy, - installAttempted: false, - installExitCode: null, - error: null), - jsonOptions)); - } - else if (result.UpdateAvailable && result.LatestVersion != null) - Console.WriteLine($"A newer cdidx {selectedChannel} release is available: {result.LatestVersion} (current: {result.CurrentVersion})."); - else if (result.Error != null) - Console.WriteLine($"Could not select a cdidx {selectedChannel} release ({result.Error}); current: {result.CurrentVersion}."); - else - Console.WriteLine($"cdidx is up to date (current: {result.CurrentVersion})."); - return metadataFailureExitCode; - } - - var selectedReleaseTag = result.LatestVersion!; - bool? manifestProvenanceVerified = null; - bool? installerProvenanceVerified = null; - - if (OperatingSystem.IsWindows()) - { - var handoff = CreateWindowsUpgradeHandoff(selectedReleaseTag, RuntimeInformation.ProcessArchitecture); - if (wantsJson) - { - Console.WriteLine(JsonSerializer.Serialize( - CreateUpgradeJsonResult( - result, - selectedChannel, - selectionSource, - includePrerelease, - verificationPolicy, - installAttempted: false, - installExitCode: null, - error: "windows_handoff_required", - handoff: handoff), - jsonOptions)); - } - else - { - CommandErrorWriter.WriteStderr("Error: cdidx upgrade cannot replace the running Windows binary directly."); - CommandErrorWriter.WriteStderr($"Hint: update via NuGet global tool: {handoff.Command}"); - CommandErrorWriter.WriteStderr($"Release page: {handoff.Url}"); - CommandErrorWriter.WriteStderr($"Manual zip asset: {handoff.Asset} ({handoff.AssetUrl})"); - } - return CommandExitCodes.FeatureUnavailable; - } - - if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) - { - if (wantsJson) - { - Console.WriteLine(JsonSerializer.Serialize( - CreateUpgradeJsonResult( - result, - selectedChannel, - selectionSource, - includePrerelease, - verificationPolicy, - installAttempted: false, - installExitCode: null, - error: "unsupported_platform"), - jsonOptions)); - } - else - { - CommandErrorWriter.WriteStderr("Error: cdidx upgrade currently requires a POSIX shell installer on Linux or macOS."); - CommandErrorWriter.WriteStderr("Hint: download the latest release asset manually, or rerun install.sh from a shell environment."); - } - return CommandExitCodes.FeatureUnavailable; - } - - var installDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - if (!TryCheckInstallDirectoryWritable(installDir, out var installDirectoryError)) - { - if (wantsJson) - { - Console.WriteLine(JsonSerializer.Serialize( - CreateUpgradeJsonResult( - result, - selectedChannel, - selectionSource, - includePrerelease, - verificationPolicy, - installAttempted: false, - installExitCode: null, - error: "install_directory_not_writable", - installDirectoryError: installDirectoryError), - jsonOptions)); - } - else - { - CommandErrorWriter.WriteStderr($"Error: install directory is not writable: {installDir}"); - if (installDirectoryError != null) - CommandErrorWriter.WriteStderr($"Reason: {installDirectoryError}"); - WriteUpgradeInstallerTrustDiagnostic(); - CommandErrorWriter.WriteStderr("Hint: rerun with permissions that can write this directory, or reinstall cdidx into a per-user directory."); - } - return CommandExitCodes.UsageError; - } - - string? scriptDirectory = null; - string? scriptPath = null; - string? checksumManifestPath = null; - try - { - scriptDirectory = DataDirectorySecurity.CreateSensitiveTempDirectory(UpgradeInstallerDirectoryPrefix).FullName; - scriptPath = Path.Combine(scriptDirectory, "install.sh"); - checksumManifestPath = Path.Combine(scriptDirectory, ReleaseChecksumAssetName); - using (var client = UpgradeHttpClientFactory()) - { - DownloadReleaseChecksumManifestToFileAsync( - client, - selectedReleaseTag, - checksumManifestPath, - TimeSpan.FromSeconds(20), - cancellationToken) - .GetAwaiter() - .GetResult(); - RequireUpgradeAssetProvenance( - checksumManifestPath, - ReleaseChecksumAssetName, - selectedReleaseTag, - verificationPolicy, - wantsJson, - cancellationToken, - out manifestProvenanceVerified); - var checksumManifest = File.ReadAllText(checksumManifestPath, Encoding.UTF8); - var expectedInstallerSha256 = GetReleaseAssetChecksum(checksumManifest, InstallerScriptAssetName); - - DownloadInstallerScriptAsync( - client, - selectedReleaseTag, - scriptPath, - TimeSpan.FromSeconds(20), - cancellationToken) - .GetAwaiter() - .GetResult(); - RequireUpgradeAssetProvenance( - scriptPath, - InstallerScriptAssetName, - selectedReleaseTag, - verificationPolicy, - wantsJson, - cancellationToken, - out installerProvenanceVerified); - if (!wantsJson) - CommandErrorWriter.WriteStderr($"Verifying {InstallerScriptAssetName} checksum..."); - VerifyFileSha256(scriptPath, expectedInstallerSha256, InstallerScriptAssetName, cancellationToken); - if (!wantsJson) - CommandErrorWriter.WriteStderr($"Verified {InstallerScriptAssetName} checksum."); - } - - var startInfo = CreateInstallerProcessStartInfo(scriptPath, selectedReleaseTag, installDir); - var installerResult = RunInstallerProcessDetailed( - startInfo, - InstallerRunTimeout, - cancellationToken, - suppressOutput: wantsJson); - var installExitCode = installerResult.ExitCode; - if (wantsJson) - { - var error = installExitCode == CommandExitCodes.Success - ? null - : $"installer_exit_code_{installExitCode.ToString(CultureInfo.InvariantCulture)}"; - Console.WriteLine(JsonSerializer.Serialize( - CreateUpgradeJsonResult( - result, - selectedChannel, - selectionSource, - includePrerelease, - verificationPolicy, - installAttempted: true, - installExitCode: installExitCode, - error: error, - installerResult: installerResult, - manifestProvenanceVerified: manifestProvenanceVerified, - installerProvenanceVerified: installerProvenanceVerified), - jsonOptions)); - } - return installExitCode; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - if (wantsJson) - { - Console.WriteLine(JsonSerializer.Serialize( - CreateUpgradeJsonResult( - result, - selectedChannel, - selectionSource, - includePrerelease, - verificationPolicy, - installAttempted: false, - installExitCode: null, - error: ex.GetType().Name, - manifestProvenanceVerified: manifestProvenanceVerified, - installerProvenanceVerified: installerProvenanceVerified), - jsonOptions)); - } - else - { - CommandErrorWriter.WriteStderr($"Error: upgrade failed before install.sh completed ({FormatSanitizedExceptionSummary(ex)})."); - WriteUpgradeInstallerTrustDiagnostic(); - CommandErrorWriter.WriteStderr("Hint: rerun `install.sh` manually for the desired release."); - } - return CommandExitCodes.InstallError; - } - finally - { - if (scriptPath != null) - TryDeleteUpgradeInstallerScript(scriptPath); - if (scriptDirectory != null) - TryDeleteUpgradeInstallerDirectory(scriptDirectory); - } - } - - private static int WriteUpgradeUsageError( - string message, - bool wantsJson, - JsonSerializerOptions jsonOptions) - => CommandErrorWriter.WriteJsonOrHuman( - wantsJson, - jsonOptions, - message, - CommandExitCodes.UsageError, - "use `cdidx upgrade [--check-only] [--channel stable|latest|prerelease] [--prerelease] [--version vX.Y.Z]`."); - - private static bool TryApplyUpgradeChannel( - string rawChannel, - out string selectedChannel, - out bool includePrerelease, - out string error) - { - selectedChannel = "stable"; - includePrerelease = false; - error = string.Empty; - - switch (rawChannel.Trim().ToLowerInvariant()) - { - case "stable": - selectedChannel = "stable"; - return true; - case "latest": - selectedChannel = "latest"; - return true; - case "prerelease": - case "preview": - selectedChannel = "prerelease"; - includePrerelease = true; - return true; - default: - error = $"unsupported upgrade channel '{rawChannel}'."; - return false; - } - } - - private static bool TryNormalizeReleaseTag(string rawVersion, out string? normalizedVersion, out string error) - { - normalizedVersion = null; - error = string.Empty; - - var trimmed = rawVersion.Trim(); - if (trimmed.Length == 0) - { - error = "--version requires a non-empty release tag."; - return false; - } - - normalizedVersion = trimmed[0] is 'v' or 'V' - ? "v" + trimmed[1..] - : "v" + trimmed; - if (!IsValidUpgradeReleaseTag(normalizedVersion)) - { - error = "--version must be a release tag shaped like vX.Y.Z or vX.Y.Z-prerelease."; - normalizedVersion = null; - return false; - } - - return true; - } - - internal static bool IsValidUpgradeReleaseTag(string releaseTag) - { - if (string.IsNullOrWhiteSpace(releaseTag) || releaseTag[0] != 'v') - return false; - - var rest = releaseTag[1..]; - var prereleaseStart = rest.IndexOf('-'); - var core = prereleaseStart >= 0 ? rest[..prereleaseStart] : rest; - var prerelease = prereleaseStart >= 0 ? rest[(prereleaseStart + 1)..] : null; - var parts = core.Split('.'); - if (parts.Length != 3 || parts.Any(part => part.Length == 0 || !part.All(char.IsDigit))) - return false; - - if (prerelease == null) - return true; - - var identifiers = prerelease.Split('.'); - return identifiers.Length > 0 - && identifiers.All(identifier => - identifier.Length > 0 - && identifier.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '-')); - } - - private static bool IsPrereleaseTag(string releaseTag) - => releaseTag.Contains('-', StringComparison.Ordinal); - - private static void WriteUpgradeInstallerTrustDiagnostic() - => CommandErrorWriter.WriteStderr($"Installer verification: {UpgradeInstallerVerification}; {UpgradeInstallerTrustBoundary}"); - - internal static UpgradeHandoff CreateWindowsUpgradeHandoff(string releaseTag, Architecture processArchitecture) - { - var normalizedTag = releaseTag.Trim(); - var nugetVersion = normalizedTag.Length > 0 && (normalizedTag[0] is 'v' or 'V') - ? normalizedTag[1..] - : normalizedTag; - var asset = processArchitecture == Architecture.Arm64 - ? "CodeIndex-win-arm64.zip" - : "CodeIndex-win-x64.zip"; - return new UpgradeHandoff( - $"dotnet tool update -g cdidx --version {nugetVersion}", - BuildReleasePageUrl(normalizedTag), - asset, - BuildReleaseAssetUrl(normalizedTag, asset)); - } - - private static UpdateCheckResult CheckLatestPrerelease(string appVersion, CancellationToken cancellationToken) - { - if (UpdateChecker.IsDisabled()) - return UpdateChecker.CreateDisabledResult(appVersion); - - try - { - using var client = UpgradeHttpClientFactory(); - var tag = UpdateChecker.FetchLatestPrereleaseTagAsync( - client, - TimeSpan.FromSeconds(20), - cancellationToken) - .GetAwaiter() - .GetResult(); - return new UpdateCheckResult( - appVersion, - tag, - UpdateChecker.IsNewerRelease(tag, appVersion), - FromCache: false, - Error: tag is null ? "prerelease_not_found" : null, - ErrorCategory: tag is null ? "release_metadata" : null, - ErrorHint: tag is null - ? "Retry later, omit --prerelease, or pass --version to use a known prerelease tag." - : null); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - var failure = UpdateChecker.ClassifyFailure(ex); - return new UpdateCheckResult( - appVersion, - null, - false, - FromCache: false, - Error: failure.Code, - ErrorCategory: failure.Category, - ErrorHint: failure.Hint); - } - } - - internal static ProcessStartInfo CreateInstallerProcessStartInfo(string scriptPath, string releaseTag, string installDir) - { - var fullScriptPath = Path.GetFullPath(scriptPath); - var startInfo = CodeIndex.ProcessLaunchPolicy.CreateNoShellStartInfo( - fileName: ResolveTrustedBashPath(), - workingDirectory: Path.GetDirectoryName(fullScriptPath) ?? string.Empty); - CodeIndex.ProcessLaunchPolicy.AddArguments(startInfo, fullScriptPath, releaseTag); - CodeIndex.SubprocessEnvironmentPolicy.ApplyUpgradeInstallerEnvironment(startInfo); - startInfo.Environment["CDIDX_INSTALL_DIR"] = installDir; - return startInfo; - } - - private static bool TryGetUpgradeVerificationPolicy(out string verificationPolicy, out string error) - { - var policy = EnvironmentAccess.GetProcessEnvironmentVariable("CDIDX_VERIFY_POLICY"); - if (string.IsNullOrEmpty(policy) || string.Equals(policy, "strict", StringComparison.Ordinal)) - { - verificationPolicy = "strict"; - error = string.Empty; - return true; - } - if (string.Equals(policy, "compat", StringComparison.Ordinal)) - { - verificationPolicy = "compat"; - error = string.Empty; - return true; - } - - verificationPolicy = string.Empty; - error = $"CDIDX_VERIFY_POLICY must be 'compat' or 'strict' (got '{policy}')."; - return false; - } - - private static void RequireUpgradeAssetProvenance( - string assetPath, - string assetName, - string releaseTag, - string verificationPolicy, - bool suppressOutput, - CancellationToken cancellationToken, - out bool? verified) - { - var compat = string.Equals(verificationPolicy, "compat", StringComparison.Ordinal); - verified = UpgradeAssetProvenanceVerifier(assetPath, releaseTag, cancellationToken); - - if (verified == true) - { - if (!suppressOutput) - CommandErrorWriter.WriteStderr($"Verified independent release provenance for {assetName}."); - return; - } - - if (!compat) - throw new InvalidDataException($"Independent release provenance verification failed for {assetName}; installer execution is blocked."); - - if (!suppressOutput) - CommandErrorWriter.WriteStderr($"Warning: AUDIT: CDIDX_VERIFY_POLICY=compat permits {assetName} without independent release provenance verification."); - } - - private static bool VerifyUpgradeAssetProvenance(string assetPath, string releaseTag, CancellationToken cancellationToken) - { - var startInfo = CreateUpgradeAttestationStartInfo(assetPath, releaseTag); - var result = RunInstallerProcessDetailed( - startInfo, - TimeSpan.FromSeconds(30), - cancellationToken, - suppressOutput: true); - return result.ExitCode == CommandExitCodes.Success; - } - - internal static ProcessStartInfo CreateUpgradeAttestationStartInfo(string assetPath, string releaseTag) - { - var fullAssetPath = Path.GetFullPath(assetPath); - var startInfo = CodeIndex.ProcessLaunchPolicy.CreateNoShellStartInfo( - fileName: "gh", - workingDirectory: Path.GetDirectoryName(fullAssetPath) ?? string.Empty); - CodeIndex.ProcessLaunchPolicy.AddArguments( - startInfo, - "attestation", - "verify", - fullAssetPath, - "-R", - "Widthdom/CodeIndex", - "--signer-workflow", - ReleaseAttestationSignerWorkflow, - "--source-ref", - $"refs/tags/{releaseTag}"); - CodeIndex.SubprocessEnvironmentPolicy.ApplyUpgradeInstallerEnvironment(startInfo); - return startInfo; - } - - internal static string ResolveTrustedBashPath() - { - if (OperatingSystem.IsWindows()) - throw new PlatformNotSupportedException("The install.sh upgrade path requires a POSIX bash executable."); - - foreach (var candidate in new[] { "/bin/bash", "/usr/bin/bash" }) - { - if (File.Exists(candidate)) - return candidate; - } - - throw new FileNotFoundException("Could not find a trusted absolute bash path for running install.sh."); - } - - internal static int RunInstallerProcess( - ProcessStartInfo startInfo, - TimeSpan timeout, - CancellationToken cancellationToken = default, - bool suppressOutput = false) - => RunInstallerProcessDetailed(startInfo, timeout, cancellationToken, suppressOutput).ExitCode; - - internal static InstallerProcessResult RunInstallerProcessDetailed( - ProcessStartInfo startInfo, - TimeSpan timeout, - CancellationToken cancellationToken = default, - bool suppressOutput = false) - { - if (suppressOutput) - { - startInfo.RedirectStandardOutput = true; - startInfo.RedirectStandardError = true; - } - - Process? process; - try - { - process = Process.Start(startInfo); - } - catch (Exception ex) when (IsInstallerProcessStartException(ex)) - { - if (!suppressOutput) - { - CommandErrorWriter.WriteStderr($"Error: failed to start install.sh for upgrade ({CommandErrorWriter.FormatSanitizedException(ex)})."); - CommandErrorWriter.WriteStderr("Hint: rerun `install.sh` manually for the desired release."); - } - return InstallerProcessResult.Failure(CommandExitCodes.InstallError); - } - - if (process == null) - { - if (!suppressOutput) - { - CommandErrorWriter.WriteStderr("Error: failed to start install.sh for upgrade."); - CommandErrorWriter.WriteStderr("Hint: rerun `install.sh` manually for the desired release."); - } - return InstallerProcessResult.Failure(CommandExitCodes.InstallError); - } - - using (process) - { - var outputDrainTask = suppressOutput - ? DrainSuppressedInstallerOutputAsync(process) - : Task.FromResult(SuppressedInstallerOutputResult.Empty); - - try - { - var waitTask = process.WaitForExitAsync(cancellationToken); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var timeoutTask = Task.Delay(ToWaitMilliseconds(timeout), timeoutCts.Token); - var completedTask = Task.WhenAny(waitTask, timeoutTask).GetAwaiter().GetResult(); - if (completedTask == waitTask) - { - timeoutCts.Cancel(); - waitTask.GetAwaiter().GetResult(); - var output = outputDrainTask.GetAwaiter().GetResult(); - return new InstallerProcessResult( - process.ExitCode, - output.StdoutTail, - output.StderrTail, - output.Truncated); - } - - if (cancellationToken.IsCancellationRequested) - waitTask.GetAwaiter().GetResult(); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - TryKillProcessTree(process); - if (!process.WaitForExit(ToWaitMilliseconds(InstallerKillWaitTimeout))) - { - if (!suppressOutput) - CommandErrorWriter.WriteStderr("Error: install.sh was cancelled and did not exit after cancellation."); - } - else - { - outputDrainTask.GetAwaiter().GetResult(); - } - throw; - } - - if (process.HasExited) - { - var output = outputDrainTask.GetAwaiter().GetResult(); - return new InstallerProcessResult( - process.ExitCode, - output.StdoutTail, - output.StderrTail, - output.Truncated); - } - - TryKillProcessTree(process); - if (!process.WaitForExit(ToWaitMilliseconds(InstallerKillWaitTimeout))) - { - if (!suppressOutput) - CommandErrorWriter.WriteStderr("Error: install.sh timed out and did not exit after cancellation."); - } - else - { - outputDrainTask.GetAwaiter().GetResult(); - if (!suppressOutput) - CommandErrorWriter.WriteStderr($"Error: install.sh timed out after {FormatDuration(timeout)}."); - } - if (!suppressOutput) - CommandErrorWriter.WriteStderr("Hint: rerun `install.sh` manually for the desired release."); - var timeoutOutput = outputDrainTask.IsCompletedSuccessfully - ? outputDrainTask.GetAwaiter().GetResult() - : SuppressedInstallerOutputResult.Empty; - return new InstallerProcessResult( - CommandExitCodes.InstallError, - timeoutOutput.StdoutTail, - timeoutOutput.StderrTail, - timeoutOutput.Truncated); - } - } - - private static bool IsInstallerProcessStartException(Exception ex) - => ex is Win32Exception - or InvalidOperationException - or FileNotFoundException - or DirectoryNotFoundException - or UnauthorizedAccessException; - - private static async Task DrainSuppressedInstallerOutputAsync(Process process) - { - var outputs = await Task.WhenAll( - DrainSuppressedInstallerOutputAsync(process.StandardOutput), - DrainSuppressedInstallerOutputAsync(process.StandardError)).ConfigureAwait(false); - return new SuppressedInstallerOutputResult( - outputs[0].Tail, - outputs[1].Tail, - outputs[0].Truncated || outputs[1].Truncated); - } - - private static async Task DrainSuppressedInstallerOutputAsync(TextReader reader) - { - var buffer = new char[InstallerSuppressedOutputDrainBufferChars]; - var tail = new SuppressedOutputTail(InstallerSuppressedOutputTailChars); - while (true) - { - var read = await reader.ReadAsync(buffer.AsMemory()).ConfigureAwait(false); - if (read == 0) - break; - - tail.Append(buffer.AsSpan(0, read)); - } - - return new SuppressedInstallerOutput(tail.Value, tail.Truncated); - } - - internal sealed record InstallerProcessResult( - int ExitCode, - string? StdoutTail, - string? StderrTail, - bool OutputTruncated) - { - internal static InstallerProcessResult Failure(int exitCode) => new(exitCode, null, null, false); - } - - private sealed record SuppressedInstallerOutputResult( - string? StdoutTail, - string? StderrTail, - bool Truncated) - { - internal static SuppressedInstallerOutputResult Empty { get; } = new(null, null, false); - } - - private sealed record SuppressedInstallerOutput(string? Tail, bool Truncated); - - private sealed class SuppressedOutputTail(int maxChars) - { - private readonly StringBuilder _builder = new(maxChars); - private long _totalChars; - - internal bool Truncated { get; private set; } - - internal string? Value => _builder.Length == 0 ? null : _builder.ToString(); - - internal void Append(ReadOnlySpan value) - { - _totalChars += value.Length; - if (_totalChars > maxChars) - Truncated = true; - - if (value.Length >= maxChars) - { - _builder.Clear(); - _builder.Append(value[^maxChars..]); - return; - } - - _builder.Append(value); - if (_builder.Length > maxChars) - _builder.Remove(0, _builder.Length - maxChars); - } - } - - private static void TryDeleteUpgradeInstallerScript(string scriptPath) - { - try - { - if (!File.Exists(scriptPath)) - return; - - if (DeleteUpgradeInstallerScriptForTesting != null) - DeleteUpgradeInstallerScriptForTesting(scriptPath); - else - File.Delete(scriptPath); - } - catch (Exception ex) when (IsExpectedCleanupException(ex)) - { - CommandErrorWriter.WriteStderr($"Warning: failed to delete upgrade installer script {ConsoleUi.FormatBoundedValue(scriptPath)} ({FormatSanitizedExceptionSummary(ex)})."); - } - } - - private static void TryDeleteUpgradeInstallerDirectory(string scriptDirectory) - { - try - { - if (!TryValidateUpgradeInstallerDirectoryCleanupTarget(scriptDirectory, out var fullPath, out var validationFailure)) - { - CommandErrorWriter.WriteStderr($"Warning: skipped deleting upgrade installer temporary directory {ConsoleUi.FormatBoundedValue(scriptDirectory)} ({validationFailure})."); - return; - } - - if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath))) - return; - - if (!TryValidateUpgradeInstallerDirectoryCleanupTarget(fullPath, out fullPath, out validationFailure)) - { - CommandErrorWriter.WriteStderr($"Warning: skipped deleting upgrade installer temporary directory {ConsoleUi.FormatBoundedValue(scriptDirectory)} ({validationFailure})."); - return; - } - - if (DeleteUpgradeInstallerDirectoryForTesting != null) - DeleteUpgradeInstallerDirectoryForTesting(fullPath); - else - Directory.Delete(LongPath.EnsureWindowsPrefix(fullPath), recursive: true); - } - catch (Exception ex) when (IsExpectedCleanupException(ex)) - { - CommandErrorWriter.WriteStderr($"Warning: failed to delete upgrade installer temporary directory {ConsoleUi.FormatBoundedValue(scriptDirectory)} ({FormatSanitizedExceptionSummary(ex)})."); - } - } - - private static bool IsExpectedCleanupException(Exception ex) - => ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException; - - internal static bool TryValidateUpgradeInstallerDirectoryCleanupTarget( - string path, - out string fullPath, - out string failureReason) - { - var options = new DirectoryCleanupBoundaryOptions( - UpgradeInstallerDirectoryPrefix, - "target is outside the expected cleanup root", - "target name does not match the expected upgrade temporary-directory prefix", - "target is a symbolic link, reparse point, or device"); - return FileSystemBoundary.TryValidateDirectoryCleanupTarget( - path, - Path.GetTempPath(), - options, - out fullPath, - out failureReason); - } - - private static UpgradeJsonResult CreateUpgradeJsonResult( - UpdateCheckResult result, - string selectedChannel, - string selectionSource, - bool includePrerelease, - string verificationPolicy, - bool installAttempted, - int? installExitCode, - string? error, - UpgradeHandoff? handoff = null, - InstallerProcessResult? installerResult = null, - string? installDirectoryError = null, - bool? manifestProvenanceVerified = null, - bool? installerProvenanceVerified = null) - => new( - result.CurrentVersion, - result.LatestVersion, - result.UpdateAvailable, - result.FromCache, - result.LatestVersion, - selectedChannel, - selectionSource, - includePrerelease, - error ?? result.Error, - error is null ? result.ErrorCategory : null, - error is null ? result.ErrorHint : null, - installAttempted, - installExitCode, - installExitCode is null ? null : installExitCode == CommandExitCodes.Success, - handoff?.Command, - handoff?.Url, - handoff?.Asset, - handoff?.AssetUrl, - result.LatestVersion is null ? null : UpgradeInstallerVerification, - result.LatestVersion is null ? null : UpgradeInstallerTrustBoundary, - installerResult is { ExitCode: not CommandExitCodes.Success } ? installerResult.StdoutTail : null, - installerResult is { ExitCode: not CommandExitCodes.Success } ? installerResult.StderrTail : null, - installerResult is { ExitCode: not CommandExitCodes.Success } ? installerResult.OutputTruncated : null, - installDirectoryError, - result.LatestVersion is null ? null : verificationPolicy, - manifestProvenanceVerified, - installerProvenanceVerified, - GetUpgradeVerificationStatus( - result.LatestVersion, - verificationPolicy, - manifestProvenanceVerified, - installerProvenanceVerified), - string.Equals(verificationPolicy, "compat", StringComparison.Ordinal) - && (manifestProvenanceVerified == false || installerProvenanceVerified == false) - ? "compat_provenance_bypass" - : null); - - private static string? GetUpgradeVerificationStatus( - string? selectedVersion, - string verificationPolicy, - bool? manifestProvenanceVerified, - bool? installerProvenanceVerified) - { - if (selectedVersion is null) - return null; - if (manifestProvenanceVerified == true && installerProvenanceVerified == true) - return "verified"; - if (manifestProvenanceVerified == false || installerProvenanceVerified == false) - return string.Equals(verificationPolicy, "compat", StringComparison.Ordinal) - ? "compat_bypass" - : "verification_failed"; - return "not_attempted"; - } - - internal static string BuildReleasePageUrl(string releaseTag) - => string.Format( - CultureInfo.InvariantCulture, - ReleasePageUrlTemplate, - Uri.EscapeDataString(releaseTag.Trim())); - - internal static string BuildInstallerScriptUrl(string releaseTag) - => BuildReleaseAssetUrl(releaseTag, InstallerScriptAssetName); - - internal static string BuildReleaseAssetUrl(string releaseTag, string assetName) - => string.Format( - CultureInfo.InvariantCulture, - ReleaseAssetUrlTemplate, - Uri.EscapeDataString(releaseTag.Trim()), - Uri.EscapeDataString(assetName)); - - private static HttpClient CreateUpgradeHttpClient() - => GitHubHttpClientFactory.CreateReleaseDownloadHttpClient(TimeSpan.FromSeconds(20)); - - internal static async Task DownloadReleaseChecksumManifestAsync( - HttpClient client, - string releaseTag, - TimeSpan timeout, - CancellationToken cancellationToken) - { - using var downloadScope = OperationTimeoutScope.Create( - OperationTimeoutCategories.UpgradeDownload, - timeout, - cancellationToken); - using var response = await GitHubHttpClientFactory.SendWithRetryAsync( - client, - () => - { - var request = new HttpRequestMessage(HttpMethod.Get, BuildReleaseAssetUrl(releaseTag, ReleaseChecksumAssetName)); - GitHubHttpClientFactory.ApplyReleaseDownloadHeaders(request.Headers); - return request; - }, - HttpCompletionOption.ResponseHeadersRead, - downloadScope.Token).ConfigureAwait(false); - await GitHubHttpClientFactory.EnsureSuccessStatusCodeWithBoundedDiagnosticsAsync( - response, - ReleaseChecksumAssetName, - downloadScope.Token).ConfigureAwait(false); - var bytes = await BoundedHttpContentReader.ReadAsByteArrayAsync( - response.Content, - MaxReleaseChecksumBytes, - downloadScope.Token).ConfigureAwait(false); - return Encoding.UTF8.GetString(bytes); - } - - internal static async Task DownloadReleaseChecksumManifestToFileAsync( - HttpClient client, - string releaseTag, - string manifestPath, - TimeSpan timeout, - CancellationToken cancellationToken) - { - using var downloadScope = OperationTimeoutScope.Create( - OperationTimeoutCategories.UpgradeDownload, - timeout, - cancellationToken); - using var response = await GitHubHttpClientFactory.SendWithRetryAsync( - client, - () => - { - var request = new HttpRequestMessage(HttpMethod.Get, BuildReleaseAssetUrl(releaseTag, ReleaseChecksumAssetName)); - GitHubHttpClientFactory.ApplyReleaseDownloadHeaders(request.Headers); - return request; - }, - HttpCompletionOption.ResponseHeadersRead, - downloadScope.Token).ConfigureAwait(false); - await GitHubHttpClientFactory.EnsureSuccessStatusCodeWithBoundedDiagnosticsAsync( - response, - ReleaseChecksumAssetName, - downloadScope.Token).ConfigureAwait(false); - await BoundedHttpContentReader.WriteToPrivateFileAsync( - response.Content, - manifestPath, - MaxReleaseChecksumBytes, - downloadScope.Token).ConfigureAwait(false); - } - - internal static string GetReleaseAssetChecksum(string checksumManifest, string assetName) - { - foreach (var rawLine in checksumManifest.Split('\n')) - { - var line = rawLine.TrimEnd('\r'); - if (line.Length < 66) - continue; - - var checksum = line[..64]; - if (!IsSha256Hex(checksum) || !char.IsWhiteSpace(line[64])) - continue; - - var fileName = line[65..].TrimStart(); - if (fileName.StartsWith('*')) - fileName = fileName[1..]; - if (string.Equals(fileName, assetName, StringComparison.Ordinal)) - return checksum.ToLowerInvariant(); - } - - throw new InvalidDataException($"Release checksum manifest does not contain {assetName}."); - } - - internal static void VerifyFileSha256( - string path, - string expectedSha256Hex, - string assetName, - CancellationToken cancellationToken = default) - { - if (!IsSha256Hex(expectedSha256Hex)) - throw new InvalidDataException($"Release checksum for {assetName} is not a valid SHA-256 digest."); - - using var stream = BoundedFile.OpenReadForHash(path); - var actual = Sha256StreamHasher.ComputeHex(stream, cancellationToken); - if (!string.Equals(actual, expectedSha256Hex, StringComparison.OrdinalIgnoreCase)) - throw new InvalidDataException( - $"Downloaded {assetName} checksum mismatch: expected {expectedSha256Hex}, got {actual}."); - } - - private static bool IsSha256Hex(string value) - { - if (value.Length != 64) - return false; - foreach (var ch in value) - { - if (!Uri.IsHexDigit(ch)) - return false; - } - - return true; - } - - internal static async Task DownloadInstallerScriptAsync( - HttpClient client, - string releaseTag, - string scriptPath, - TimeSpan timeout, - CancellationToken cancellationToken) - { - using var downloadScope = OperationTimeoutScope.Create( - OperationTimeoutCategories.UpgradeDownload, - timeout, - cancellationToken); - using var response = await GitHubHttpClientFactory.SendWithRetryAsync( - client, - () => - { - var request = new HttpRequestMessage(HttpMethod.Get, BuildInstallerScriptUrl(releaseTag)); - GitHubHttpClientFactory.ApplyReleaseDownloadHeaders(request.Headers); - return request; - }, - HttpCompletionOption.ResponseHeadersRead, - downloadScope.Token).ConfigureAwait(false); - await GitHubHttpClientFactory.EnsureSuccessStatusCodeWithBoundedDiagnosticsAsync( - response, - InstallerScriptAssetName, - downloadScope.Token).ConfigureAwait(false); - await BoundedHttpContentReader.WriteToPrivateFileAsync( - response.Content, - scriptPath, - MaxInstallerScriptBytes, - downloadScope.Token).ConfigureAwait(false); - } - - internal static bool CanWriteDirectory(string directory) - => TryCheckInstallDirectoryWritable(directory, out _); - - internal static bool TryCheckInstallDirectoryWritable(string directory, out string? diagnostic) - { - diagnostic = null; - string? probe = null; - var createdProbe = false; - try - { - if (!TryResolveUpgradeInstallDirectory(directory, out var fullDirectory, out diagnostic)) - return false; - - Directory.CreateDirectory(fullDirectory); - if (!TryValidateExistingUpgradeInstallDirectory(fullDirectory, out diagnostic)) - return false; - - probe = Path.GetFullPath(Path.Combine(fullDirectory, $".cdidx-write-test-{Guid.NewGuid():N}")); - if (!IsPathEqualOrChildNoProbe(fullDirectory, probe) || string.Equals(fullDirectory, probe, InstallDirectoryPathComparison)) - { - diagnostic = "install directory write probe escaped the install directory."; - return false; - } - - FileWriteProbe.WriteEmptyFile(probe, Encoding.UTF8); - createdProbe = true; - return true; - } - catch (Exception ex) - { - diagnostic = CommandErrorWriter.FormatSanitizedException(ex); - return false; - } - finally - { - if (createdProbe && probe != null) - TryDeleteInstallDirectoryWriteProbe(probe); - } - } - - private static bool TryResolveUpgradeInstallDirectory(string directory, out string fullDirectory, out string? diagnostic) - { - fullDirectory = string.Empty; - diagnostic = null; - if (string.IsNullOrWhiteSpace(directory)) - { - diagnostic = "install directory is empty."; - return false; - } - - try - { - fullDirectory = NormalizeDirectoryBoundaryPath(Path.GetFullPath(directory)); - var root = Path.GetPathRoot(fullDirectory); - if (!string.IsNullOrEmpty(root) && string.Equals(fullDirectory, NormalizeDirectoryBoundaryPath(root), InstallDirectoryPathComparison)) - { - diagnostic = "install directory must not be the filesystem root."; - return false; - } - - return true; - } - catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) - { - diagnostic = CommandErrorWriter.FormatSanitizedException(ex); - return false; - } - } - - private static bool TryValidateExistingUpgradeInstallDirectory(string fullDirectory, out string? diagnostic) - { - diagnostic = null; - try - { - var directoryInfo = new DirectoryInfo(fullDirectory); - directoryInfo.Refresh(); - if (!directoryInfo.Exists) - { - diagnostic = "install directory does not exist after creation."; - return false; - } - - if ((directoryInfo.Attributes & FileAttributes.ReparsePoint) != 0 || !string.IsNullOrEmpty(directoryInfo.LinkTarget)) - { - diagnostic = "install directory must not be a symbolic link or reparse point."; - return false; - } - - if (!OperatingSystem.IsWindows()) - { - var mode = File.GetUnixFileMode(fullDirectory); - if ((mode & (UnixFileMode.GroupWrite | UnixFileMode.OtherWrite)) != 0) - { - diagnostic = "install directory must not be group- or world-writable."; - return false; - } - } - - return true; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) - { - diagnostic = CommandErrorWriter.FormatSanitizedException(ex); - return false; - } - } - - private static string NormalizeDirectoryBoundaryPath(string path) - { - var fullPath = Path.GetFullPath(path); - var root = Path.GetPathRoot(fullPath); - if (!string.IsNullOrEmpty(root) && string.Equals(fullPath, root, InstallDirectoryPathComparison)) - return fullPath; - return fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - } - - private static StringComparison InstallDirectoryPathComparison - => OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - - private static bool IsPathEqualOrChildNoProbe(string normalizedParent, string normalizedChild) - { - if (string.Equals(normalizedParent, normalizedChild, InstallDirectoryPathComparison)) - return true; - - var trimmedParent = normalizedParent.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return normalizedChild.StartsWith(trimmedParent + Path.DirectorySeparatorChar, InstallDirectoryPathComparison) - || normalizedChild.StartsWith(trimmedParent + Path.AltDirectorySeparatorChar, InstallDirectoryPathComparison); - } - private static void TryDeleteInstallDirectoryWriteProbe(string probePath) - { - try - { - if (!File.Exists(probePath)) - return; - - if (DeleteInstallDirectoryWriteProbeForTesting != null) - DeleteInstallDirectoryWriteProbeForTesting(probePath); - else - File.Delete(probePath); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - CommandErrorWriter.WriteStderr($"Warning: failed to delete install directory write probe {ConsoleUi.FormatBoundedValue(probePath)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); - } - } - - private static int ToWaitMilliseconds(TimeSpan timeout) - { - if (timeout <= TimeSpan.Zero) - return 1; - if (timeout.TotalMilliseconds >= int.MaxValue) - return int.MaxValue; - return Math.Max(1, (int)Math.Ceiling(timeout.TotalMilliseconds)); - } - - private static string FormatDuration(TimeSpan timeout) - => timeout.TotalSeconds.ToString("0.###", CultureInfo.InvariantCulture) + "s"; - - private static void TryKillProcessTree(Process process) - { - try - { - if (!process.HasExited) - process.Kill(entireProcessTree: true); - } - catch - { - // Best-effort cleanup only; callers receive the timeout diagnostic. - } - } - - // `--version` is now build-aware so dev builds from main are not - // indistinguishable from tagged releases in bug reports (#1550). Human - // output stays on a single line — `cdidx v` optionally followed by - // ` (commit , built , )` — so the install.sh - // reinstall validator can stay anchored against trailing diagnostic spam. - // バグ報告で dev ビルドとリリースタグを区別できるよう `--version` を - // ビルド情報付きにする (#1550)。人間出力は 1 行に保ち、install.sh の - // reinstall validator が末尾診断文を誤って許容しないよう、括弧で囲った - // メタデータ以外を許さない形に揃える。 - internal static int RunVersion( - string[] cmdArgs, - JsonSerializerOptions jsonOptions, - string? appVersion = null, - CancellationToken cancellationToken = default) - { - var wantsJson = false; - foreach (var arg in cmdArgs) - { - if (arg is "--json") - { - wantsJson = true; - continue; - } - CommandErrorWriter.WriteStderr($"Error: --version does not accept '{arg}'."); - CommandErrorWriter.WriteStderr("Hint: use `cdidx --version` or `cdidx --version --json`."); - return CommandExitCodes.UsageError; - } - - var baseMetadata = ConsoleUi.LoadBuildMetadata(); - // Honour the caller-provided appVersion (overrides version.json so - // tests and embedded hosts can pin a specific semver) while keeping - // the assembly-stamped commit/build-date/dirty fields. - // 呼び出し元が appVersion を渡した場合はそれを優先する(テストや - // 組み込みホストが semver を固定できるよう)一方、commit / build - // date / dirty は刻印された値をそのまま使う。 - var metadata = string.IsNullOrWhiteSpace(appVersion) - ? baseMetadata - : baseMetadata with { Version = appVersion! }; - if (wantsJson) - { - var payload = new VersionInfoJsonResult( - Name: "cdidx", - Version: metadata.Version, - Commit: metadata.Commit, - BuildDate: metadata.BuildDate, - Dirty: metadata.Dirty); - var json = JsonSerializer.Serialize(payload, CliJsonSerializerContextFactory.Create(jsonOptions).VersionInfoJsonResult); - Console.WriteLine(json); - return CommandExitCodes.Success; - } - var updateHint = UpdateChecker.GetNewerReleaseHint(metadata.Version, cancellationToken); - Console.WriteLine(FormatVersionLine(metadata, updateHint)); - return CommandExitCodes.Success; - } - - internal static string FormatVersionLine(ConsoleUi.BuildMetadata metadata, string? updateHint = null) - { - var commit = string.IsNullOrWhiteSpace(metadata.Commit) ? "unknown" : metadata.Commit; - var buildDate = string.IsNullOrWhiteSpace(metadata.BuildDate) ? "unknown" : metadata.BuildDate; - var dirty = string.IsNullOrWhiteSpace(metadata.Dirty) ? "unknown" : metadata.Dirty; - var suffix = string.IsNullOrWhiteSpace(updateHint) ? string.Empty : $" [{updateHint}]"; - // Suppress the metadata suffix only when every component is "unknown", - // so legacy callers that depend on the exact `cdidx v` shape keep - // working when no build stamp is present (e.g. mocked binaries). - // 全項目が unknown のときだけ末尾メタデータを省略し、ビルド刻印が - // 無い旧バイナリ/モックでも `cdidx v` 形式を保つ。 - if (commit == "unknown" && buildDate == "unknown" && dirty == "unknown") - return $"cdidx v{metadata.Version}{suffix}"; - return $"cdidx v{metadata.Version} (commit {commit}, built {buildDate}, {dirty}){suffix}"; - } - private static int RunCompletions(string[] cmdArgs, JsonSerializerOptions jsonOptions, string commandName = "--completions") - { - var usage = $"cdidx {commandName} "; - var wantsJson = ContainsJsonOutputFlag(cmdArgs); - if (wantsJson) - return CommandErrorWriter.WriteJsonOrHuman( - true, - jsonOptions, - "--json is not supported for completions.", - CommandExitCodes.UsageError, - "rerun with one of `bash`, `zsh`, `fish`, or `powershell`; completions output is already a shell script.", - usage); + // Strip `--metrics ` / `--metrics=` from the global args before subcommand + // parsing so any command (CLI or MCP) inherits the same JSONL metrics sink without + // each subcommand re-declaring the flag. Falls back to the CDIDX_METRICS env var when + // the explicit flag is absent. Anything after `--` is left untouched to preserve + // subcommand query-escape semantics (#1549). + // サブコマンド解析前に `--metrics ` / `--metrics=` を取り除き、CLI/MCPいずれの + // コマンドでも同じJSONLシンクを継承させる。明示フラグが無い場合は CDIDX_METRICS 環境変数に + // フォールバック。`--` 以降はサブコマンドのクエリエスケープ意味論を保つため触らない (#1549)。 - if (cmdArgs.Length == 0) - return CommandErrorWriter.Write( - $"{commandName} requires a shell value.", - CommandExitCodes.UsageError, - "rerun with one of `bash`, `zsh`, `fish`, or `powershell`.", - usage); + private const string DefaultMcpHttpListen = "127.0.0.1:38080"; + internal const string McpHttpTokenEnvVar = "CDIDX_MCP_HTTP_TOKEN"; - if (cmdArgs[0].StartsWith("-", StringComparison.Ordinal)) - return CommandErrorWriter.Write( - $"{commandName} requires a shell value, got option-like token '{cmdArgs[0]}'.", - CommandExitCodes.UsageError, - "rerun with one of `bash`, `zsh`, `fish`, or `powershell`.", - usage); - if (cmdArgs.Length > 1) - return CommandErrorWriter.Write( - $"{commandName} accepts exactly one shell value, got extra {ConsoleUi.Counted(cmdArgs.Length - 1, "argument")}: {string.Join(", ", cmdArgs.Skip(1).Select(arg => $"`{arg}`"))}.", - CommandExitCodes.UsageError, - "rerun with exactly one shell name: `bash`, `zsh`, `fish`, or `powershell`.", - usage); - if (ConsoleUi.PrintCompletions(cmdArgs[0])) - return CommandExitCodes.Success; - return CommandErrorWriter.Write( - $"unsupported completion shell `{cmdArgs[0]}`.", - CommandExitCodes.UsageError, - "rerun with one of `bash`, `zsh`, `fish`, or `powershell`.", - usage); - } - private static string StripErrorPrefix(string message) - { - const string prefix = "Error: "; - return message.StartsWith(prefix, StringComparison.Ordinal) ? message[prefix.Length..] : message; - } - private static int ShowError(string[] args, string message, JsonSerializerOptions jsonOptions) - { - if (ContainsJsonOutputFlag(args)) - { - return CommandErrorWriter.WriteJsonOrHuman( - true, - jsonOptions, - message, - CommandExitCodes.UsageError, - "run `cdidx --help` to list available commands."); - } - CommandErrorWriter.WriteStderr($"Error: {message}"); - var input = args[0]; - if (!input.StartsWith('-')) - { - var best = ConsoleUi.FindClosestCommand(input); - if (best != null) - CommandErrorWriter.WriteStderr($"Did you mean: cdidx {best}?"); - } - CommandErrorWriter.WriteStderr("Run 'cdidx --help' for usage information."); - return CommandExitCodes.UsageError; - } } From a788a245ba4e48ba71b4959f68cd9cb2b6d780d6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 18:58:56 +0900 Subject: [PATCH 059/101] Decompose the upgrade workflow --- src/CodeIndex/Cli/ProgramRunner.Upgrade.cs | 282 ++++++++++++++++----- 1 file changed, 213 insertions(+), 69 deletions(-) diff --git a/src/CodeIndex/Cli/ProgramRunner.Upgrade.cs b/src/CodeIndex/Cli/ProgramRunner.Upgrade.cs index 2f2793fa9..40cb06fe0 100644 --- a/src/CodeIndex/Cli/ProgramRunner.Upgrade.cs +++ b/src/CodeIndex/Cli/ProgramRunner.Upgrade.cs @@ -65,12 +65,84 @@ internal static int RunUpgrade( string appVersion, CancellationToken cancellationToken = default) { - var checkOnly = false; var wantsJson = cmdArgs.Contains("--json", StringComparer.Ordinal); + if (!TryParseUpgradeSelection(cmdArgs, out var selection, out var selectionError)) + return WriteUpgradeUsageError(selectionError, wantsJson, jsonOptions); + + if (!TryGetUpgradeVerificationPolicy(out var verificationPolicy, out var verificationPolicyError)) + return WriteUpgradeUsageError(verificationPolicyError, wantsJson, jsonOptions); + + if (selection.ExplicitVersion != null + && IsPrereleaseTag(selection.ExplicitVersion) + && selection.SelectedChannel == "stable") + { + selection = selection with + { + SelectedChannel = "prerelease", + IncludePrerelease = true, + }; + } + + var result = ResolveUpgradeRelease(selection, appVersion, cancellationToken); + var shouldInstall = result.LatestVersion != null + && (selection.ExplicitVersion != null || result.UpdateAvailable); + if (selection.CheckOnly || !shouldInstall) + return WriteUpgradeAvailabilityResult(result, selection, verificationPolicy, wantsJson, jsonOptions); + + var selectedReleaseTag = result.LatestVersion!; + if (TryHandleUnsupportedUpgradePlatform( + selectedReleaseTag, + result, + selection, + verificationPolicy, + wantsJson, + jsonOptions, + out var platformExitCode)) + { + return platformExitCode; + } + + if (!TryResolveWritableUpgradeInstallDirectory( + result, + selection, + verificationPolicy, + wantsJson, + jsonOptions, + out var installDir, + out var installDirectoryExitCode)) + { + return installDirectoryExitCode; + } + + return RunVerifiedUpgradeInstaller( + selectedReleaseTag, + installDir, + result, + selection, + verificationPolicy, + wantsJson, + jsonOptions, + cancellationToken); + } + + private sealed record UpgradeSelection( + bool CheckOnly, + string SelectedChannel, + bool IncludePrerelease, + string SelectionSource, + string? ExplicitVersion); + + private static bool TryParseUpgradeSelection( + string[] cmdArgs, + out UpgradeSelection selection, + out string error) + { + var checkOnly = false; var selectedChannel = "stable"; var includePrerelease = false; var selectionSource = "latest"; string? explicitVersion = null; + for (var i = 0; i < cmdArgs.Length; i++) { var arg = cmdArgs[i]; @@ -93,10 +165,18 @@ internal static int RunUpgrade( if (arg == "--channel") { if (i + 1 >= cmdArgs.Length) - return WriteUpgradeUsageError("--channel requires a value: stable, latest, or prerelease.", wantsJson, jsonOptions); + { + selection = default!; + error = "--channel requires a value: stable, latest, or prerelease."; + return false; + } if (!TryApplyUpgradeChannel(cmdArgs[++i], out selectedChannel, out includePrerelease, out var channelError)) - return WriteUpgradeUsageError(channelError, wantsJson, jsonOptions); + { + selection = default!; + error = channelError; + return false; + } selectionSource = selectedChannel; continue; @@ -104,7 +184,11 @@ internal static int RunUpgrade( if (arg.StartsWith("--channel=", StringComparison.Ordinal)) { if (!TryApplyUpgradeChannel(arg["--channel=".Length..], out selectedChannel, out includePrerelease, out var channelError)) - return WriteUpgradeUsageError(channelError, wantsJson, jsonOptions); + { + selection = default!; + error = channelError; + return false; + } selectionSource = selectedChannel; continue; @@ -112,10 +196,18 @@ internal static int RunUpgrade( if (arg == "--version") { if (i + 1 >= cmdArgs.Length) - return WriteUpgradeUsageError("--version requires a release tag such as v1.29.0.", wantsJson, jsonOptions); + { + selection = default!; + error = "--version requires a release tag such as v1.29.0."; + return false; + } if (!TryNormalizeReleaseTag(cmdArgs[++i], out explicitVersion, out var versionError)) - return WriteUpgradeUsageError(versionError, wantsJson, jsonOptions); + { + selection = default!; + error = versionError; + return false; + } selectionSource = "explicit_version"; continue; @@ -123,67 +215,87 @@ internal static int RunUpgrade( if (arg.StartsWith("--version=", StringComparison.Ordinal)) { if (!TryNormalizeReleaseTag(arg["--version=".Length..], out explicitVersion, out var versionError)) - return WriteUpgradeUsageError(versionError, wantsJson, jsonOptions); + { + selection = default!; + error = versionError; + return false; + } selectionSource = "explicit_version"; continue; } - return WriteUpgradeUsageError($"upgrade does not accept '{arg}'.", wantsJson, jsonOptions); + selection = default!; + error = $"upgrade does not accept '{arg}'."; + return false; } - if (!TryGetUpgradeVerificationPolicy(out var verificationPolicy, out var verificationPolicyError)) - return WriteUpgradeUsageError(verificationPolicyError, wantsJson, jsonOptions); - - if (explicitVersion != null && IsPrereleaseTag(explicitVersion) && selectedChannel == "stable") - { - selectedChannel = "prerelease"; - includePrerelease = true; - } + selection = new UpgradeSelection( + checkOnly, + selectedChannel, + includePrerelease, + selectionSource, + explicitVersion); + error = string.Empty; + return true; + } - var result = explicitVersion != null + private static UpdateCheckResult ResolveUpgradeRelease( + UpgradeSelection selection, + string appVersion, + CancellationToken cancellationToken) + => selection.ExplicitVersion != null ? new UpdateCheckResult( appVersion, - explicitVersion, - UpdateChecker.IsNewerRelease(explicitVersion, appVersion), + selection.ExplicitVersion, + UpdateChecker.IsNewerRelease(selection.ExplicitVersion, appVersion), FromCache: false, Error: null) - : includePrerelease + : selection.IncludePrerelease ? CheckLatestPrerelease(appVersion, cancellationToken) : UpdateChecker.Check(appVersion, cancellationToken); - var shouldInstall = result.LatestVersion != null && (explicitVersion != null || result.UpdateAvailable); - if (checkOnly || !shouldInstall) + private static int WriteUpgradeAvailabilityResult( + UpdateCheckResult result, + UpgradeSelection selection, + string verificationPolicy, + bool wantsJson, + JsonSerializerOptions jsonOptions) + { + var metadataFailureExitCode = result.Error is null + ? CommandExitCodes.Success + : CommandExitCodes.RuntimeError; + if (wantsJson) { - var metadataFailureExitCode = result.Error is null - ? CommandExitCodes.Success - : CommandExitCodes.RuntimeError; - if (wantsJson) - { - Console.WriteLine(JsonSerializer.Serialize( - CreateUpgradeJsonResult( - result, - selectedChannel, - selectionSource, - includePrerelease, - verificationPolicy, - installAttempted: false, - installExitCode: null, - error: null), - jsonOptions)); - } - else if (result.UpdateAvailable && result.LatestVersion != null) - Console.WriteLine($"A newer cdidx {selectedChannel} release is available: {result.LatestVersion} (current: {result.CurrentVersion})."); - else if (result.Error != null) - Console.WriteLine($"Could not select a cdidx {selectedChannel} release ({result.Error}); current: {result.CurrentVersion}."); - else - Console.WriteLine($"cdidx is up to date (current: {result.CurrentVersion})."); - return metadataFailureExitCode; + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult( + result, + selection.SelectedChannel, + selection.SelectionSource, + selection.IncludePrerelease, + verificationPolicy, + installAttempted: false, + installExitCode: null, + error: null), + jsonOptions)); } + else if (result.UpdateAvailable && result.LatestVersion != null) + Console.WriteLine($"A newer cdidx {selection.SelectedChannel} release is available: {result.LatestVersion} (current: {result.CurrentVersion})."); + else if (result.Error != null) + Console.WriteLine($"Could not select a cdidx {selection.SelectedChannel} release ({result.Error}); current: {result.CurrentVersion}."); + else + Console.WriteLine($"cdidx is up to date (current: {result.CurrentVersion})."); + return metadataFailureExitCode; + } - var selectedReleaseTag = result.LatestVersion!; - bool? manifestProvenanceVerified = null; - bool? installerProvenanceVerified = null; - + private static bool TryHandleUnsupportedUpgradePlatform( + string selectedReleaseTag, + UpdateCheckResult result, + UpgradeSelection selection, + string verificationPolicy, + bool wantsJson, + JsonSerializerOptions jsonOptions, + out int exitCode) + { if (OperatingSystem.IsWindows()) { var handoff = CreateWindowsUpgradeHandoff(selectedReleaseTag, RuntimeInformation.ProcessArchitecture); @@ -192,9 +304,9 @@ internal static int RunUpgrade( Console.WriteLine(JsonSerializer.Serialize( CreateUpgradeJsonResult( result, - selectedChannel, - selectionSource, - includePrerelease, + selection.SelectedChannel, + selection.SelectionSource, + selection.IncludePrerelease, verificationPolicy, installAttempted: false, installExitCode: null, @@ -209,7 +321,8 @@ internal static int RunUpgrade( CommandErrorWriter.WriteStderr($"Release page: {handoff.Url}"); CommandErrorWriter.WriteStderr($"Manual zip asset: {handoff.Asset} ({handoff.AssetUrl})"); } - return CommandExitCodes.FeatureUnavailable; + exitCode = CommandExitCodes.FeatureUnavailable; + return true; } if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) @@ -219,9 +332,9 @@ internal static int RunUpgrade( Console.WriteLine(JsonSerializer.Serialize( CreateUpgradeJsonResult( result, - selectedChannel, - selectionSource, - includePrerelease, + selection.SelectedChannel, + selection.SelectionSource, + selection.IncludePrerelease, verificationPolicy, installAttempted: false, installExitCode: null, @@ -233,10 +346,24 @@ internal static int RunUpgrade( CommandErrorWriter.WriteStderr("Error: cdidx upgrade currently requires a POSIX shell installer on Linux or macOS."); CommandErrorWriter.WriteStderr("Hint: download the latest release asset manually, or rerun install.sh from a shell environment."); } - return CommandExitCodes.FeatureUnavailable; + exitCode = CommandExitCodes.FeatureUnavailable; + return true; } - var installDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + exitCode = CommandExitCodes.Success; + return false; + } + + private static bool TryResolveWritableUpgradeInstallDirectory( + UpdateCheckResult result, + UpgradeSelection selection, + string verificationPolicy, + bool wantsJson, + JsonSerializerOptions jsonOptions, + out string installDir, + out int exitCode) + { + installDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (!TryCheckInstallDirectoryWritable(installDir, out var installDirectoryError)) { if (wantsJson) @@ -244,9 +371,9 @@ internal static int RunUpgrade( Console.WriteLine(JsonSerializer.Serialize( CreateUpgradeJsonResult( result, - selectedChannel, - selectionSource, - includePrerelease, + selection.SelectedChannel, + selection.SelectionSource, + selection.IncludePrerelease, verificationPolicy, installAttempted: false, installExitCode: null, @@ -262,9 +389,26 @@ internal static int RunUpgrade( WriteUpgradeInstallerTrustDiagnostic(); CommandErrorWriter.WriteStderr("Hint: rerun with permissions that can write this directory, or reinstall cdidx into a per-user directory."); } - return CommandExitCodes.UsageError; + exitCode = CommandExitCodes.UsageError; + return false; } + exitCode = CommandExitCodes.Success; + return true; + } + + private static int RunVerifiedUpgradeInstaller( + string selectedReleaseTag, + string installDir, + UpdateCheckResult result, + UpgradeSelection selection, + string verificationPolicy, + bool wantsJson, + JsonSerializerOptions jsonOptions, + CancellationToken cancellationToken) + { + bool? manifestProvenanceVerified = null; + bool? installerProvenanceVerified = null; string? scriptDirectory = null; string? scriptPath = null; string? checksumManifestPath = null; @@ -332,9 +476,9 @@ internal static int RunUpgrade( Console.WriteLine(JsonSerializer.Serialize( CreateUpgradeJsonResult( result, - selectedChannel, - selectionSource, - includePrerelease, + selection.SelectedChannel, + selection.SelectionSource, + selection.IncludePrerelease, verificationPolicy, installAttempted: true, installExitCode: installExitCode, @@ -357,9 +501,9 @@ internal static int RunUpgrade( Console.WriteLine(JsonSerializer.Serialize( CreateUpgradeJsonResult( result, - selectedChannel, - selectionSource, - includePrerelease, + selection.SelectedChannel, + selection.SelectionSource, + selection.IncludePrerelease, verificationPolicy, installAttempted: false, installExitCode: null, From c1bfcd71da7c39b03cc32a416726d13035ba5918 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 19:15:41 +0900 Subject: [PATCH 060/101] Split index command responsibilities --- .../Cli/IndexCommandRunner.ChangeDetection.cs | 270 ++++ .../Cli/IndexCommandRunner.Diagnostics.cs | 347 ++++++ .../Cli/IndexCommandRunner.GitExclude.cs | 125 ++ .../Cli/IndexCommandRunner.GitMetadata.cs | 161 +++ src/CodeIndex/Cli/IndexCommandRunner.Reuse.cs | 63 + .../Cli/IndexCommandRunner.WorkItems.cs | 222 ++++ src/CodeIndex/Cli/IndexCommandRunner.cs | 1086 ----------------- 7 files changed, 1188 insertions(+), 1086 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.ChangeDetection.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.Diagnostics.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.GitExclude.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.GitMetadata.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.Reuse.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.WorkItems.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.ChangeDetection.cs b/src/CodeIndex/Cli/IndexCommandRunner.ChangeDetection.cs new file mode 100644 index 000000000..93dccdfdf --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.ChangeDetection.cs @@ -0,0 +1,270 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private static Dictionary GetHotspotFamilyMarkerFingerprints( + FileIndexer indexer, + CancellationToken cancellationToken = default) => + indexer.GetProjectMarkerFingerprintResults(cancellationToken); + + private static int AddProjectMarkerFingerprintWarnings( + IReadOnlyDictionary currentFingerprints, + List warningList, + IndexCommandOptions options) + { + var added = 0; + var seen = new HashSet(StringComparer.Ordinal); + foreach (var fingerprint in currentFingerprints.Values) + { + foreach (var warning in fingerprint.Warnings) + { + if (!IsProjectMarkerFingerprintWarning(warning)) + continue; + + var path = string.IsNullOrWhiteSpace(warning.Path) + ? "" + : warning.Path; + var key = $"{path}\0{warning.Message}"; + if (!seen.Add(key)) + continue; + + warningList.Add(new CliJsonMessage(path, warning.Message)); + added++; + if (!options.Json && !options.Quiet) + ConsoleUi.PrintWarning($"{path}: {warning.Message}"); + } + } + + return added; + } + + private static bool IsProjectMarkerFingerprintWarning(FileIndexer.ScanError warning) => + warning.Message.StartsWith("Project marker discovery skipped", StringComparison.Ordinal) + || warning.Message.StartsWith("Project marker discovery truncated", StringComparison.Ordinal) + || warning.Message.StartsWith("Skipped .gitmodules", StringComparison.Ordinal); + + private static void RestampHotspotFamilyTrustForUpdate( + DbWriter writer, + IReadOnlyDictionary priorVersions, + IReadOnlyDictionary priorFingerprints, + IReadOnlyDictionary currentFingerprints) + { + var currentVersion = DbContext.HotspotFamilyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); + foreach (var lang in FileIndexer.GetHotspotFamilyMarkerLanguages()) + { + if (!currentFingerprints.TryGetValue(lang, out var currentFingerprint)) + continue; + + if (!currentFingerprint.IsComplete) + { + writer.MarkHotspotFamilyMarkerFingerprintIncomplete(lang, currentFingerprint.Fingerprint); + continue; + } + + if (priorVersions.TryGetValue(lang, out var priorVersion) + && priorFingerprints.TryGetValue(lang, out var priorFingerprint) + && priorVersion == currentVersion + && priorFingerprint == currentFingerprint.Fingerprint) + { + writer.MarkHotspotFamilyReady(lang, currentFingerprint.Fingerprint); + } + } + } + + private static void RestampHotspotFamilyTrustForFullScan( + DbWriter writer, + IReadOnlySet? reusedLanguages, + IReadOnlyDictionary priorVersions, + IReadOnlyDictionary priorFingerprints, + IReadOnlyDictionary currentFingerprints) + { + var currentVersion = DbContext.HotspotFamilyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); + foreach (var lang in FileIndexer.GetHotspotFamilyMarkerLanguages()) + { + if (!currentFingerprints.TryGetValue(lang, out var currentFingerprint)) + continue; + + if (!currentFingerprint.IsComplete) + { + writer.MarkHotspotFamilyMarkerFingerprintIncomplete(lang, currentFingerprint.Fingerprint); + continue; + } + + priorVersions.TryGetValue(lang, out var priorVersion); + priorFingerprints.TryGetValue(lang, out var priorFingerprint); + if (reusedLanguages?.Contains(lang) != true || (priorVersion == currentVersion && priorFingerprint == currentFingerprint.Fingerprint)) + writer.MarkHotspotFamilyReady(lang, currentFingerprint.Fingerprint); + } + } + + private static Dictionary GetHotspotFamilyTrustMatchesCurrent( + IReadOnlyDictionary priorVersions, + IReadOnlyDictionary priorFingerprints, + IReadOnlyDictionary currentFingerprints) + { + var currentVersion = DbContext.HotspotFamilyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); + var values = new Dictionary(StringComparer.Ordinal); + foreach (var lang in FileIndexer.GetHotspotFamilyMarkerLanguages()) + { + currentFingerprints.TryGetValue(lang, out var currentFingerprint); + priorVersions.TryGetValue(lang, out var priorVersion); + priorFingerprints.TryGetValue(lang, out var priorFingerprint); + values[lang] = currentFingerprint.IsComplete + && priorVersion == currentVersion + && priorFingerprint == currentFingerprint.Fingerprint; + } + + return values; + } + + private static bool AllowReuseWithCurrentHotspotFamilyTrust( + string? lang, + IReadOnlyDictionary hotspotFamilyTrustMatchesCurrent) + { + if (!FileIndexer.SupportsHotspotFamilyMarkerLanguage(lang)) + return true; + + return lang != null + && hotspotFamilyTrustMatchesCurrent.TryGetValue(lang, out var matchesCurrent) + && matchesCurrent; + } + + internal static bool IsOutsideProjectRoot(string relativePath) + { + if (Path.IsPathRooted(relativePath)) + return true; + + var normalized = OperatingSystem.IsWindows() + ? relativePath.Replace('\\', '/') + : relativePath; + return normalized == ".." || normalized.StartsWith("../", StringComparison.Ordinal); + } + + private static bool ContainsIgnoreFilePath(IEnumerable paths) + => paths.Any(FileIndexer.IsIgnoreFilePath); + + private static bool ContainsExtractorConfigurationPath(string projectRoot, IEnumerable paths) + => paths.Any(path => + FileIndexer.ClassifyIndexInputInvalidation(projectRoot, path) + == FileIndexer.IndexInputInvalidationKind.ExtractorConfiguration); + + private static bool ContainsJavaScriptTypeScriptConfigPath(IEnumerable paths) + => paths.Any(IsJavaScriptTypeScriptConfigPath); + + private static bool IsJavaScriptTypeScriptLanguage(string? language) + => string.Equals(language, "javascript", StringComparison.Ordinal) + || string.Equals(language, "typescript", StringComparison.Ordinal); + + private static bool IsJavaScriptTypeScriptConfigPath(string path) + { + var fileName = Path.GetFileName(path.AsSpan()); + return fileName.Equals("jsconfig.json".AsSpan(), StringComparison.OrdinalIgnoreCase) + || fileName.Equals("tsconfig.json".AsSpan(), StringComparison.OrdinalIgnoreCase) + || (fileName.StartsWith("jsconfig.".AsSpan(), StringComparison.OrdinalIgnoreCase) + && fileName.EndsWith(".json".AsSpan(), StringComparison.OrdinalIgnoreCase)) + || (fileName.StartsWith("tsconfig.".AsSpan(), StringComparison.OrdinalIgnoreCase) + && fileName.EndsWith(".json".AsSpan(), StringComparison.OrdinalIgnoreCase)); + } + + private static bool ContainsRelevantIgnoreFileUpdate(string projectRoot, IEnumerable updateFiles) + { + foreach (var file in updateFiles) + { + var absolutePath = Path.IsPathRooted(file) + ? Path.GetFullPath(file) + : Path.GetFullPath(Path.Combine(projectRoot, file)); + if (FileIndexer.IsIgnoreFilePath(absolutePath) && IsRelevantIgnoreFileForProjectRoot(projectRoot, absolutePath)) + return true; + } + + return false; + } + + private static IReadOnlyList NormalizeCommitFileTargets( + string projectRoot, + string repoRoot, + IEnumerable changedFiles, + out bool relevantIgnoreFileChanged) + { + relevantIgnoreFileChanged = false; + var normalized = new List(); + foreach (var changedFile in changedFiles) + { + var absolutePath = Path.GetFullPath(Path.Combine(repoRoot, changedFile.Replace('/', Path.DirectorySeparatorChar))); + if (FileIndexer.IsIgnoreFilePath(absolutePath) && IsRelevantIgnoreFileForProjectRoot(projectRoot, absolutePath)) + relevantIgnoreFileChanged = true; + + var relativePath = FileIndexer.NormalizePathSeparators( + FileIndexer.GetRelativePathFromProjectRoot(projectRoot, absolutePath)); + if (IsOutsideProjectRoot(relativePath)) + continue; + + normalized.Add(relativePath); + } + + return normalized; + } + + private static bool IsRelevantIgnoreFileForProjectRoot(string projectRoot, string ignoreFileAbsolutePath) + { + var ignoreDirectory = Path.GetDirectoryName(ignoreFileAbsolutePath); + if (string.IsNullOrEmpty(ignoreDirectory)) + return false; + + return IsPathEqualOrParent(ignoreDirectory, projectRoot) + || IsPathEqualOrParent(projectRoot, ignoreDirectory); + } + + private static string DescribePathFilter(FileIndexer.PathFilterKind filterKind) + => filterKind switch + { + FileIndexer.PathFilterKind.IgnoredByRules => "ignored by .gitignore/.cdidxignore", + FileIndexer.PathFilterKind.ExcludedByDefaultDirectory => "excluded by default directory rules", + FileIndexer.PathFilterKind.ExcludedByDefaultFile => "excluded by default file rules", + FileIndexer.PathFilterKind.OutsideProjectRoot => "outside the project root", + FileIndexer.PathFilterKind.IgnoreRulesUnavailable => "ignore rules unavailable", + _ => "filtered", + }; + + private static IReadOnlyList NormalizeUpdateFileTargets(string projectRoot, IEnumerable updateFiles, bool json) + { + var normalized = new List(); + foreach (var file in updateFiles) + { + var absPath = Path.IsPathRooted(file) ? file : Path.GetFullPath(Path.Combine(projectRoot, file)); + var relPath = FileIndexer.NormalizePathSeparators( + FileIndexer.GetRelativePathFromProjectRoot(projectRoot, absPath)); + if (IsOutsideProjectRoot(relPath)) + { + if (!json) + CommandErrorWriter.WriteStderr($" [WARN] Skipping file outside project root: {file}. Use a path under the indexed project root or run `cdidx index` from the correct workspace."); + continue; + } + + normalized.Add(relPath); + } + + return normalized; + } + + private static bool IsPathEqualOrParent(string candidateParent, string candidateChild) + { + var normalizedParent = Path.GetFullPath(candidateParent) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var normalizedChild = Path.GetFullPath(candidateChild) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return PathCasing.IsPathEqualOrParent(normalizedParent, normalizedChild); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Diagnostics.cs b/src/CodeIndex/Cli/IndexCommandRunner.Diagnostics.cs new file mode 100644 index 000000000..36c9b8e75 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Diagnostics.cs @@ -0,0 +1,347 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private static string DescribeLockHolder(IndexLockInfo? holder) + { + if (holder == null) + return string.Empty; + var startedLocal = holder.StartedAt.ToLocalTime(); + var verification = holder.Verification switch + { + IndexLockHolderVerification.Verified => "verified", + IndexLockHolderVerification.Stale => "stale", + _ => "unverified", + }; + return $"PID {holder.Pid.ToString(System.Globalization.CultureInfo.InvariantCulture)} ({verification}), started {startedLocal.ToString("yyyy-MM-dd HH:mm:ss zzz", System.Globalization.CultureInfo.InvariantCulture)}"; + } + private static Dictionary GetHotspotFamilyMetaSnapshot(DbContext db, Func keyFactory) + { + var languages = FileIndexer.GetHotspotFamilyMarkerLanguages(); + var values = new Dictionary(StringComparer.Ordinal); + var keys = new string[languages.Count]; + for (var i = 0; i < languages.Count; i++) + { + var lang = languages[i]; + keys[i] = keyFactory(lang); + values[lang] = null; + } + + var metaValues = db.GetMetaStrings(keys); + for (var i = 0; i < languages.Count; i++) + values[languages[i]] = metaValues.TryGetValue(keys[i], out var value) ? value : null; + + return values; + } + + private static IndexMemorySampleJsonResult CaptureMemorySample(string phase, Stopwatch stopwatch) + { + var snapshot = ProcessMemorySnapshot.Capture(); + return new IndexMemorySampleJsonResult + { + Phase = phase, + ElapsedMs = stopwatch.ElapsedMilliseconds, + HeapBytes = snapshot.HeapBytes, + TotalAllocatedBytes = snapshot.TotalAllocatedBytes, + GcHeapSizeBytes = snapshot.GcHeapSizeBytes, + FragmentedBytes = snapshot.FragmentedBytes, + WorkingSetBytes = snapshot.WorkingSetBytes, + Gen0Collections = snapshot.Gen0Collections, + Gen1Collections = snapshot.Gen1Collections, + Gen2Collections = snapshot.Gen2Collections, + }; + } + + private static IndexMemoryTimelineJsonResult? BuildMemoryTimeline(List samples) + { + if (samples.Count == 0) + return null; + + return new IndexMemoryTimelineJsonResult + { + Samples = samples, + PeakWorkingSetBytes = samples.Max(static sample => sample.WorkingSetBytes), + PeakHeapBytes = samples.Max(static sample => sample.HeapBytes), + }; + } + + private static void WarnIfMemoryThresholdExceeded(IndexMemoryTimelineJsonResult? timeline) + { + var rawThreshold = CdidxEnvironment.GetProcessEnvironmentVariable("CDIDX_MEM_WARN_MB"); + if (timeline == null || !long.TryParse(rawThreshold, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var thresholdMb) || thresholdMb <= 0) + return; + + var peakMb = timeline.PeakWorkingSetBytes / (1024 * 1024); + if (peakMb >= thresholdMb) + CommandErrorWriter.WriteStderr($"Warning: cdidx working set reached {peakMb:N0} MB (CDIDX_MEM_WARN_MB={thresholdMb:N0})."); + } + + private static void StampLastIndexRunMetadata( + DbWriter writer, + string mode, + DateTime startedAtUtc, + long durationMs, + long filesScanned, + long filesSkipped, + long parseErrors, + long bytesRead, + long bytesReadSkippedFileCount, + long rowsUpserted, + long rowsDeleted, + IndexMemoryTimelineJsonResult? memoryTimeline) + => StampLastIndexRunMetadata( + writer, + mode, + startedAtUtc, + durationMs, + filesScanned, + filesSkipped, + parseErrors, + bytesRead, + bytesReadSkippedFileCount, + rowsUpserted, + rowsDeleted, + memoryTimeline, + diagnostics: null, + referenceExtractionCapHits: null); + + private static void StampLastIndexRunMetadata( + DbWriter writer, + string mode, + DateTime startedAtUtc, + long durationMs, + long filesScanned, + long filesSkipped, + long parseErrors, + long bytesRead, + long bytesReadSkippedFileCount, + long rowsUpserted, + long rowsDeleted, + IndexMemoryTimelineJsonResult? memoryTimeline, + IReadOnlyList? diagnostics, + ReferenceExtractionCapHitSummary? referenceExtractionCapHits) + { + writer.SetMetaValues( + (DbContext.LastIndexRunModeMetaKey, mode), + (DbContext.LastIndexRunStartedAtMetaKey, startedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastIndexRunDurationMsMetaKey, durationMs.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastIndexRunFilesScannedMetaKey, filesScanned.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastIndexRunFilesSkippedMetaKey, filesSkipped.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastIndexRunParseErrorsMetaKey, parseErrors.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastIndexRunBytesReadMetaKey, bytesRead.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastIndexRunBytesReadSkippedFileCountMetaKey, bytesReadSkippedFileCount.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastIndexRunBytesReadIncompleteMetaKey, (bytesReadSkippedFileCount > 0).ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastIndexRunRowsUpsertedMetaKey, rowsUpserted.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastIndexRunRowsDeletedMetaKey, rowsDeleted.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastIndexRunReferenceExtractionCapHitsMetaKey, referenceExtractionCapHits == null + ? null + : JsonSerializer.Serialize(referenceExtractionCapHits, StatusMetadataJsonContext.Default.ReferenceExtractionCapHitSummary)), + (DbContext.LastIndexRunPeakMemoryMbMetaKey, memoryTimeline == null + ? null + : (memoryTimeline.PeakWorkingSetBytes / (1024 * 1024)).ToString(System.Globalization.CultureInfo.InvariantCulture))); + StampLastIndexRunDiagnostics(writer, diagnostics); + writer.MarkIndexComplete(); + writer.ClearLastFailedIndexRunMetadata(); + } + + internal static void StampLastIndexRunDiagnostics(DbWriter writer, IReadOnlyList? diagnostics) + { + var total = diagnostics?.Count ?? 0; + if (total == 0) + { + writer.SetMetaValues( + (DbContext.LastIndexRunDiagnosticsMetaKey, null), + (DbContext.LastIndexRunDiagnosticCountMetaKey, null), + (DbContext.LastIndexRunDiagnosticsTruncatedMetaKey, null)); + return; + } + + var sample = JsonStringListCodec.TakeSerializableSample( + diagnostics!, + DbContext.LastIndexRunDiagnosticSampleLimit); + writer.SetMetaValues( + (DbContext.LastIndexRunDiagnosticsMetaKey, JsonStringListCodec.Serialize(sample)), + (DbContext.LastIndexRunDiagnosticCountMetaKey, total.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastIndexRunDiagnosticsTruncatedMetaKey, (total > sample.Count).ToString(System.Globalization.CultureInfo.InvariantCulture))); + } + + internal static Action>? PlannerStatisticsMaintenanceDiagnosticStampingForTesting + { + get => ScopedPlannerStatisticsMaintenanceDiagnosticStampingForTesting.Value; + set => ScopedPlannerStatisticsMaintenanceDiagnosticStampingForTesting.Value = value; + } + + internal static bool TryStampPlannerStatisticsMaintenanceDiagnostic( + DbWriter writer, + List indexRunDiagnostics, + DbContext.PlannerStatisticsMaintenanceFailure plannerMaintenanceFailure) + { + indexRunDiagnostics.Add(FormatPlannerStatisticsMaintenanceDiagnostic(plannerMaintenanceFailure)); + try + { + PlannerStatisticsMaintenanceDiagnosticStampingForTesting?.Invoke(writer, indexRunDiagnostics); + StampLastIndexRunDiagnostics(writer, indexRunDiagnostics); + return true; + } + catch (Exception ex) + { + GlobalToolLog.Error("planner_statistics_maintenance_diagnostic_persist_failed", ex, includeStacks: false); + return false; + } + } + + internal static string FormatIndexRunDiagnostic(string code, Exception ex) + { + var raw = $"{code}: {ex.GetType().Name}: {DiagnosticRedactor.FormatExceptionMessage(ex, MaxIndexRunDiagnosticLength)}"; + return raw.Length <= MaxIndexRunDiagnosticLength + ? raw + : raw[..MaxIndexRunDiagnosticLength] + "..."; + } + + internal static string FormatIndexRunDiagnostic(string code, string? target, Exception ex) + { + if (string.IsNullOrWhiteSpace(target)) + return FormatIndexRunDiagnostic(code, ex); + + var raw = $"{code}: {CollapseLineBreaks(target)}: {ex.GetType().Name}: {DiagnosticRedactor.FormatExceptionMessage(ex, MaxIndexRunDiagnosticLength)}"; + return raw.Length <= MaxIndexRunDiagnosticLength + ? raw + : raw[..MaxIndexRunDiagnosticLength] + "..."; + } + + internal static string FormatPlannerStatisticsMaintenanceDiagnostic(DbContext.PlannerStatisticsMaintenanceFailure failure) + => FormatIndexRunDiagnostic( + "planner_statistics_maintenance_failed", + failure.CommandText, + failure.Exception); + + private static void RecordIndexRunDiagnostic(List? diagnostics, string code, Exception ex) + { + if (diagnostics == null) + return; + + diagnostics.Add(FormatIndexRunDiagnostic(code, ex)); + } + + private static void RecordIndexRunDiagnostic(List? diagnostics, string code, string? target, Exception ex) + { + if (diagnostics == null) + return; + + diagnostics.Add(FormatIndexRunDiagnostic(code, target, ex)); + } + + private static void TryStampLastFailedIndexRun( + string dbPath, + string status, + string mode, + DateTime startedAtUtc, + long durationMs, + long? filesProcessed, + long? filesTotal, + string errorCode, + string reason, + bool? progressPersisted = null, + string? recoveryHint = null) + { + if (string.IsNullOrWhiteSpace(dbPath) + || dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + || !File.Exists(dbPath)) + { + return; + } + + try + { + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + db.InitializeSchema(); + var writer = new DbWriter(db); + writer.SetMetaValues( + (DbContext.LastFailedIndexRunStatusMetaKey, status), + (DbContext.LastFailedIndexRunModeMetaKey, mode), + (DbContext.LastFailedIndexRunStartedAtMetaKey, startedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunDurationMsMetaKey, durationMs.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunFilesProcessedMetaKey, filesProcessed?.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunFilesTotalMetaKey, filesTotal?.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunErrorCodeMetaKey, errorCode), + (DbContext.LastFailedIndexRunReasonMetaKey, reason), + (DbContext.LastFailedIndexRunProgressPersistedMetaKey, progressPersisted?.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunRecoveryHintMetaKey, recoveryHint), + (DbContext.LastFailedIndexRunFileErrorsMetaKey, null)); + if (progressPersisted == true) + writer.MarkIndexIncomplete(["interrupted_index_run"]); + } + catch (Exception ex) when (ex is CodeIndexException or IOException or UnauthorizedAccessException or NotSupportedException or SqliteException) + { + } + } + + internal static FileByteReadSummary MeasureReadableFileBytes( + IEnumerable paths, + string? projectRoot = null, + List? diagnostics = null, + IReadOnlyDictionary? knownFileSizes = null) + => MeasureReadableFileBytes(paths, static path => path, projectRoot, diagnostics, knownFileSizes); + + internal static FileByteReadSummary MeasureReadableFileBytes( + IEnumerable paths, + Func pathSelector, + string? projectRoot = null, + List? diagnostics = null, + IReadOnlyDictionary? knownFileSizes = null) + { + long total = 0; + long skipped = 0; + foreach (var sourcePath in paths) + { + var path = pathSelector(sourcePath); + if (knownFileSizes != null && knownFileSizes.TryGetValue(path, out var knownSize)) + { + total += knownSize; + continue; + } + + try + { + var info = new FileInfo(path); + if (info.Exists) + total += info.Length; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) + { + skipped++; + RecordIndexRunDiagnostic(diagnostics, "file_size_bytes_skipped", FormatDiagnosticPath(projectRoot, path), ex); + } + } + + return new FileByteReadSummary(total, skipped); + } + + private static string FormatDiagnosticPath(string? projectRoot, string path) + { + if (string.IsNullOrWhiteSpace(projectRoot)) + return path; + + try + { + var relative = FileIndexer.NormalizePathSeparators(FileIndexer.GetRelativePathFromDirectory(projectRoot, path)); + return IsOutsideProjectRoot(relative) ? path : relative; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) + { + return path; + } + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.GitExclude.cs b/src/CodeIndex/Cli/IndexCommandRunner.GitExclude.cs new file mode 100644 index 000000000..fbd898f04 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.GitExclude.cs @@ -0,0 +1,125 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private static void AddToGitExclude( + string projectPath, + string dbPath, + List? diagnostics, + CancellationToken cancellationToken) + { + try + { + var projectRoot = Path.GetFullPath(projectPath); + var gitDir = GitHelper.ResolveGitCommonDir(projectRoot, cancellationToken); + if (gitDir == null) return; + + if (!GitHelper.TryResolveGitMetadataChildPath( + gitDir, + "info", + expectDirectory: true, + allowMissing: true, + out var infoDirectory)) + { + throw new IOException("Unsafe Git metadata info directory."); + } + + Directory.CreateDirectory(LongPath.EnsureWindowsPrefix(infoDirectory)); + if (!GitHelper.TryResolveGitMetadataChildPath( + gitDir, + "info", + expectDirectory: true, + allowMissing: false, + out infoDirectory) + || !GitHelper.TryResolveGitMetadataChildPath( + infoDirectory, + "exclude", + expectDirectory: false, + allowMissing: true, + out var excludeFile)) + { + throw new IOException("Unsafe Git metadata exclude path."); + } + + var dbAbsolutePath = Path.IsPathRooted(dbPath) + ? Path.GetFullPath(dbPath) + : Path.GetFullPath(Path.Combine(projectRoot, dbPath)); + var dbDirAbsolute = Path.GetDirectoryName(dbAbsolutePath); + if (string.IsNullOrEmpty(dbDirAbsolute)) return; + + var dbDirRelative = FileIndexer.NormalizePathSeparators(FileIndexer.GetRelativePathFromDirectory(projectRoot, dbDirAbsolute)); + if (IsOutsideProjectRoot(dbDirRelative)) return; + + string[] patterns; + if (dbDirRelative == ".") + { + var dbFileName = Path.GetFileName(dbAbsolutePath); + patterns = [dbFileName, $"{dbFileName}-*"]; + } + else + { + patterns = [$"{dbDirRelative.TrimEnd('/')}/"]; + } + + var ioExcludeFile = LongPath.EnsureWindowsPrefix(excludeFile); + var existingContent = File.Exists(ioExcludeFile) + ? DataDirectorySecurity.ReadTextWithinLimit(ioExcludeFile, MaxGitExcludeBytes, FileShare.ReadWrite) + : ""; + if (existingContent is null) + return; + + var existingLines = existingContent.Split('\n').Select(l => l.TrimEnd('\r')).ToHashSet(); + + var missing = patterns.Where(p => !existingLines.Contains(p)).ToList(); + if (missing.Count == 0) return; + + if (!GitHelper.TryResolveGitMetadataChildPath( + gitDir, + "info", + expectDirectory: true, + allowMissing: false, + out infoDirectory) + || !GitHelper.TryResolveGitMetadataChildPath( + infoDirectory, + "exclude", + expectDirectory: false, + allowMissing: true, + out excludeFile)) + { + throw new IOException("Git metadata exclude path became unsafe before write."); + } + + var updatedContent = new System.Text.StringBuilder(existingContent); + if (existingContent.Length > 0 && !existingContent.EndsWith('\n')) + updatedContent.AppendLine(); + updatedContent.AppendLine("# cdidx (CodeIndex) — auto-generated"); + foreach (var pattern in missing) + updatedContent.AppendLine(pattern); + + AtomicFileWriter.WriteText( + excludeFile, + updatedContent.ToString(), + new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + RecordIndexRunDiagnostic(diagnostics, "git_exclude_metadata_write_failed", ex); + } + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.GitMetadata.cs b/src/CodeIndex/Cli/IndexCommandRunner.GitMetadata.cs new file mode 100644 index 000000000..f767716c0 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.GitMetadata.cs @@ -0,0 +1,161 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private static void StampIndexedHeadMetadata(DbWriter writer, string projectRoot, List? diagnostics, CancellationToken cancellationToken) + { + try + { + var headSha = GitHelper.TryGetHeadCommit(projectRoot, cancellationToken); + var headBranch = GitHelper.TryGetHeadBranch(projectRoot, cancellationToken); + StampIndexedHeadMetadata(writer, headSha, headBranch); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // Best-effort metadata only; never fail an otherwise-successful index run. + // best-effort であり、stamp の失敗で index 全体を失敗扱いにしない。 + RecordIndexRunDiagnostic(diagnostics, "indexed_head_metadata_write_failed", ex); + } + StampWorkspacePathCaseSensitivity(writer, projectRoot, diagnostics, cancellationToken); + } + + private static void StampIndexedSymlinkPolicy(DbWriter writer, FileIndexer.SymlinkPolicy symlinkPolicy, List? diagnostics) + { + try + { + writer.SetMeta( + DbContext.IndexedFollowSymlinksPolicyMetaKey, + symlinkPolicy.ToString().ToLowerInvariant()); + } + catch (Exception ex) + { + // Best-effort metadata only; never fail an otherwise-successful index run. + // best-effort のみ。stamp 失敗で index 全体を落とさない。 + RecordIndexRunDiagnostic(diagnostics, "indexed_symlink_policy_metadata_write_failed", ex); + } + } + + private static void StampIndexedHeadMetadata(DbWriter writer, string? headSha, string? headBranch) + { + var timestamp = headSha != null + ? GetUtcNow().ToString("o", System.Globalization.CultureInfo.InvariantCulture) + : null; + writer.SetMetaValues( + (DbContext.IndexedHeadShaMetaKey, headSha), + (DbContext.IndexedHeadBranchMetaKey, headBranch), + (DbContext.IndexedHeadTimestampMetaKey, timestamp)); + } + + private static void TryStampIndexedHeadMetadata(DbWriter writer, string? headSha, string? headBranch, List? diagnostics) + { + try + { + StampIndexedHeadMetadata(writer, headSha, headBranch); + } + catch (Exception ex) + { + // Best-effort metadata only; never fail an otherwise-successful index run. + // best-effort であり、stamp の失敗で index 全体を失敗扱いにしない。 + RecordIndexRunDiagnostic(diagnostics, "indexed_head_metadata_write_failed", ex); + } + } + + private static void StampCommitScopedFreshHeadMetadata( + DbWriter writer, + IndexCommandOptions options, + string projectRoot, + string? currentHeadCommit, + List? diagnostics, + CancellationToken cancellationToken = default) + { + try + { + var coveredHead = !string.IsNullOrWhiteSpace(currentHeadCommit) + && (options.Commits.Any(commit => GitRefCoversCurrentHead(projectRoot, commit, currentHeadCommit, cancellationToken)) + || TryChangedBetweenCoversCurrentHead(options, projectRoot, currentHeadCommit, cancellationToken)) + ? currentHeadCommit + : null; + writer.SetMeta(DbContext.CommitScopedFreshHeadShaMetaKey, coveredHead); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // Best-effort metadata only; never fail an otherwise-successful index run. + // best-effort のみ。stamp 失敗で index 全体を落とさない。 + RecordIndexRunDiagnostic(diagnostics, "commit_scoped_head_metadata_write_failed", ex); + } + } + + private static bool GitRefCoversCurrentHead( + string projectRoot, + string refName, + string currentHeadCommit, + CancellationToken cancellationToken) + { + if (currentHeadCommit.StartsWith(refName, StringComparison.OrdinalIgnoreCase)) + return true; + + var resolvedRef = GitHelper.TryResolveCommit(projectRoot, refName, cancellationToken); + return string.Equals(resolvedRef, currentHeadCommit, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryChangedBetweenCoversCurrentHead( + IndexCommandOptions options, + string projectRoot, + string currentHeadCommit, + CancellationToken cancellationToken) + { + if (options.ChangedBetweenRefs.Count != 2) + return false; + + return GitRefCoversCurrentHead(projectRoot, options.ChangedBetweenRefs[1], currentHeadCommit, cancellationToken); + } + + // Issue #1546: capture the actual case-sensitivity of the workspace filesystem so + // `cdidx status` can diagnose phantom path collapses on case-sensitive APFS / WSL + // NTFS / ReFS volumes (where the OS-keyed heuristic would mismatch reality). Probed + // via the same `core.ignorecase` + filesystem probe used by FileIndexer, then + // persisted as "true" / "false" alongside the HEAD stamp. Failures are swallowed so + // an unwritable git config / temp probe never blocks an otherwise-successful index. + // #1546: workspace FS の大小区別を実プローブして codeindex_meta に保存する。 + // probe 失敗時は黙って null stamp にして index 本体は成功扱いのままとする。 + private static void StampWorkspacePathCaseSensitivity(DbWriter writer, string projectRoot, List? diagnostics, CancellationToken cancellationToken) + { + try + { + var ignoreCase = GitHelper.ResolveIgnoreCase(projectRoot, cancellationToken); + PathCasing.SeedFromWorkspace(projectRoot, ignoreCase); + var caseSensitive = (!ignoreCase).ToString(System.Globalization.CultureInfo.InvariantCulture); + writer.SetMeta(DbContext.WorkspacePathCaseSensitiveMetaKey, caseSensitive); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // Best-effort metadata only; never fail an otherwise-successful index run. + // best-effort のみ。stamp 失敗で index 全体を落とさない。 + RecordIndexRunDiagnostic(diagnostics, "path_case_sensitivity_metadata_write_failed", ex); + } + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Reuse.cs b/src/CodeIndex/Cli/IndexCommandRunner.Reuse.cs new file mode 100644 index 000000000..dc435e28d --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Reuse.cs @@ -0,0 +1,63 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private static string? GetStatReusableLanguage( + string absolutePath, + FileIndexer.LanguageDetectionResult detection) + { + if (string.Equals(Path.GetExtension(absolutePath), ".h", StringComparison.OrdinalIgnoreCase)) + return null; + + return detection.Status == FileIndexer.FileProbeStatus.Supported + ? detection.Language + : null; + } + + private static long? TryGetUnchangedFileIdFromChecksum( + DbWriter writer, + string absolutePath, + string relativePath, + string? language, + long? maxBytes) + { + if (language == null) + return null; + + try + { + var info = new FileInfo(absolutePath); + if (!info.Exists) + return null; + if (!FileIndexer.TryComputeChecksum(absolutePath, maxBytes ?? FileIndexer.DefaultMaxFileSizeBytes, out var checksum)) + return null; + + return writer.GetUnchangedFileId( + relativePath, + info.LastWriteTimeUtc, + checksum, + size: info.Length, + language: language); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.WorkItems.cs b/src/CodeIndex/Cli/IndexCommandRunner.WorkItems.cs new file mode 100644 index 000000000..35475ee12 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.WorkItems.cs @@ -0,0 +1,222 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private readonly record struct FullScanFileTarget( + string FilePath, + string RelativePath, + string DisplayRelativePath, + string IndexPath, + string? Language, + bool GeneratedExtractionSuppressed) + { + public static FullScanFileTarget CreateFromPath(string projectRoot, string path) + { + var filePath = Path.IsPathRooted(path) + ? path + : Path.Combine(projectRoot, FileIndexer.NormalizeRelativePathForCurrentPlatform(path)); + return Create(projectRoot, filePath); + } + + public static FullScanFileTarget Create(string projectRoot, string filePath, string? language = null) + { + var relativePath = FileIndexer.GetRelativePathFromProjectRoot(projectRoot, filePath); + return new FullScanFileTarget( + filePath, + relativePath, + FileIndexer.NormalizePathSeparators(relativePath), + FileIndexer.NormalizeIndexPath(relativePath), + language, + GeneratedExtractionSuppressed: false); + } + } + + private readonly record struct UpdateFileTarget( + string FilePath, + string RelativePath, + string DisplayRelativePath, + string IndexPath) + { + public static UpdateFileTarget Create(string projectRoot, string path) + { + var isRooted = Path.IsPathRooted(path); + var filePath = isRooted + ? path + : Path.Combine(projectRoot, path.Replace('/', Path.DirectorySeparatorChar)); + var relativePath = isRooted + ? FileIndexer.GetRelativePathFromProjectRoot(projectRoot, path) + : path; + return new UpdateFileTarget( + filePath, + relativePath, + FileIndexer.NormalizePathSeparators(relativePath), + FileIndexer.NormalizeIndexPath(relativePath)); + } + } + + private sealed record FullScanFileWorkItem( + int FileIndex, + string FilePath, + string RelativePath, + FileRecord? Record, + string? Content, + bool? HasOversizeLine, + int? ConflictMarkerLine, + string? Warning, + IReadOnlyList? Chunks, + IReadOnlyList? Symbols, + IReadOnlyList? References, + IReadOnlyList? Issues, + FileIssue? GeneratedSuppressionIssue, + bool GeneratedSuppressionChecked, + string? FailurePhase, + Exception? Exception) + { + public static FullScanFileWorkItem Success( + int fileIndex, + string filePath, + string relativePath, + FileRecord record, + string? content, + bool hasOversizeLine, + int conflictMarkerLine, + string? warning, + IReadOnlyList? chunks, + IReadOnlyList? symbols, + IReadOnlyList? references, + IReadOnlyList? issues, + FileIssue? generatedSuppressionIssue, + bool generatedSuppressionChecked) + { + return new FullScanFileWorkItem( + fileIndex, + filePath, + relativePath, + record, + content, + hasOversizeLine, + conflictMarkerLine, + warning, + chunks, + symbols, + references, + issues, + generatedSuppressionIssue, + generatedSuppressionChecked, + null, + null); + } + + public static FullScanFileWorkItem Precomputed( + int fileIndex, + string filePath, + string relativePath, + FileRecord record, + string? warning, + IReadOnlyList chunks, + IReadOnlyList symbols, + IReadOnlyList references, + IReadOnlyList issues, + FileIssue? generatedSuppressionIssue = null, + bool generatedSuppressionChecked = false) + { + return new FullScanFileWorkItem( + fileIndex, + filePath, + relativePath, + record, + null, + null, + null, + warning, + chunks, + symbols, + references, + issues, + generatedSuppressionIssue, + generatedSuppressionChecked, + null, + null); + } + + public static FullScanFileWorkItem Failure(int fileIndex, string filePath, string relativePath, string phase, Exception exception) + => new(fileIndex, filePath, relativePath, null, null, null, null, null, null, null, null, null, null, false, phase, exception); + + public static FullScanFileWorkItem Skipped(int fileIndex, string filePath, string relativePath, string warning) + => new(fileIndex, filePath, relativePath, null, null, null, null, warning, null, null, null, null, null, false, null, null); + } + + private sealed class CSharpWorkspaceSnapshotDriftException(string path) + : IOException("A C# source changed after workspace preflight; rerun indexing to refresh the complete C# graph.") + { + public string Path { get; } = path; + } + + private sealed record FoldOnlyRemediation( + string DegradedReason, + string RecommendedAction, + string AlternativeAction); + + private sealed class IndexInterruptedException : OperationCanceledException + { + public IndexInterruptedException(int filesProcessed, int? filesTotal, string? actualMode = null) + : base("Indexing was interrupted.") + { + FilesProcessed = filesProcessed; + FilesTotal = filesTotal; + ActualMode = actualMode; + } + + public int FilesProcessed { get; } + public int? FilesTotal { get; } + public string? ActualMode { get; } + } + + private sealed class IndexExtractionStalledException : Exception + { + public IndexExtractionStalledException(int filesProcessed, int? filesTotal, TimeSpan timeout, string? activePath, string? workerError = null) + : base("Index extraction stalled.") + { + FilesProcessed = filesProcessed; + FilesTotal = filesTotal; + Timeout = timeout; + ActivePath = activePath; + WorkerError = workerError; + } + + public int FilesProcessed { get; } + public int? FilesTotal { get; } + public TimeSpan Timeout { get; } + public string? ActivePath { get; } + public string? WorkerError { get; } + } + + private sealed class CancelKeyPressRegistration(ConsoleCancelEventHandler handler) : IDisposable + { + public void Dispose() + { + Console.CancelKeyPress -= handler; + } + } + + private sealed class NullDisposable : IDisposable + { + public static readonly NullDisposable Instance = new(); + + public void Dispose() + { + } + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index ff70200d6..fef743ba1 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -497,590 +497,7 @@ internal static int Run( indexCancellation.Token); } - private static string DescribeLockHolder(IndexLockInfo? holder) - { - if (holder == null) - return string.Empty; - var startedLocal = holder.StartedAt.ToLocalTime(); - var verification = holder.Verification switch - { - IndexLockHolderVerification.Verified => "verified", - IndexLockHolderVerification.Stale => "stale", - _ => "unverified", - }; - return $"PID {holder.Pid.ToString(System.Globalization.CultureInfo.InvariantCulture)} ({verification}), started {startedLocal.ToString("yyyy-MM-dd HH:mm:ss zzz", System.Globalization.CultureInfo.InvariantCulture)}"; - } - private static Dictionary GetHotspotFamilyMetaSnapshot(DbContext db, Func keyFactory) - { - var languages = FileIndexer.GetHotspotFamilyMarkerLanguages(); - var values = new Dictionary(StringComparer.Ordinal); - var keys = new string[languages.Count]; - for (var i = 0; i < languages.Count; i++) - { - var lang = languages[i]; - keys[i] = keyFactory(lang); - values[lang] = null; - } - - var metaValues = db.GetMetaStrings(keys); - for (var i = 0; i < languages.Count; i++) - values[languages[i]] = metaValues.TryGetValue(keys[i], out var value) ? value : null; - - return values; - } - - private static IndexMemorySampleJsonResult CaptureMemorySample(string phase, Stopwatch stopwatch) - { - var snapshot = ProcessMemorySnapshot.Capture(); - return new IndexMemorySampleJsonResult - { - Phase = phase, - ElapsedMs = stopwatch.ElapsedMilliseconds, - HeapBytes = snapshot.HeapBytes, - TotalAllocatedBytes = snapshot.TotalAllocatedBytes, - GcHeapSizeBytes = snapshot.GcHeapSizeBytes, - FragmentedBytes = snapshot.FragmentedBytes, - WorkingSetBytes = snapshot.WorkingSetBytes, - Gen0Collections = snapshot.Gen0Collections, - Gen1Collections = snapshot.Gen1Collections, - Gen2Collections = snapshot.Gen2Collections, - }; - } - - private static IndexMemoryTimelineJsonResult? BuildMemoryTimeline(List samples) - { - if (samples.Count == 0) - return null; - - return new IndexMemoryTimelineJsonResult - { - Samples = samples, - PeakWorkingSetBytes = samples.Max(static sample => sample.WorkingSetBytes), - PeakHeapBytes = samples.Max(static sample => sample.HeapBytes), - }; - } - - private static void WarnIfMemoryThresholdExceeded(IndexMemoryTimelineJsonResult? timeline) - { - var rawThreshold = CdidxEnvironment.GetProcessEnvironmentVariable("CDIDX_MEM_WARN_MB"); - if (timeline == null || !long.TryParse(rawThreshold, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var thresholdMb) || thresholdMb <= 0) - return; - - var peakMb = timeline.PeakWorkingSetBytes / (1024 * 1024); - if (peakMb >= thresholdMb) - CommandErrorWriter.WriteStderr($"Warning: cdidx working set reached {peakMb:N0} MB (CDIDX_MEM_WARN_MB={thresholdMb:N0})."); - } - - private static void StampLastIndexRunMetadata( - DbWriter writer, - string mode, - DateTime startedAtUtc, - long durationMs, - long filesScanned, - long filesSkipped, - long parseErrors, - long bytesRead, - long bytesReadSkippedFileCount, - long rowsUpserted, - long rowsDeleted, - IndexMemoryTimelineJsonResult? memoryTimeline) - => StampLastIndexRunMetadata( - writer, - mode, - startedAtUtc, - durationMs, - filesScanned, - filesSkipped, - parseErrors, - bytesRead, - bytesReadSkippedFileCount, - rowsUpserted, - rowsDeleted, - memoryTimeline, - diagnostics: null, - referenceExtractionCapHits: null); - - private static void StampLastIndexRunMetadata( - DbWriter writer, - string mode, - DateTime startedAtUtc, - long durationMs, - long filesScanned, - long filesSkipped, - long parseErrors, - long bytesRead, - long bytesReadSkippedFileCount, - long rowsUpserted, - long rowsDeleted, - IndexMemoryTimelineJsonResult? memoryTimeline, - IReadOnlyList? diagnostics, - ReferenceExtractionCapHitSummary? referenceExtractionCapHits) - { - writer.SetMetaValues( - (DbContext.LastIndexRunModeMetaKey, mode), - (DbContext.LastIndexRunStartedAtMetaKey, startedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastIndexRunDurationMsMetaKey, durationMs.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastIndexRunFilesScannedMetaKey, filesScanned.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastIndexRunFilesSkippedMetaKey, filesSkipped.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastIndexRunParseErrorsMetaKey, parseErrors.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastIndexRunBytesReadMetaKey, bytesRead.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastIndexRunBytesReadSkippedFileCountMetaKey, bytesReadSkippedFileCount.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastIndexRunBytesReadIncompleteMetaKey, (bytesReadSkippedFileCount > 0).ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastIndexRunRowsUpsertedMetaKey, rowsUpserted.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastIndexRunRowsDeletedMetaKey, rowsDeleted.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastIndexRunReferenceExtractionCapHitsMetaKey, referenceExtractionCapHits == null - ? null - : JsonSerializer.Serialize(referenceExtractionCapHits, StatusMetadataJsonContext.Default.ReferenceExtractionCapHitSummary)), - (DbContext.LastIndexRunPeakMemoryMbMetaKey, memoryTimeline == null - ? null - : (memoryTimeline.PeakWorkingSetBytes / (1024 * 1024)).ToString(System.Globalization.CultureInfo.InvariantCulture))); - StampLastIndexRunDiagnostics(writer, diagnostics); - writer.MarkIndexComplete(); - writer.ClearLastFailedIndexRunMetadata(); - } - - internal static void StampLastIndexRunDiagnostics(DbWriter writer, IReadOnlyList? diagnostics) - { - var total = diagnostics?.Count ?? 0; - if (total == 0) - { - writer.SetMetaValues( - (DbContext.LastIndexRunDiagnosticsMetaKey, null), - (DbContext.LastIndexRunDiagnosticCountMetaKey, null), - (DbContext.LastIndexRunDiagnosticsTruncatedMetaKey, null)); - return; - } - var sample = JsonStringListCodec.TakeSerializableSample( - diagnostics!, - DbContext.LastIndexRunDiagnosticSampleLimit); - writer.SetMetaValues( - (DbContext.LastIndexRunDiagnosticsMetaKey, JsonStringListCodec.Serialize(sample)), - (DbContext.LastIndexRunDiagnosticCountMetaKey, total.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastIndexRunDiagnosticsTruncatedMetaKey, (total > sample.Count).ToString(System.Globalization.CultureInfo.InvariantCulture))); - } - - internal static Action>? PlannerStatisticsMaintenanceDiagnosticStampingForTesting - { - get => ScopedPlannerStatisticsMaintenanceDiagnosticStampingForTesting.Value; - set => ScopedPlannerStatisticsMaintenanceDiagnosticStampingForTesting.Value = value; - } - - internal static bool TryStampPlannerStatisticsMaintenanceDiagnostic( - DbWriter writer, - List indexRunDiagnostics, - DbContext.PlannerStatisticsMaintenanceFailure plannerMaintenanceFailure) - { - indexRunDiagnostics.Add(FormatPlannerStatisticsMaintenanceDiagnostic(plannerMaintenanceFailure)); - try - { - PlannerStatisticsMaintenanceDiagnosticStampingForTesting?.Invoke(writer, indexRunDiagnostics); - StampLastIndexRunDiagnostics(writer, indexRunDiagnostics); - return true; - } - catch (Exception ex) - { - GlobalToolLog.Error("planner_statistics_maintenance_diagnostic_persist_failed", ex, includeStacks: false); - return false; - } - } - - internal static string FormatIndexRunDiagnostic(string code, Exception ex) - { - var raw = $"{code}: {ex.GetType().Name}: {DiagnosticRedactor.FormatExceptionMessage(ex, MaxIndexRunDiagnosticLength)}"; - return raw.Length <= MaxIndexRunDiagnosticLength - ? raw - : raw[..MaxIndexRunDiagnosticLength] + "..."; - } - - internal static string FormatIndexRunDiagnostic(string code, string? target, Exception ex) - { - if (string.IsNullOrWhiteSpace(target)) - return FormatIndexRunDiagnostic(code, ex); - - var raw = $"{code}: {CollapseLineBreaks(target)}: {ex.GetType().Name}: {DiagnosticRedactor.FormatExceptionMessage(ex, MaxIndexRunDiagnosticLength)}"; - return raw.Length <= MaxIndexRunDiagnosticLength - ? raw - : raw[..MaxIndexRunDiagnosticLength] + "..."; - } - - internal static string FormatPlannerStatisticsMaintenanceDiagnostic(DbContext.PlannerStatisticsMaintenanceFailure failure) - => FormatIndexRunDiagnostic( - "planner_statistics_maintenance_failed", - failure.CommandText, - failure.Exception); - - private static void RecordIndexRunDiagnostic(List? diagnostics, string code, Exception ex) - { - if (diagnostics == null) - return; - - diagnostics.Add(FormatIndexRunDiagnostic(code, ex)); - } - - private static void RecordIndexRunDiagnostic(List? diagnostics, string code, string? target, Exception ex) - { - if (diagnostics == null) - return; - - diagnostics.Add(FormatIndexRunDiagnostic(code, target, ex)); - } - - private static void TryStampLastFailedIndexRun( - string dbPath, - string status, - string mode, - DateTime startedAtUtc, - long durationMs, - long? filesProcessed, - long? filesTotal, - string errorCode, - string reason, - bool? progressPersisted = null, - string? recoveryHint = null) - { - if (string.IsNullOrWhiteSpace(dbPath) - || dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) - || !File.Exists(dbPath)) - { - return; - } - - try - { - using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); - db.InitializeSchema(); - var writer = new DbWriter(db); - writer.SetMetaValues( - (DbContext.LastFailedIndexRunStatusMetaKey, status), - (DbContext.LastFailedIndexRunModeMetaKey, mode), - (DbContext.LastFailedIndexRunStartedAtMetaKey, startedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunDurationMsMetaKey, durationMs.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunFilesProcessedMetaKey, filesProcessed?.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunFilesTotalMetaKey, filesTotal?.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunErrorCodeMetaKey, errorCode), - (DbContext.LastFailedIndexRunReasonMetaKey, reason), - (DbContext.LastFailedIndexRunProgressPersistedMetaKey, progressPersisted?.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunRecoveryHintMetaKey, recoveryHint), - (DbContext.LastFailedIndexRunFileErrorsMetaKey, null)); - if (progressPersisted == true) - writer.MarkIndexIncomplete(["interrupted_index_run"]); - } - catch (Exception ex) when (ex is CodeIndexException or IOException or UnauthorizedAccessException or NotSupportedException or SqliteException) - { - } - } - - internal static FileByteReadSummary MeasureReadableFileBytes( - IEnumerable paths, - string? projectRoot = null, - List? diagnostics = null, - IReadOnlyDictionary? knownFileSizes = null) - => MeasureReadableFileBytes(paths, static path => path, projectRoot, diagnostics, knownFileSizes); - - internal static FileByteReadSummary MeasureReadableFileBytes( - IEnumerable paths, - Func pathSelector, - string? projectRoot = null, - List? diagnostics = null, - IReadOnlyDictionary? knownFileSizes = null) - { - long total = 0; - long skipped = 0; - foreach (var sourcePath in paths) - { - var path = pathSelector(sourcePath); - if (knownFileSizes != null && knownFileSizes.TryGetValue(path, out var knownSize)) - { - total += knownSize; - continue; - } - - try - { - var info = new FileInfo(path); - if (info.Exists) - total += info.Length; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) - { - skipped++; - RecordIndexRunDiagnostic(diagnostics, "file_size_bytes_skipped", FormatDiagnosticPath(projectRoot, path), ex); - } - } - - return new FileByteReadSummary(total, skipped); - } - - private static string FormatDiagnosticPath(string? projectRoot, string path) - { - if (string.IsNullOrWhiteSpace(projectRoot)) - return path; - - try - { - var relative = FileIndexer.NormalizePathSeparators(FileIndexer.GetRelativePathFromDirectory(projectRoot, path)); - return IsOutsideProjectRoot(relative) ? path : relative; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) - { - return path; - } - } - - private static Dictionary GetHotspotFamilyMarkerFingerprints( - FileIndexer indexer, - CancellationToken cancellationToken = default) => - indexer.GetProjectMarkerFingerprintResults(cancellationToken); - - private static int AddProjectMarkerFingerprintWarnings( - IReadOnlyDictionary currentFingerprints, - List warningList, - IndexCommandOptions options) - { - var added = 0; - var seen = new HashSet(StringComparer.Ordinal); - foreach (var fingerprint in currentFingerprints.Values) - { - foreach (var warning in fingerprint.Warnings) - { - if (!IsProjectMarkerFingerprintWarning(warning)) - continue; - - var path = string.IsNullOrWhiteSpace(warning.Path) - ? "" - : warning.Path; - var key = $"{path}\0{warning.Message}"; - if (!seen.Add(key)) - continue; - - warningList.Add(new CliJsonMessage(path, warning.Message)); - added++; - if (!options.Json && !options.Quiet) - ConsoleUi.PrintWarning($"{path}: {warning.Message}"); - } - } - - return added; - } - - private static bool IsProjectMarkerFingerprintWarning(FileIndexer.ScanError warning) => - warning.Message.StartsWith("Project marker discovery skipped", StringComparison.Ordinal) - || warning.Message.StartsWith("Project marker discovery truncated", StringComparison.Ordinal) - || warning.Message.StartsWith("Skipped .gitmodules", StringComparison.Ordinal); - - private static void RestampHotspotFamilyTrustForUpdate( - DbWriter writer, - IReadOnlyDictionary priorVersions, - IReadOnlyDictionary priorFingerprints, - IReadOnlyDictionary currentFingerprints) - { - var currentVersion = DbContext.HotspotFamilyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); - foreach (var lang in FileIndexer.GetHotspotFamilyMarkerLanguages()) - { - if (!currentFingerprints.TryGetValue(lang, out var currentFingerprint)) - continue; - - if (!currentFingerprint.IsComplete) - { - writer.MarkHotspotFamilyMarkerFingerprintIncomplete(lang, currentFingerprint.Fingerprint); - continue; - } - - if (priorVersions.TryGetValue(lang, out var priorVersion) - && priorFingerprints.TryGetValue(lang, out var priorFingerprint) - && priorVersion == currentVersion - && priorFingerprint == currentFingerprint.Fingerprint) - { - writer.MarkHotspotFamilyReady(lang, currentFingerprint.Fingerprint); - } - } - } - - private static void RestampHotspotFamilyTrustForFullScan( - DbWriter writer, - IReadOnlySet? reusedLanguages, - IReadOnlyDictionary priorVersions, - IReadOnlyDictionary priorFingerprints, - IReadOnlyDictionary currentFingerprints) - { - var currentVersion = DbContext.HotspotFamilyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); - foreach (var lang in FileIndexer.GetHotspotFamilyMarkerLanguages()) - { - if (!currentFingerprints.TryGetValue(lang, out var currentFingerprint)) - continue; - - if (!currentFingerprint.IsComplete) - { - writer.MarkHotspotFamilyMarkerFingerprintIncomplete(lang, currentFingerprint.Fingerprint); - continue; - } - - priorVersions.TryGetValue(lang, out var priorVersion); - priorFingerprints.TryGetValue(lang, out var priorFingerprint); - if (reusedLanguages?.Contains(lang) != true || (priorVersion == currentVersion && priorFingerprint == currentFingerprint.Fingerprint)) - writer.MarkHotspotFamilyReady(lang, currentFingerprint.Fingerprint); - } - } - - private static Dictionary GetHotspotFamilyTrustMatchesCurrent( - IReadOnlyDictionary priorVersions, - IReadOnlyDictionary priorFingerprints, - IReadOnlyDictionary currentFingerprints) - { - var currentVersion = DbContext.HotspotFamilyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture); - var values = new Dictionary(StringComparer.Ordinal); - foreach (var lang in FileIndexer.GetHotspotFamilyMarkerLanguages()) - { - currentFingerprints.TryGetValue(lang, out var currentFingerprint); - priorVersions.TryGetValue(lang, out var priorVersion); - priorFingerprints.TryGetValue(lang, out var priorFingerprint); - values[lang] = currentFingerprint.IsComplete - && priorVersion == currentVersion - && priorFingerprint == currentFingerprint.Fingerprint; - } - - return values; - } - - private static bool AllowReuseWithCurrentHotspotFamilyTrust( - string? lang, - IReadOnlyDictionary hotspotFamilyTrustMatchesCurrent) - { - if (!FileIndexer.SupportsHotspotFamilyMarkerLanguage(lang)) - return true; - - return lang != null - && hotspotFamilyTrustMatchesCurrent.TryGetValue(lang, out var matchesCurrent) - && matchesCurrent; - } - - internal static bool IsOutsideProjectRoot(string relativePath) - { - if (Path.IsPathRooted(relativePath)) - return true; - - var normalized = OperatingSystem.IsWindows() - ? relativePath.Replace('\\', '/') - : relativePath; - return normalized == ".." || normalized.StartsWith("../", StringComparison.Ordinal); - } - - private static bool ContainsIgnoreFilePath(IEnumerable paths) - => paths.Any(FileIndexer.IsIgnoreFilePath); - - private static bool ContainsExtractorConfigurationPath(string projectRoot, IEnumerable paths) - => paths.Any(path => - FileIndexer.ClassifyIndexInputInvalidation(projectRoot, path) - == FileIndexer.IndexInputInvalidationKind.ExtractorConfiguration); - - private static bool ContainsJavaScriptTypeScriptConfigPath(IEnumerable paths) - => paths.Any(IsJavaScriptTypeScriptConfigPath); - - private static bool IsJavaScriptTypeScriptLanguage(string? language) - => string.Equals(language, "javascript", StringComparison.Ordinal) - || string.Equals(language, "typescript", StringComparison.Ordinal); - - private static bool IsJavaScriptTypeScriptConfigPath(string path) - { - var fileName = Path.GetFileName(path.AsSpan()); - return fileName.Equals("jsconfig.json".AsSpan(), StringComparison.OrdinalIgnoreCase) - || fileName.Equals("tsconfig.json".AsSpan(), StringComparison.OrdinalIgnoreCase) - || (fileName.StartsWith("jsconfig.".AsSpan(), StringComparison.OrdinalIgnoreCase) - && fileName.EndsWith(".json".AsSpan(), StringComparison.OrdinalIgnoreCase)) - || (fileName.StartsWith("tsconfig.".AsSpan(), StringComparison.OrdinalIgnoreCase) - && fileName.EndsWith(".json".AsSpan(), StringComparison.OrdinalIgnoreCase)); - } - - private static bool ContainsRelevantIgnoreFileUpdate(string projectRoot, IEnumerable updateFiles) - { - foreach (var file in updateFiles) - { - var absolutePath = Path.IsPathRooted(file) - ? Path.GetFullPath(file) - : Path.GetFullPath(Path.Combine(projectRoot, file)); - if (FileIndexer.IsIgnoreFilePath(absolutePath) && IsRelevantIgnoreFileForProjectRoot(projectRoot, absolutePath)) - return true; - } - - return false; - } - - private static IReadOnlyList NormalizeCommitFileTargets( - string projectRoot, - string repoRoot, - IEnumerable changedFiles, - out bool relevantIgnoreFileChanged) - { - relevantIgnoreFileChanged = false; - var normalized = new List(); - foreach (var changedFile in changedFiles) - { - var absolutePath = Path.GetFullPath(Path.Combine(repoRoot, changedFile.Replace('/', Path.DirectorySeparatorChar))); - if (FileIndexer.IsIgnoreFilePath(absolutePath) && IsRelevantIgnoreFileForProjectRoot(projectRoot, absolutePath)) - relevantIgnoreFileChanged = true; - - var relativePath = FileIndexer.NormalizePathSeparators( - FileIndexer.GetRelativePathFromProjectRoot(projectRoot, absolutePath)); - if (IsOutsideProjectRoot(relativePath)) - continue; - - normalized.Add(relativePath); - } - - return normalized; - } - - private static bool IsRelevantIgnoreFileForProjectRoot(string projectRoot, string ignoreFileAbsolutePath) - { - var ignoreDirectory = Path.GetDirectoryName(ignoreFileAbsolutePath); - if (string.IsNullOrEmpty(ignoreDirectory)) - return false; - - return IsPathEqualOrParent(ignoreDirectory, projectRoot) - || IsPathEqualOrParent(projectRoot, ignoreDirectory); - } - - private static string DescribePathFilter(FileIndexer.PathFilterKind filterKind) - => filterKind switch - { - FileIndexer.PathFilterKind.IgnoredByRules => "ignored by .gitignore/.cdidxignore", - FileIndexer.PathFilterKind.ExcludedByDefaultDirectory => "excluded by default directory rules", - FileIndexer.PathFilterKind.ExcludedByDefaultFile => "excluded by default file rules", - FileIndexer.PathFilterKind.OutsideProjectRoot => "outside the project root", - FileIndexer.PathFilterKind.IgnoreRulesUnavailable => "ignore rules unavailable", - _ => "filtered", - }; - - private static IReadOnlyList NormalizeUpdateFileTargets(string projectRoot, IEnumerable updateFiles, bool json) - { - var normalized = new List(); - foreach (var file in updateFiles) - { - var absPath = Path.IsPathRooted(file) ? file : Path.GetFullPath(Path.Combine(projectRoot, file)); - var relPath = FileIndexer.NormalizePathSeparators( - FileIndexer.GetRelativePathFromProjectRoot(projectRoot, absPath)); - if (IsOutsideProjectRoot(relPath)) - { - if (!json) - CommandErrorWriter.WriteStderr($" [WARN] Skipping file outside project root: {file}. Use a path under the indexed project root or run `cdidx index` from the correct workspace."); - continue; - } - - normalized.Add(relPath); - } - - return normalized; - } - - private static bool IsPathEqualOrParent(string candidateParent, string candidateChild) - { - var normalizedParent = Path.GetFullPath(candidateParent) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var normalizedChild = Path.GetFullPath(candidateChild) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return PathCasing.IsPathEqualOrParent(normalizedParent, normalizedChild); - } // Issue #1509: stamp the Git HEAD commit, branch, and UTC timestamp into // codeindex_meta so cross-session staleness ("the DB was indexed at commit X but @@ -1092,512 +509,9 @@ private static bool IsPathEqualOrParent(string candidateParent, string candidate // the index data itself is valid; the metadata stamp is best-effort. Issue #1509. // #1509: 成功 index 末尾で HEAD / branch / timestamp を codeindex_meta に保存する。 // git 不在時は NULL stamp、stamp 自体の例外は warn せず無視(index 本体は成功)。 - private static void StampIndexedHeadMetadata(DbWriter writer, string projectRoot, List? diagnostics, CancellationToken cancellationToken) - { - try - { - var headSha = GitHelper.TryGetHeadCommit(projectRoot, cancellationToken); - var headBranch = GitHelper.TryGetHeadBranch(projectRoot, cancellationToken); - StampIndexedHeadMetadata(writer, headSha, headBranch); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - // Best-effort metadata only; never fail an otherwise-successful index run. - // best-effort であり、stamp の失敗で index 全体を失敗扱いにしない。 - RecordIndexRunDiagnostic(diagnostics, "indexed_head_metadata_write_failed", ex); - } - StampWorkspacePathCaseSensitivity(writer, projectRoot, diagnostics, cancellationToken); - } - - private static void StampIndexedSymlinkPolicy(DbWriter writer, FileIndexer.SymlinkPolicy symlinkPolicy, List? diagnostics) - { - try - { - writer.SetMeta( - DbContext.IndexedFollowSymlinksPolicyMetaKey, - symlinkPolicy.ToString().ToLowerInvariant()); - } - catch (Exception ex) - { - // Best-effort metadata only; never fail an otherwise-successful index run. - // best-effort のみ。stamp 失敗で index 全体を落とさない。 - RecordIndexRunDiagnostic(diagnostics, "indexed_symlink_policy_metadata_write_failed", ex); - } - } - - private static void StampIndexedHeadMetadata(DbWriter writer, string? headSha, string? headBranch) - { - var timestamp = headSha != null - ? GetUtcNow().ToString("o", System.Globalization.CultureInfo.InvariantCulture) - : null; - writer.SetMetaValues( - (DbContext.IndexedHeadShaMetaKey, headSha), - (DbContext.IndexedHeadBranchMetaKey, headBranch), - (DbContext.IndexedHeadTimestampMetaKey, timestamp)); - } - - private static void TryStampIndexedHeadMetadata(DbWriter writer, string? headSha, string? headBranch, List? diagnostics) - { - try - { - StampIndexedHeadMetadata(writer, headSha, headBranch); - } - catch (Exception ex) - { - // Best-effort metadata only; never fail an otherwise-successful index run. - // best-effort であり、stamp の失敗で index 全体を失敗扱いにしない。 - RecordIndexRunDiagnostic(diagnostics, "indexed_head_metadata_write_failed", ex); - } - } - - private static void StampCommitScopedFreshHeadMetadata( - DbWriter writer, - IndexCommandOptions options, - string projectRoot, - string? currentHeadCommit, - List? diagnostics, - CancellationToken cancellationToken = default) - { - try - { - var coveredHead = !string.IsNullOrWhiteSpace(currentHeadCommit) - && (options.Commits.Any(commit => GitRefCoversCurrentHead(projectRoot, commit, currentHeadCommit, cancellationToken)) - || TryChangedBetweenCoversCurrentHead(options, projectRoot, currentHeadCommit, cancellationToken)) - ? currentHeadCommit - : null; - writer.SetMeta(DbContext.CommitScopedFreshHeadShaMetaKey, coveredHead); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - // Best-effort metadata only; never fail an otherwise-successful index run. - // best-effort のみ。stamp 失敗で index 全体を落とさない。 - RecordIndexRunDiagnostic(diagnostics, "commit_scoped_head_metadata_write_failed", ex); - } - } - - private static bool GitRefCoversCurrentHead( - string projectRoot, - string refName, - string currentHeadCommit, - CancellationToken cancellationToken) - { - if (currentHeadCommit.StartsWith(refName, StringComparison.OrdinalIgnoreCase)) - return true; - - var resolvedRef = GitHelper.TryResolveCommit(projectRoot, refName, cancellationToken); - return string.Equals(resolvedRef, currentHeadCommit, StringComparison.OrdinalIgnoreCase); - } - - private static bool TryChangedBetweenCoversCurrentHead( - IndexCommandOptions options, - string projectRoot, - string currentHeadCommit, - CancellationToken cancellationToken) - { - if (options.ChangedBetweenRefs.Count != 2) - return false; - - return GitRefCoversCurrentHead(projectRoot, options.ChangedBetweenRefs[1], currentHeadCommit, cancellationToken); - } - - // Issue #1546: capture the actual case-sensitivity of the workspace filesystem so - // `cdidx status` can diagnose phantom path collapses on case-sensitive APFS / WSL - // NTFS / ReFS volumes (where the OS-keyed heuristic would mismatch reality). Probed - // via the same `core.ignorecase` + filesystem probe used by FileIndexer, then - // persisted as "true" / "false" alongside the HEAD stamp. Failures are swallowed so - // an unwritable git config / temp probe never blocks an otherwise-successful index. - // #1546: workspace FS の大小区別を実プローブして codeindex_meta に保存する。 - // probe 失敗時は黙って null stamp にして index 本体は成功扱いのままとする。 - private static void StampWorkspacePathCaseSensitivity(DbWriter writer, string projectRoot, List? diagnostics, CancellationToken cancellationToken) - { - try - { - var ignoreCase = GitHelper.ResolveIgnoreCase(projectRoot, cancellationToken); - PathCasing.SeedFromWorkspace(projectRoot, ignoreCase); - var caseSensitive = (!ignoreCase).ToString(System.Globalization.CultureInfo.InvariantCulture); - writer.SetMeta(DbContext.WorkspacePathCaseSensitiveMetaKey, caseSensitive); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - // Best-effort metadata only; never fail an otherwise-successful index run. - // best-effort のみ。stamp 失敗で index 全体を落とさない。 - RecordIndexRunDiagnostic(diagnostics, "path_case_sensitivity_metadata_write_failed", ex); - } - } - - private static void AddToGitExclude( - string projectPath, - string dbPath, - List? diagnostics, - CancellationToken cancellationToken) - { - try - { - var projectRoot = Path.GetFullPath(projectPath); - var gitDir = GitHelper.ResolveGitCommonDir(projectRoot, cancellationToken); - if (gitDir == null) return; - - if (!GitHelper.TryResolveGitMetadataChildPath( - gitDir, - "info", - expectDirectory: true, - allowMissing: true, - out var infoDirectory)) - { - throw new IOException("Unsafe Git metadata info directory."); - } - - Directory.CreateDirectory(LongPath.EnsureWindowsPrefix(infoDirectory)); - if (!GitHelper.TryResolveGitMetadataChildPath( - gitDir, - "info", - expectDirectory: true, - allowMissing: false, - out infoDirectory) - || !GitHelper.TryResolveGitMetadataChildPath( - infoDirectory, - "exclude", - expectDirectory: false, - allowMissing: true, - out var excludeFile)) - { - throw new IOException("Unsafe Git metadata exclude path."); - } - - var dbAbsolutePath = Path.IsPathRooted(dbPath) - ? Path.GetFullPath(dbPath) - : Path.GetFullPath(Path.Combine(projectRoot, dbPath)); - var dbDirAbsolute = Path.GetDirectoryName(dbAbsolutePath); - if (string.IsNullOrEmpty(dbDirAbsolute)) return; - - var dbDirRelative = FileIndexer.NormalizePathSeparators(FileIndexer.GetRelativePathFromDirectory(projectRoot, dbDirAbsolute)); - if (IsOutsideProjectRoot(dbDirRelative)) return; - - string[] patterns; - if (dbDirRelative == ".") - { - var dbFileName = Path.GetFileName(dbAbsolutePath); - patterns = [dbFileName, $"{dbFileName}-*"]; - } - else - { - patterns = [$"{dbDirRelative.TrimEnd('/')}/"]; - } - - var ioExcludeFile = LongPath.EnsureWindowsPrefix(excludeFile); - var existingContent = File.Exists(ioExcludeFile) - ? DataDirectorySecurity.ReadTextWithinLimit(ioExcludeFile, MaxGitExcludeBytes, FileShare.ReadWrite) - : ""; - if (existingContent is null) - return; - - var existingLines = existingContent.Split('\n').Select(l => l.TrimEnd('\r')).ToHashSet(); - - var missing = patterns.Where(p => !existingLines.Contains(p)).ToList(); - if (missing.Count == 0) return; - - if (!GitHelper.TryResolveGitMetadataChildPath( - gitDir, - "info", - expectDirectory: true, - allowMissing: false, - out infoDirectory) - || !GitHelper.TryResolveGitMetadataChildPath( - infoDirectory, - "exclude", - expectDirectory: false, - allowMissing: true, - out excludeFile)) - { - throw new IOException("Git metadata exclude path became unsafe before write."); - } - - var updatedContent = new System.Text.StringBuilder(existingContent); - if (existingContent.Length > 0 && !existingContent.EndsWith('\n')) - updatedContent.AppendLine(); - updatedContent.AppendLine("# cdidx (CodeIndex) — auto-generated"); - foreach (var pattern in missing) - updatedContent.AppendLine(pattern); - - AtomicFileWriter.WriteText( - excludeFile, - updatedContent.ToString(), - new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - RecordIndexRunDiagnostic(diagnostics, "git_exclude_metadata_write_failed", ex); - } - } - - private static string? GetStatReusableLanguage( - string absolutePath, - FileIndexer.LanguageDetectionResult detection) - { - if (string.Equals(Path.GetExtension(absolutePath), ".h", StringComparison.OrdinalIgnoreCase)) - return null; - - return detection.Status == FileIndexer.FileProbeStatus.Supported - ? detection.Language - : null; - } - - private static long? TryGetUnchangedFileIdFromChecksum( - DbWriter writer, - string absolutePath, - string relativePath, - string? language, - long? maxBytes) - { - if (language == null) - return null; - - try - { - var info = new FileInfo(absolutePath); - if (!info.Exists) - return null; - if (!FileIndexer.TryComputeChecksum(absolutePath, maxBytes ?? FileIndexer.DefaultMaxFileSizeBytes, out var checksum)) - return null; - - return writer.GetUnchangedFileId( - relativePath, - info.LastWriteTimeUtc, - checksum, - size: info.Length, - language: language); - } - catch (IOException) - { - return null; - } - catch (UnauthorizedAccessException) - { - return null; - } - } - - private readonly record struct FullScanFileTarget( - string FilePath, - string RelativePath, - string DisplayRelativePath, - string IndexPath, - string? Language, - bool GeneratedExtractionSuppressed) - { - public static FullScanFileTarget CreateFromPath(string projectRoot, string path) - { - var filePath = Path.IsPathRooted(path) - ? path - : Path.Combine(projectRoot, FileIndexer.NormalizeRelativePathForCurrentPlatform(path)); - return Create(projectRoot, filePath); - } - - public static FullScanFileTarget Create(string projectRoot, string filePath, string? language = null) - { - var relativePath = FileIndexer.GetRelativePathFromProjectRoot(projectRoot, filePath); - return new FullScanFileTarget( - filePath, - relativePath, - FileIndexer.NormalizePathSeparators(relativePath), - FileIndexer.NormalizeIndexPath(relativePath), - language, - GeneratedExtractionSuppressed: false); - } - } - - private readonly record struct UpdateFileTarget( - string FilePath, - string RelativePath, - string DisplayRelativePath, - string IndexPath) - { - public static UpdateFileTarget Create(string projectRoot, string path) - { - var isRooted = Path.IsPathRooted(path); - var filePath = isRooted - ? path - : Path.Combine(projectRoot, path.Replace('/', Path.DirectorySeparatorChar)); - var relativePath = isRooted - ? FileIndexer.GetRelativePathFromProjectRoot(projectRoot, path) - : path; - return new UpdateFileTarget( - filePath, - relativePath, - FileIndexer.NormalizePathSeparators(relativePath), - FileIndexer.NormalizeIndexPath(relativePath)); - } - } - - private sealed record FullScanFileWorkItem( - int FileIndex, - string FilePath, - string RelativePath, - FileRecord? Record, - string? Content, - bool? HasOversizeLine, - int? ConflictMarkerLine, - string? Warning, - IReadOnlyList? Chunks, - IReadOnlyList? Symbols, - IReadOnlyList? References, - IReadOnlyList? Issues, - FileIssue? GeneratedSuppressionIssue, - bool GeneratedSuppressionChecked, - string? FailurePhase, - Exception? Exception) - { - public static FullScanFileWorkItem Success( - int fileIndex, - string filePath, - string relativePath, - FileRecord record, - string? content, - bool hasOversizeLine, - int conflictMarkerLine, - string? warning, - IReadOnlyList? chunks, - IReadOnlyList? symbols, - IReadOnlyList? references, - IReadOnlyList? issues, - FileIssue? generatedSuppressionIssue, - bool generatedSuppressionChecked) - { - return new FullScanFileWorkItem( - fileIndex, - filePath, - relativePath, - record, - content, - hasOversizeLine, - conflictMarkerLine, - warning, - chunks, - symbols, - references, - issues, - generatedSuppressionIssue, - generatedSuppressionChecked, - null, - null); - } - public static FullScanFileWorkItem Precomputed( - int fileIndex, - string filePath, - string relativePath, - FileRecord record, - string? warning, - IReadOnlyList chunks, - IReadOnlyList symbols, - IReadOnlyList references, - IReadOnlyList issues, - FileIssue? generatedSuppressionIssue = null, - bool generatedSuppressionChecked = false) - { - return new FullScanFileWorkItem( - fileIndex, - filePath, - relativePath, - record, - null, - null, - null, - warning, - chunks, - symbols, - references, - issues, - generatedSuppressionIssue, - generatedSuppressionChecked, - null, - null); - } - - public static FullScanFileWorkItem Failure(int fileIndex, string filePath, string relativePath, string phase, Exception exception) - => new(fileIndex, filePath, relativePath, null, null, null, null, null, null, null, null, null, null, false, phase, exception); - public static FullScanFileWorkItem Skipped(int fileIndex, string filePath, string relativePath, string warning) - => new(fileIndex, filePath, relativePath, null, null, null, null, warning, null, null, null, null, null, false, null, null); - } - private sealed class CSharpWorkspaceSnapshotDriftException(string path) - : IOException("A C# source changed after workspace preflight; rerun indexing to refresh the complete C# graph.") - { - public string Path { get; } = path; - } - - private sealed record FoldOnlyRemediation( - string DegradedReason, - string RecommendedAction, - string AlternativeAction); - - private sealed class IndexInterruptedException : OperationCanceledException - { - public IndexInterruptedException(int filesProcessed, int? filesTotal, string? actualMode = null) - : base("Indexing was interrupted.") - { - FilesProcessed = filesProcessed; - FilesTotal = filesTotal; - ActualMode = actualMode; - } - - public int FilesProcessed { get; } - public int? FilesTotal { get; } - public string? ActualMode { get; } - } - - private sealed class IndexExtractionStalledException : Exception - { - public IndexExtractionStalledException(int filesProcessed, int? filesTotal, TimeSpan timeout, string? activePath, string? workerError = null) - : base("Index extraction stalled.") - { - FilesProcessed = filesProcessed; - FilesTotal = filesTotal; - Timeout = timeout; - ActivePath = activePath; - WorkerError = workerError; - } - - public int FilesProcessed { get; } - public int? FilesTotal { get; } - public TimeSpan Timeout { get; } - public string? ActivePath { get; } - public string? WorkerError { get; } - } - - private sealed class CancelKeyPressRegistration(ConsoleCancelEventHandler handler) : IDisposable - { - public void Dispose() - { - Console.CancelKeyPress -= handler; - } - } - - private sealed class NullDisposable : IDisposable - { - public static readonly NullDisposable Instance = new(); - - public void Dispose() - { - } - } } public sealed class IndexCommandOptions From 495fee2ec55764f569d21187e1d22e822039cb69 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 19:35:55 +0900 Subject: [PATCH 061/101] Separate index run execution phases --- src/CodeIndex/Cli/IndexCommandRunner.cs | 92 ++++++++++++++++++++----- 1 file changed, 74 insertions(+), 18 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index fef743ba1..582d8f5d1 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -234,6 +234,75 @@ internal static int Run( return WriteInterruptedResult(options.Json, jsonOptions, filesProcessed: 0, filesTotal: null, mode, progressPersisted); } + var initialExitCode = RunInitialIndex(new IndexRunExecutionContext( + options, + jsonOptions, + jsonContext, + indexCancellation, + initialCwd, + dbPath, + resolvedDbPath, + stopwatch, + runStartedAtUtc, + isUpdateMode, + mode, + spinnerFrames, + databaseExistedBeforeIndex, + ignoreCase, + ignoreRuleRoot)); + + + if (!options.Watch || initialExitCode != CommandExitCodes.Success) + return initialExitCode; + + // Release the index lock before entering the watch loop so concurrent + // `cdidx index` invocations between batches can still acquire it. Each + // partial-update batch re-acquires the lock through IndexCommandRunner.Run. + // watch ループ突入前にロックを解放し、バッチ間に別プロセスの `cdidx index` が + // 取得できる状態にする。各バッチ更新はサブ実行で再取得する。 + return IndexWatchRunner.Run( + options, + jsonOptions, + Path.GetFullPath(options.ProjectPath!), + Path.GetFullPath(dbPath), + indexCancellation.Token); + } + + private sealed record IndexRunExecutionContext( + IndexCommandOptions Options, + JsonSerializerOptions JsonOptions, + CliJsonSerializerContext JsonContext, + CancellationTokenSource IndexCancellation, + string? InitialCwd, + string DbPath, + string ResolvedDbPath, + Stopwatch Stopwatch, + DateTime RunStartedAtUtc, + bool IsUpdateMode, + string Mode, + string[] SpinnerFrames, + bool DatabaseExistedBeforeIndex, + bool IgnoreCase, + string IgnoreRuleRoot); + + private static int RunInitialIndex(IndexRunExecutionContext context) + { + var options = context.Options; + var jsonOptions = context.JsonOptions; + var jsonContext = context.JsonContext; + var indexCancellation = context.IndexCancellation; + var initialCwd = context.InitialCwd; + var dbPath = context.DbPath; + var resolvedDbPath = context.ResolvedDbPath; + var stopwatch = context.Stopwatch; + var runStartedAtUtc = context.RunStartedAtUtc; + var isUpdateMode = context.IsUpdateMode; + var mode = context.Mode; + var spinnerFrames = context.SpinnerFrames; + var databaseExistedBeforeIndex = context.DatabaseExistedBeforeIndex; + var ignoreCase = context.IgnoreCase; + var ignoreRuleRoot = context.IgnoreRuleRoot; + // --dry-run: scan files but do not write to database / --dry-run: ファイルスキャンのみでDBに書き込まない if (options.DryRun) return RunDryRun( @@ -262,7 +331,7 @@ internal static int Run( { try { - indexLock = IndexLock.Acquire(lockPath, options.ProjectPath); + indexLock = IndexLock.Acquire(lockPath, options.ProjectPath!); } catch (IndexLockConflictException ex) { @@ -368,7 +437,7 @@ internal static int Run( // `--rebuild` が DB を消す前に取り出す。incremental 経路で HEAD 差分を検知し、`status` // (no `--check`) でも worktree の HEAD 切替検出に利用する。 var priorIndexedHeadCommit = PriorMeta(DbContext.IndexedHeadCommitMetaKey); - var currentHeadCommit = GitHelper.TryGetHeadCommit(options.ProjectPath, indexCancellation.Token); + var currentHeadCommit = GitHelper.TryGetHeadCommit(options.ProjectPath!, indexCancellation.Token); // Don't demote readiness yet. A transient usage error in update-mode preflight // (bad --commits hash, git unavailable, etc.) would permanently downgrade a healthy @@ -379,11 +448,11 @@ internal static int Run( db.InitializeSchema(); var indexRunDiagnostics = new List(); - AddToGitExclude(options.ProjectPath, dbPath, indexRunDiagnostics, indexCancellation.Token); + AddToGitExclude(options.ProjectPath!, dbPath, indexRunDiagnostics, indexCancellation.Token); var writer = new DbWriter(db); var indexer = new FileIndexer( - options.ProjectPath, + options.ProjectPath!, ignoreCase, ignoreRuleRoot, options.MaxFileSizeBytes, @@ -481,20 +550,7 @@ internal static int Run( return WriteDatabaseFilesystemError(options.Json, jsonOptions, resolvedDbPath, ex); } - if (!options.Watch || initialExitCode != CommandExitCodes.Success) - return initialExitCode; - - // Release the index lock before entering the watch loop so concurrent - // `cdidx index` invocations between batches can still acquire it. Each - // partial-update batch re-acquires the lock through IndexCommandRunner.Run. - // watch ループ突入前にロックを解放し、バッチ間に別プロセスの `cdidx index` が - // 取得できる状態にする。各バッチ更新はサブ実行で再取得する。 - return IndexWatchRunner.Run( - options, - jsonOptions, - Path.GetFullPath(options.ProjectPath!), - Path.GetFullPath(dbPath), - indexCancellation.Token); + return initialExitCode; } From 425d69c441f78c417efa33aee107f746815b653c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 19:40:13 +0900 Subject: [PATCH 062/101] Extract C# invocation parsing helpers --- ...erenceExtractor.CSharpInvocationParsing.cs | 422 ++++++++++++++++++ .../ReferenceExtractor.CoreExtraction.cs | 412 ----------------- 2 files changed, 422 insertions(+), 412 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CSharpInvocationParsing.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CSharpInvocationParsing.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CSharpInvocationParsing.cs new file mode 100644 index 000000000..107c35e6e --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CSharpInvocationParsing.cs @@ -0,0 +1,422 @@ +using System.Text; +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + static bool HasCSharpQualifiedSeparatorBeforeToken(string line, int tokenStart) + { + var probe = tokenStart - 1; + while (probe >= 0 && char.IsWhiteSpace(line[probe])) + probe--; + + if (probe < 0) + return false; + if (line[probe] == '.') + return true; + return line[probe] == ':' && probe >= 1 && line[probe - 1] == ':'; + } + + static bool TryGetCSharpTokenBoundsAtColumn(string line, int column, string symbolName, out int tokenStart, out int tokenNameStart) + { + if (!TryGetCSharpIdentifierAtColumn(line, column, out tokenStart, out tokenNameStart, out var tokenName) + || string.IsNullOrWhiteSpace(symbolName)) + { + return false; + } + + return string.Equals(tokenName, symbolName, StringComparison.Ordinal); + } + + static bool TryGetCSharpIdentifierAtColumn(string line, int column, out int tokenStart, out int tokenNameStart, out string tokenName) + { + tokenStart = column - 1; + tokenNameStart = tokenStart; + tokenName = string.Empty; + if (tokenStart < 0 || tokenStart >= line.Length) + return false; + + if (line[tokenNameStart] == '@') + tokenNameStart++; + + if (tokenNameStart >= line.Length || !IsCSharpIdentifierPart(line[tokenNameStart])) + return false; + + var tokenEnd = tokenNameStart + 1; + while (tokenEnd < line.Length && IsCSharpIdentifierPart(line[tokenEnd])) + tokenEnd++; + + tokenName = NormalizeCSharpIdentifier(line[tokenStart..tokenEnd]); + return !string.IsNullOrWhiteSpace(tokenName); + } + + static bool TryGetCSharpQualifiedPrefixAtColumn(string line, int column, string symbolName, out string prefix) + { + prefix = string.Empty; + if (!TryGetCSharpTokenBoundsAtColumn(line, column, symbolName, out var tokenStart, out _) + || !HasCSharpQualifiedSeparatorBeforeToken(line, tokenStart)) + { + return false; + } + + var cursor = tokenStart - 1; + while (cursor >= 0 && char.IsWhiteSpace(line[cursor])) + cursor--; + if (cursor >= 0 && line[cursor] == '.') + cursor--; + else if (cursor >= 1 && line[cursor] == ':' && line[cursor - 1] == ':') + cursor -= 2; + else + return false; + + string? singleSegment = null; + List? segments = null; + while (cursor >= 0) + { + while (cursor >= 0 && char.IsWhiteSpace(line[cursor])) + cursor--; + + var segmentEnd = cursor; + while (cursor >= 0 && (IsCSharpIdentifierPart(line[cursor]) || line[cursor] == '@')) + cursor--; + + var segmentStart = cursor + 1; + if (segmentStart > segmentEnd) + return false; + + var segment = NormalizeCSharpIdentifier(line[segmentStart..(segmentEnd + 1)]); + if (singleSegment is null && segments is null) + { + singleSegment = segment; + } + else + { + if (segments is null) + { + segments = [singleSegment!]; + singleSegment = null; + } + + segments.Add(segment); + } + while (cursor >= 0 && char.IsWhiteSpace(line[cursor])) + cursor--; + + if (cursor >= 0 && line[cursor] == '.') + { + cursor--; + continue; + } + + if (cursor >= 1 && line[cursor] == ':' && line[cursor - 1] == ':') + { + cursor -= 2; + continue; + } + + break; + } + + if (segments is null) + { + if (singleSegment is null) + return false; + + prefix = singleSegment; + return true; + } + + if (segments.Count == 0) + return false; + + segments.Reverse(); + prefix = string.Join('.', segments); + return true; + } + + static bool TryCollectCSharpInvocationArguments(string[] sourceLines, int lineIndex, int openParen, out string args) + { + const int MaxInvocationLines = 32; + var initialCapacity = lineIndex >= 0 && lineIndex < sourceLines.Length + ? Math.Min(512, Math.Max(0, sourceLines[lineIndex].Length - openParen)) + : 0; + var builder = new StringBuilder(initialCapacity); + var depth = 0; + var started = false; + var lineLimit = Math.Min(sourceLines.Length, lineIndex + MaxInvocationLines); + + for (var currentLineIndex = lineIndex; currentLineIndex < lineLimit; currentLineIndex++) + { + var line = sourceLines[currentLineIndex]; + for (var i = currentLineIndex == lineIndex ? openParen : 0; i < line.Length;) + { + var skippedIndex = i; + if (TrySkipCSharpStringOrCharLiteral(line.AsSpan(), ref skippedIndex)) + { + if (started && depth > 0) + builder.Append(line.AsSpan(i, skippedIndex - i)); + i = skippedIndex; + continue; + } + + skippedIndex = i; + if (TrySkipCSharpComment(line.AsSpan(), ref skippedIndex)) + { + if (started && depth > 0) + builder.Append(' '); + i = skippedIndex; + continue; + } + + var ch = line[i++]; + if (ch == '(') + { + if (started && depth > 0) + builder.Append(ch); + depth++; + started = true; + continue; + } + + if (ch == ')' && started) + { + depth--; + if (depth == 0) + { + args = builder.ToString(); + return true; + } + + builder.Append(ch); + continue; + } + + if (started && depth > 0) + builder.Append(ch); + } + + if (started && depth > 0) + builder.Append('\n'); + } + + args = string.Empty; + return false; + } + + static int CountTopLevelCSharpArguments(ReadOnlySpan args, out bool hasNamedMatchTimeout) + { + hasNamedMatchTimeout = false; + var count = 0; + var tokenStart = 0; + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + + for (var i = 0; i <= args.Length; i++) + { + var atEnd = i == args.Length; + if (!atEnd) + { + if (TrySkipCSharpStringOrCharLiteral(args, ref i) + || TrySkipCSharpComment(args, ref i)) + { + i--; + continue; + } + + var ch = args[i]; + if (ch == '(') + parenDepth++; + else if (ch == ')' && parenDepth > 0) + parenDepth--; + else if (ch == '[') + bracketDepth++; + else if (ch == ']' && bracketDepth > 0) + bracketDepth--; + else if (ch == '{') + braceDepth++; + else if (ch == '}' && braceDepth > 0) + braceDepth--; + + if (ch != ',' || parenDepth != 0 || bracketDepth != 0 || braceDepth != 0) + continue; + } + + var segment = args[tokenStart..i].Trim(); + if (!segment.IsEmpty) + { + count++; + if (CSharpArgumentHasNamedMatchTimeout(segment)) + hasNamedMatchTimeout = true; + } + + tokenStart = i + 1; + } + + return count; + } + + static bool TrySkipCSharpComment(ReadOnlySpan text, ref int index) + { + if (index + 1 >= text.Length || text[index] != '/') + return false; + + if (text[index + 1] == '/') + { + index = text.Length; + return true; + } + + if (text[index + 1] != '*') + return false; + + index += 2; + while (index + 1 < text.Length) + { + if (text[index] == '*' && text[index + 1] == '/') + { + index += 2; + return true; + } + + index++; + } + + index = text.Length; + return true; + } + + static bool TrySkipCSharpStringOrCharLiteral(ReadOnlySpan text, ref int index) + { + var cursor = index; + var verbatim = false; + + if (cursor < text.Length && text[cursor] == '@') + { + verbatim = true; + cursor++; + while (cursor < text.Length && text[cursor] == '$') + cursor++; + } + else + { + while (cursor < text.Length && text[cursor] == '$') + cursor++; + if (cursor < text.Length && text[cursor] == '@') + { + verbatim = true; + cursor++; + } + } + + if (cursor >= text.Length || (text[cursor] != '"' && text[cursor] != '\'')) + return false; + + var quote = text[cursor]; + if (quote == '\'') + { + index = cursor + 1; + while (index < text.Length) + { + if (text[index] == '\\') + { + index += 2; + continue; + } + + if (text[index++] == '\'') + return true; + } + + return true; + } + + var quoteCount = 0; + while (cursor + quoteCount < text.Length && text[cursor + quoteCount] == '"') + quoteCount++; + + if (!verbatim && quoteCount >= 3) + { + index = cursor + quoteCount; + while (index + quoteCount <= text.Length) + { + var matched = true; + for (var offset = 0; offset < quoteCount; offset++) + { + if (text[index + offset] != '"') + { + matched = false; + break; + } + } + + if (matched) + { + index += quoteCount; + return true; + } + + index++; + } + + index = text.Length; + return true; + } + + index = cursor + 1; + while (index < text.Length) + { + if (!verbatim && text[index] == '\\') + { + index += 2; + continue; + } + + if (text[index] == '"') + { + if (verbatim && index + 1 < text.Length && text[index + 1] == '"') + { + index += 2; + continue; + } + + index++; + return true; + } + + index++; + } + + return true; + } + + static bool CSharpArgumentHasNamedMatchTimeout(ReadOnlySpan argument) + { + const string MatchTimeoutName = "matchTimeout"; + var cursor = 0; + while (cursor < argument.Length && char.IsWhiteSpace(argument[cursor])) + cursor++; + if (cursor + MatchTimeoutName.Length > argument.Length + || !argument[cursor..(cursor + MatchTimeoutName.Length)].Equals(MatchTimeoutName, StringComparison.Ordinal)) + { + return false; + } + + cursor += MatchTimeoutName.Length; + while (cursor < argument.Length && char.IsWhiteSpace(argument[cursor])) + cursor++; + return cursor < argument.Length && argument[cursor] == ':'; + } + + static string NormalizeCSharpBclRegexQualifiedName(string value) + { + var normalized = NormalizeCSharpAliasTargetForTypeLookup(value); + normalized = TrimLeadingCSharpGlobalQualifier(normalized); + if (normalized.StartsWith("global.", StringComparison.Ordinal)) + normalized = normalized["global.".Length..]; + return normalized; + } +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs index d76a1aa8f..2fdfc39d4 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs @@ -662,418 +662,6 @@ bool IsCSharpRegexConstructorWithoutTimeout(int lineNumber, int column, string s return argCount is 1 or 2 && !hasNamedMatchTimeout; } - static bool HasCSharpQualifiedSeparatorBeforeToken(string line, int tokenStart) - { - var probe = tokenStart - 1; - while (probe >= 0 && char.IsWhiteSpace(line[probe])) - probe--; - - if (probe < 0) - return false; - if (line[probe] == '.') - return true; - return line[probe] == ':' && probe >= 1 && line[probe - 1] == ':'; - } - - static bool TryGetCSharpTokenBoundsAtColumn(string line, int column, string symbolName, out int tokenStart, out int tokenNameStart) - { - if (!TryGetCSharpIdentifierAtColumn(line, column, out tokenStart, out tokenNameStart, out var tokenName) - || string.IsNullOrWhiteSpace(symbolName)) - { - return false; - } - - return string.Equals(tokenName, symbolName, StringComparison.Ordinal); - } - - static bool TryGetCSharpIdentifierAtColumn(string line, int column, out int tokenStart, out int tokenNameStart, out string tokenName) - { - tokenStart = column - 1; - tokenNameStart = tokenStart; - tokenName = string.Empty; - if (tokenStart < 0 || tokenStart >= line.Length) - return false; - - if (line[tokenNameStart] == '@') - tokenNameStart++; - - if (tokenNameStart >= line.Length || !IsCSharpIdentifierPart(line[tokenNameStart])) - return false; - - var tokenEnd = tokenNameStart + 1; - while (tokenEnd < line.Length && IsCSharpIdentifierPart(line[tokenEnd])) - tokenEnd++; - - tokenName = NormalizeCSharpIdentifier(line[tokenStart..tokenEnd]); - return !string.IsNullOrWhiteSpace(tokenName); - } - - static bool TryGetCSharpQualifiedPrefixAtColumn(string line, int column, string symbolName, out string prefix) - { - prefix = string.Empty; - if (!TryGetCSharpTokenBoundsAtColumn(line, column, symbolName, out var tokenStart, out _) - || !HasCSharpQualifiedSeparatorBeforeToken(line, tokenStart)) - { - return false; - } - - var cursor = tokenStart - 1; - while (cursor >= 0 && char.IsWhiteSpace(line[cursor])) - cursor--; - if (cursor >= 0 && line[cursor] == '.') - cursor--; - else if (cursor >= 1 && line[cursor] == ':' && line[cursor - 1] == ':') - cursor -= 2; - else - return false; - - string? singleSegment = null; - List? segments = null; - while (cursor >= 0) - { - while (cursor >= 0 && char.IsWhiteSpace(line[cursor])) - cursor--; - - var segmentEnd = cursor; - while (cursor >= 0 && (IsCSharpIdentifierPart(line[cursor]) || line[cursor] == '@')) - cursor--; - - var segmentStart = cursor + 1; - if (segmentStart > segmentEnd) - return false; - - var segment = NormalizeCSharpIdentifier(line[segmentStart..(segmentEnd + 1)]); - if (singleSegment is null && segments is null) - { - singleSegment = segment; - } - else - { - if (segments is null) - { - segments = [singleSegment!]; - singleSegment = null; - } - - segments.Add(segment); - } - while (cursor >= 0 && char.IsWhiteSpace(line[cursor])) - cursor--; - - if (cursor >= 0 && line[cursor] == '.') - { - cursor--; - continue; - } - - if (cursor >= 1 && line[cursor] == ':' && line[cursor - 1] == ':') - { - cursor -= 2; - continue; - } - - break; - } - - if (segments is null) - { - if (singleSegment is null) - return false; - - prefix = singleSegment; - return true; - } - - if (segments.Count == 0) - return false; - - segments.Reverse(); - prefix = string.Join('.', segments); - return true; - } - - static bool TryCollectCSharpInvocationArguments(string[] sourceLines, int lineIndex, int openParen, out string args) - { - const int MaxInvocationLines = 32; - var initialCapacity = lineIndex >= 0 && lineIndex < sourceLines.Length - ? Math.Min(512, Math.Max(0, sourceLines[lineIndex].Length - openParen)) - : 0; - var builder = new StringBuilder(initialCapacity); - var depth = 0; - var started = false; - var lineLimit = Math.Min(sourceLines.Length, lineIndex + MaxInvocationLines); - - for (var currentLineIndex = lineIndex; currentLineIndex < lineLimit; currentLineIndex++) - { - var line = sourceLines[currentLineIndex]; - for (var i = currentLineIndex == lineIndex ? openParen : 0; i < line.Length;) - { - var skippedIndex = i; - if (TrySkipCSharpStringOrCharLiteral(line.AsSpan(), ref skippedIndex)) - { - if (started && depth > 0) - builder.Append(line.AsSpan(i, skippedIndex - i)); - i = skippedIndex; - continue; - } - - skippedIndex = i; - if (TrySkipCSharpComment(line.AsSpan(), ref skippedIndex)) - { - if (started && depth > 0) - builder.Append(' '); - i = skippedIndex; - continue; - } - - var ch = line[i++]; - if (ch == '(') - { - if (started && depth > 0) - builder.Append(ch); - depth++; - started = true; - continue; - } - - if (ch == ')' && started) - { - depth--; - if (depth == 0) - { - args = builder.ToString(); - return true; - } - - builder.Append(ch); - continue; - } - - if (started && depth > 0) - builder.Append(ch); - } - - if (started && depth > 0) - builder.Append('\n'); - } - - args = string.Empty; - return false; - } - - static int CountTopLevelCSharpArguments(ReadOnlySpan args, out bool hasNamedMatchTimeout) - { - hasNamedMatchTimeout = false; - var count = 0; - var tokenStart = 0; - var parenDepth = 0; - var bracketDepth = 0; - var braceDepth = 0; - - for (var i = 0; i <= args.Length; i++) - { - var atEnd = i == args.Length; - if (!atEnd) - { - if (TrySkipCSharpStringOrCharLiteral(args, ref i) - || TrySkipCSharpComment(args, ref i)) - { - i--; - continue; - } - - var ch = args[i]; - if (ch == '(') - parenDepth++; - else if (ch == ')' && parenDepth > 0) - parenDepth--; - else if (ch == '[') - bracketDepth++; - else if (ch == ']' && bracketDepth > 0) - bracketDepth--; - else if (ch == '{') - braceDepth++; - else if (ch == '}' && braceDepth > 0) - braceDepth--; - - if (ch != ',' || parenDepth != 0 || bracketDepth != 0 || braceDepth != 0) - continue; - } - - var segment = args[tokenStart..i].Trim(); - if (!segment.IsEmpty) - { - count++; - if (CSharpArgumentHasNamedMatchTimeout(segment)) - hasNamedMatchTimeout = true; - } - - tokenStart = i + 1; - } - - return count; - } - - static bool TrySkipCSharpComment(ReadOnlySpan text, ref int index) - { - if (index + 1 >= text.Length || text[index] != '/') - return false; - - if (text[index + 1] == '/') - { - index = text.Length; - return true; - } - - if (text[index + 1] != '*') - return false; - - index += 2; - while (index + 1 < text.Length) - { - if (text[index] == '*' && text[index + 1] == '/') - { - index += 2; - return true; - } - - index++; - } - - index = text.Length; - return true; - } - - static bool TrySkipCSharpStringOrCharLiteral(ReadOnlySpan text, ref int index) - { - var cursor = index; - var verbatim = false; - - if (cursor < text.Length && text[cursor] == '@') - { - verbatim = true; - cursor++; - while (cursor < text.Length && text[cursor] == '$') - cursor++; - } - else - { - while (cursor < text.Length && text[cursor] == '$') - cursor++; - if (cursor < text.Length && text[cursor] == '@') - { - verbatim = true; - cursor++; - } - } - - if (cursor >= text.Length || (text[cursor] != '"' && text[cursor] != '\'')) - return false; - - var quote = text[cursor]; - if (quote == '\'') - { - index = cursor + 1; - while (index < text.Length) - { - if (text[index] == '\\') - { - index += 2; - continue; - } - - if (text[index++] == '\'') - return true; - } - - return true; - } - - var quoteCount = 0; - while (cursor + quoteCount < text.Length && text[cursor + quoteCount] == '"') - quoteCount++; - - if (!verbatim && quoteCount >= 3) - { - index = cursor + quoteCount; - while (index + quoteCount <= text.Length) - { - var matched = true; - for (var offset = 0; offset < quoteCount; offset++) - { - if (text[index + offset] != '"') - { - matched = false; - break; - } - } - - if (matched) - { - index += quoteCount; - return true; - } - - index++; - } - - index = text.Length; - return true; - } - - index = cursor + 1; - while (index < text.Length) - { - if (!verbatim && text[index] == '\\') - { - index += 2; - continue; - } - - if (text[index] == '"') - { - if (verbatim && index + 1 < text.Length && text[index + 1] == '"') - { - index += 2; - continue; - } - - index++; - return true; - } - - index++; - } - - return true; - } - - static bool CSharpArgumentHasNamedMatchTimeout(ReadOnlySpan argument) - { - const string MatchTimeoutName = "matchTimeout"; - var cursor = 0; - while (cursor < argument.Length && char.IsWhiteSpace(argument[cursor])) - cursor++; - if (cursor + MatchTimeoutName.Length > argument.Length - || !argument[cursor..(cursor + MatchTimeoutName.Length)].Equals(MatchTimeoutName, StringComparison.Ordinal)) - { - return false; - } - - cursor += MatchTimeoutName.Length; - while (cursor < argument.Length && char.IsWhiteSpace(argument[cursor])) - cursor++; - return cursor < argument.Length && argument[cursor] == ':'; - } - - static string NormalizeCSharpBclRegexQualifiedName(string value) - { - var normalized = NormalizeCSharpAliasTargetForTypeLookup(value); - normalized = TrimLeadingCSharpGlobalQualifier(normalized); - if (normalized.StartsWith("global.", StringComparison.Ordinal)) - normalized = normalized["global.".Length..]; - return normalized; - } var references = CreateReferenceList(request.MaxReferenceCount, EstimateReferenceListInitialCapacity(lines.Length)); var seen = CreateReferenceSeenSet(lines.Length); From 0f7ae5a325fc206fe8c8e1fad035b84c4f9f415d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 19:51:49 +0900 Subject: [PATCH 063/101] Extract core reference lookup state --- .../ReferenceExtractor.CoreExtraction.cs | 523 ++---------------- .../ReferenceExtractor.CoreLookups.cs | 509 +++++++++++++++++ 2 files changed, 547 insertions(+), 485 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs index 2fdfc39d4..13dcb40d0 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs @@ -124,13 +124,9 @@ internal static List ExtractCore(ReferenceExtractionContext req // プロパティ自身に帰属させる (issue #233 参照)。 var containerCandidates = BuildReferenceContainerCandidates(symbols, request.ReportDiagnostic); var containerResolver = new InnermostContainerResolver(containerCandidates); - Dictionary>? csharpSameLineContainerCandidatesByLine = null; - var csharpSameLineContainerCandidatesResolved = false; if (language == "solidity") return ExtractSolidityReferences(fileId, lines, preparedLines, containerResolver); - IReadOnlyList? csharpXmlDocAttachmentScopeCandidates = null; - var csharpXmlDocAttachmentScopeCandidatesResolved = false; // Enclosing-type candidates for constructor-chain rewrites (class/struct/record; namespace excluded). // Ordered innermost-first via ascending body range. Java enums can declare constructors and // chain via `this(...)` so `enum` is included; C# enums cannot declare constructors, and @@ -138,20 +134,6 @@ internal static List ExtractCore(ReferenceExtractionContext req // コンストラクタ連鎖の呼び先解決で使う外側の型候補(class/struct/record/enum。namespace は含めない)。 // 内側優先で昇順にソート。Java の enum は `this(...)` 連鎖を持てるため `enum` も含める。 // C# の enum はコンストラクタ自体を持てず `CSharpCtorChainRegex` が一致しないので副作用は無い。 - IReadOnlyList? enclosingTypeCandidates = null; - var enclosingTypeCandidatesResolved = false; - IReadOnlyList? rustEnumCandidates = null; - var rustEnumCandidatesResolved = false; - ( - IReadOnlyDictionary<(int Line, string Kind), SymbolRecord>? DefinitionContainersByLineAndKind, - IReadOnlyDictionary? HeaderSymbolsByLine) pythonSymbolLookups = default; - var pythonSymbolLookupsResolved = false; - HashSet? pythonClassNames = null; - var pythonClassNamesResolved = false; - PythonImportBindingResolver.ImportedTypeCallLookup? pythonImportedTypeCallLookup = null; - HashSet<(string Container, string Name)>? csharpPrivateProperties = null; - var csharpPrivatePropertiesResolved = false; - Dictionary>? csharpContainerCandidatesByName = null; var swiftPropertyDefinitionsByLine = language == "swift" ? BuildSwiftPropertyDefinitionsByLine(language, symbols, request.ReportDiagnostic) : null; @@ -162,8 +144,6 @@ internal static List ExtractCore(ReferenceExtractionContext req // later line are covered. Later lines inside the body keep their real innermost containers. // C# のプライマリコンストラクタ宣言(record / class / struct)で base primary-ctor を呼んでいる場合、 // 宣言ヘッダー全体を合成コンテナで上書きする。`{` / `;` 以降の本体行は通常の container に戻す。 - List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container)>? recordPrimaryCtorRanges = null; - var recordPrimaryCtorRangesResolved = false; var csharpTypeNameSets = language == "csharp" ? BuildCSharpTypeNameSets(language, symbols) : (KnownTypeNames: EmptyCSharpStringSet, NonEnumTypeNames: EmptyCSharpStringSet); @@ -220,447 +200,20 @@ internal static List ExtractCore(ReferenceExtractionContext req var csharpUsingAliases = csharpUsingImports.Aliases; var csharpUsingNamespaces = csharpUsingImports.Namespaces; var csharpUsingStatics = csharpUsingImports.Statics; - ( - IReadOnlyDictionary ByContainingType, - IReadOnlyDictionary> ByFunctionStartLine)? csharpValueReceiverLookups = null; - var csharpValueReceiverLookupsResolved = false; - IReadOnlyDictionary>? powershellSplatAssignments = null; - var powershellSplatAssignmentsResolved = false; - - bool HasSameFilePythonClass(string candidate, string leaf) - { - if (!pythonClassNamesResolved) - { - foreach (var symbol in symbols) - { - if (symbol.Kind == "class") - (pythonClassNames ??= new HashSet(StringComparer.Ordinal)).Add(symbol.Name); - } - pythonClassNamesResolved = true; - } - - return pythonClassNames != null - && (pythonClassNames.Contains(candidate) || pythonClassNames.Contains(leaf)); - } - - PythonImportBindingResolver.ImportedTypeCallLookup GetPythonImportedTypeCallLookup() - => pythonImportedTypeCallLookup ??= PythonImportBindingResolver.BuildImportedTypeCallLookup(symbols); - - bool HasCSharpPrivateProperty(string containingType, string propertyName) - { - if (!csharpPrivatePropertiesResolved) - { - foreach (var symbol in symbols) - { - if (symbol.Kind == "property" - && symbol.ContainerQualifiedName != null - && string.Equals(symbol.Visibility, "private", StringComparison.OrdinalIgnoreCase)) - { - (csharpPrivateProperties ??= []).Add((symbol.ContainerQualifiedName, symbol.Name)); - } - } - csharpPrivatePropertiesResolved = true; - } - - return csharpPrivateProperties?.Contains((containingType, propertyName)) == true; - } - - SymbolRecord? FindCSharpContainerCandidate(string? containerName, int lineNumber) - { - if (containerName == null) - return null; - - if (csharpContainerCandidatesByName == null) - { - csharpContainerCandidatesByName = new Dictionary>(StringComparer.Ordinal); - foreach (var candidate in containerCandidates) - { - if (!csharpContainerCandidatesByName.TryGetValue(candidate.Name, out var candidates)) - { - candidates = []; - csharpContainerCandidatesByName.Add(candidate.Name, candidates); - } - candidates.Add(candidate); - } - } - - if (!csharpContainerCandidatesByName.TryGetValue(containerName, out var namedCandidates)) - return null; - - foreach (var candidate in namedCandidates) - { - if (candidate.BodyStartLine <= lineNumber && candidate.BodyEndLine >= lineNumber) - return candidate; - } - - return null; - } - - // Workspace-wide same-name type rescue needs cross-file visibility, so the - // extractor leaves ambiguous unqualified using-static pattern heads for the - // read path to disambiguate. - // ワークスペース全体の同名型 rescue には cross-file 可視性が必要なため、 - // extractor は曖昧な unqualified using-static pattern head を残し、 - // read path 側で判定させる。 - bool HasActiveSameFileCSharpTypeCandidate(string typeExpression, int lineNumber) - { - var normalized = NormalizeCSharpAliasTargetForTypeLookup(typeExpression); - if (string.IsNullOrWhiteSpace(normalized)) - return false; - - normalized = TrimLeadingCSharpGlobalQualifier(normalized); - if (csharpKnownTypeNames.Contains(normalized)) - return true; - - var shortName = GetLastQualifiedSegment(normalized); - for (var aliasIndex = csharpUsingAliases.Count - 1; aliasIndex >= 0; aliasIndex--) - { - var alias = csharpUsingAliases[aliasIndex]; - if (alias.TargetsType - && alias.Line <= lineNumber - && lineNumber >= alias.ScopeStartLine - && lineNumber <= alias.ScopeEndLine - && string.Equals(alias.AliasName, shortName, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - - Dictionary>? GetCSharpSameLineContainerCandidatesByLine() - { - if (!csharpSameLineContainerCandidatesResolved) - { - csharpSameLineContainerCandidatesByLine = BuildCSharpSameLineContainerCandidatesByLine(language, containerCandidates); - csharpSameLineContainerCandidatesResolved = true; - } - - return csharpSameLineContainerCandidatesByLine; - } - - IReadOnlyList? GetCSharpXmlDocAttachmentScopeCandidates() - { - if (!csharpXmlDocAttachmentScopeCandidatesResolved) - { - csharpXmlDocAttachmentScopeCandidates = csharpLinesInsideMultilineStringContent != null - ? BuildCSharpXmlDocAttachmentScopeCandidates(language, symbols, request.ReportDiagnostic) - : null; - csharpXmlDocAttachmentScopeCandidatesResolved = true; - } - - return csharpXmlDocAttachmentScopeCandidates; - } - - IReadOnlyList GetEnclosingTypeCandidates() - { - if (!enclosingTypeCandidatesResolved) - { - enclosingTypeCandidates = language is "csharp" or "java" or "kotlin" - ? BuildEnclosingTypeCandidates(symbols, request.ReportDiagnostic) - : []; - enclosingTypeCandidatesResolved = true; - } - - return enclosingTypeCandidates!; - } - - IReadOnlyList? GetRustEnumCandidates() - { - if (!rustEnumCandidatesResolved) - { - rustEnumCandidates = language == "rust" - ? BuildRustEnumCandidates(symbols) - : null; - rustEnumCandidatesResolved = true; - } - - return rustEnumCandidates; - } - - ( - IReadOnlyDictionary<(int Line, string Kind), SymbolRecord>? DefinitionContainersByLineAndKind, - IReadOnlyDictionary? HeaderSymbolsByLine) GetPythonSymbolLookups() - { - if (!pythonSymbolLookupsResolved) - { - pythonSymbolLookups = language == "python" - ? BuildPythonSymbolLookups(symbols) - : default; - pythonSymbolLookupsResolved = true; - } - - return pythonSymbolLookups; - } - - IReadOnlyDictionary<(int Line, string Kind), SymbolRecord>? GetPythonDefinitionContainersByLineAndKind() => - GetPythonSymbolLookups().DefinitionContainersByLineAndKind; - - IReadOnlyDictionary? GetPythonHeaderSymbolsByLine() => - GetPythonSymbolLookups().HeaderSymbolsByLine; - - IReadOnlyDictionary> GetPowerShellSplatAssignments() - { - if (!powershellSplatAssignmentsResolved) - { - powershellSplatAssignments = PowerShellReferenceExtractor.BuildSplatAssignments(preparedLines); - powershellSplatAssignmentsResolved = true; - } - - return powershellSplatAssignments!; - } - - List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container)> GetRecordPrimaryCtorRanges() - { - if (!recordPrimaryCtorRangesResolved) - { - recordPrimaryCtorRanges = BuildCSharpPrimaryCtorContainers(language, symbols, structuralLines); - recordPrimaryCtorRangesResolved = true; - } - - return recordPrimaryCtorRanges!; - } - - ( - IReadOnlyDictionary ByContainingType, - IReadOnlyDictionary> ByFunctionStartLine) GetCSharpValueReceiverLookups() - { - if (!csharpValueReceiverLookupsResolved) - { - csharpValueReceiverLookups = BuildCSharpValueReceiverNameLookups( - language, - symbols, - structuralLines, - csharpKnownTypeNames, - csharpUsingAliases); - csharpValueReceiverLookupsResolved = true; - } - - return csharpValueReceiverLookups!.Value; - } - - IReadOnlyDictionary GetCSharpValueReceiverNames() => - GetCSharpValueReceiverLookups().ByContainingType; - - IReadOnlyDictionary> GetCSharpFunctionValueReceiverNames() => - GetCSharpValueReceiverLookups().ByFunctionStartLine; - - string ResolveCSharpUsingAliasReferenceName(string referenceName, int lineNumber) - { - if (language != "csharp") - return referenceName; - - for (var aliasIndex = csharpUsingAliases.Count - 1; aliasIndex >= 0; aliasIndex--) - { - var alias = csharpUsingAliases[aliasIndex]; - if (alias.Line > lineNumber - || lineNumber < alias.ScopeStartLine - || lineNumber > alias.ScopeEndLine - || !string.Equals(alias.AliasName, referenceName, StringComparison.Ordinal)) - { - continue; - } - - var targetName = GetLastQualifiedSegment(TrimLeadingCSharpGlobalQualifier(alias.TargetQualifiedName)); - return string.IsNullOrWhiteSpace(targetName) ? referenceName : targetName; - } - - return referenceName; - } - - void ApplyCSharpUsingAliasReferenceNames(List references) - { - if (language != "csharp") - return; - - var aliasNameChanged = false; - foreach (var reference in references) - { - if (reference.ReferenceKind is not ("instantiate" or "attribute")) - continue; - if (reference.Line <= 0 || reference.Line > lines.Length || reference.Column <= 0) - continue; - if (!IsUnqualifiedCSharpTokenAtColumn(reference.Line, reference.Column, reference.SymbolName)) - continue; - - var resolvedName = ResolveCSharpUsingAliasReferenceName(reference.SymbolName, reference.Line); - if (string.Equals(resolvedName, reference.SymbolName, StringComparison.Ordinal)) - continue; - - reference.SymbolName = resolvedName; - reference.IsSelfReference = IsSameReferenceName(reference.ContainerName, resolvedName); - aliasNameChanged = true; - } - - if (aliasNameChanged) - CompactCSharpUsingAliasReferences(references, language); - } - - bool IsUnqualifiedCSharpTokenAtColumn(int lineNumber, int column, string symbolName) - { - if (lineNumber <= 0 - || lineNumber > lines.Length - || column <= 0 - || string.IsNullOrWhiteSpace(symbolName)) - return false; - - var line = lines[lineNumber - 1]; - var tokenStart = column - 1; - if (tokenStart >= line.Length) - return false; - - var tokenNameStart = tokenStart; - if (line[tokenNameStart] == '@') - tokenNameStart++; - - if (tokenNameStart + symbolName.Length > line.Length) - return false; - if (!line.AsSpan(tokenNameStart, symbolName.Length).Equals(symbolName, StringComparison.Ordinal)) - return false; - - var previousIndex = tokenStart - 1; - var nextIndex = tokenNameStart + symbolName.Length; - var hasQualifiedPrefix = HasCSharpQualifiedSeparatorBeforeToken(line, tokenStart) - || (previousIndex >= 0 && IsCSharpIdentifierPart(line[previousIndex])); - var hasIdentifierSuffix = nextIndex < line.Length && IsCSharpIdentifierPart(line[nextIndex]); - return !hasQualifiedPrefix && !hasIdentifierSuffix; - } - - bool HasActiveCSharpUsingNamespace(string targetQualifiedName, int lineNumber) - { - var normalizedTarget = NormalizeCSharpBclRegexQualifiedName(targetQualifiedName); - for (var importIndex = csharpUsingNamespaces.Count - 1; importIndex >= 0; importIndex--) - { - var import = csharpUsingNamespaces[importIndex]; - if (import.Line > lineNumber - || lineNumber < import.ScopeStartLine - || lineNumber > import.ScopeEndLine) - { - continue; - } - - if (string.Equals(NormalizeCSharpBclRegexQualifiedName(import.TargetQualifiedName), normalizedTarget, StringComparison.Ordinal)) - return true; - } - - return false; - } - - CSharpUsingAliasRecord? FindActiveCSharpUsingAlias(string aliasName, int lineNumber) - { - for (var aliasIndex = csharpUsingAliases.Count - 1; aliasIndex >= 0; aliasIndex--) - { - var alias = csharpUsingAliases[aliasIndex]; - if (alias.Line > lineNumber - || lineNumber < alias.ScopeStartLine - || lineNumber > alias.ScopeEndLine - || !string.Equals(alias.AliasName, aliasName, StringComparison.Ordinal)) - { - continue; - } - - return alias; - } - - return null; - } - - void EmitCSharpBclRegexWithoutTimeoutReferences(List references, ReferenceDedupeSet seen) - { - if (language != "csharp") - return; - - var referenceCount = references.Count; - for (var referenceIndex = 0; referenceIndex < referenceCount; referenceIndex++) - { - var reference = references[referenceIndex]; - if (reference.ReferenceKind != "instantiate" - || !string.Equals(reference.SymbolName, "Regex", StringComparison.Ordinal) - || reference.Line <= 0 - || reference.Line > lines.Length - || reference.Column <= 0 - || !IsCSharpBclRegexInstantiateReference(reference) - || !IsCSharpRegexConstructorWithoutTimeout(reference.Line, reference.Column, reference.SymbolName)) - { - continue; - } - - var dedupeKey = CreateReferenceDedupeKey( - reference.FileId, - language, - reference.Line, - reference.Column, - "bcl_regex_without_timeout", - reference.SymbolName, - reference.ContainerKind, - reference.ContainerName); - if (!seen.Add(dedupeKey)) - continue; - - if (!TryAddReference(references, new ReferenceRecord - { - FileId = reference.FileId, - SymbolName = reference.SymbolName, - ReferenceKind = "bcl_regex_without_timeout", - Line = reference.Line, - Column = reference.Column, - Context = reference.Context, - ContainerKind = reference.ContainerKind, - ContainerName = reference.ContainerName, - IsSelfReference = reference.IsSelfReference, - })) - { - return; - } - } - } - - bool IsCSharpBclRegexInstantiateReference(ReferenceRecord reference) - { - var line = lines[reference.Line - 1]; - if (!TryGetCSharpIdentifierAtColumn(line, reference.Column, out _, out _, out var tokenName)) - return false; - - if (TryGetCSharpQualifiedPrefixAtColumn(line, reference.Column, tokenName, out var prefix) - && string.Equals(NormalizeCSharpBclRegexQualifiedName($"{prefix}.{tokenName}"), "System.Text.RegularExpressions.Regex", StringComparison.Ordinal)) - { - return true; - } - - var alias = FindActiveCSharpUsingAlias(tokenName, reference.Line); - if (alias != null) - { - return string.Equals( - NormalizeCSharpBclRegexQualifiedName(alias.TargetQualifiedName), - "System.Text.RegularExpressions.Regex", - StringComparison.Ordinal); - } - - return string.Equals(tokenName, "Regex", StringComparison.Ordinal) - && !HasActiveSameFileCSharpTypeCandidate(tokenName, reference.Line) - && HasActiveCSharpUsingNamespace("System.Text.RegularExpressions", reference.Line); - } - - bool IsCSharpRegexConstructorWithoutTimeout(int lineNumber, int column, string symbolName) - { - var line = lines[lineNumber - 1]; - _ = symbolName; - if (!TryGetCSharpIdentifierAtColumn(line, column, out _, out var tokenNameStart, out var tokenName)) - return false; - - var cursor = tokenNameStart + tokenName.Length; - while (cursor < line.Length && char.IsWhiteSpace(line[cursor])) - cursor++; - if (cursor >= line.Length || line[cursor] != '(') - return false; + var lookups = new CoreExtractionLookups( + request, + language, + symbols, + containerCandidates, + csharpLinesInsideMultilineStringContent, + preparedLines, + structuralLines, + lines, + csharpKnownTypeNames, + csharpUsingAliases, + csharpUsingNamespaces); - if (!TryCollectCSharpInvocationArguments(lines, lineNumber - 1, cursor, out var args)) - return false; - var argCount = CountTopLevelCSharpArguments(args.AsSpan(), out var hasNamedMatchTimeout); - return argCount is 1 or 2 && !hasNamedMatchTimeout; - } var references = CreateReferenceList(request.MaxReferenceCount, EstimateReferenceListInitialCapacity(lines.Length)); @@ -795,7 +348,7 @@ bool IsCSharpRegexConstructorWithoutTimeout(int lineNumber, int column, string s && (docContainer.StartLine == lineNumber || CanAttachCSharpXmlDocCommentToNextDeclaration( innermostContainer, - GetCSharpXmlDocAttachmentScopeCandidates(), + lookups.GetCSharpXmlDocAttachmentScopeCandidates(), csharpAttrRanges, preparedLines, lineNumber, @@ -945,7 +498,7 @@ bool IsCSharpRegexConstructorWithoutTimeout(int lineNumber, int column, string s csharpQualifiedConstantPatternMemberLookup, csharpUsingAliases, csharpUsingStatics, - HasActiveSameFileCSharpTypeCandidate, + lookups.HasActiveSameFileCSharpTypeCandidate, references, seen, fileId); @@ -986,7 +539,7 @@ bool IsCSharpRegexConstructorWithoutTimeout(int lineNumber, int column, string s javaSameLineCtor = JavaReferenceExtractor.TryBuildSameLineCtorSpan( preparedLine, lineNumber, - GetEnclosingTypeCandidates); + lookups.GetEnclosingTypeCandidates); } // Per-call-site record primary-ctor override: only calls whose column sits inside the @@ -1000,7 +553,7 @@ bool IsCSharpRegexConstructorWithoutTimeout(int lineNumber, int column, string s { if (language == "csharp") { - foreach (var (rangeStart, rangeStartColumn, rangeEnd, rangeEndColumn, syntheticRecordCtor) in GetRecordPrimaryCtorRanges()) + foreach (var (rangeStart, rangeStartColumn, rangeEnd, rangeEndColumn, syntheticRecordCtor) in lookups.GetRecordPrimaryCtorRanges()) { if (lineNumber < rangeStart || lineNumber > rangeEnd) continue; @@ -1045,7 +598,7 @@ bool IsCSharpRegexConstructorWithoutTimeout(int lineNumber, int column, string s } var sameLineContainer = FindInnermostSameLineCSharpContainer( - GetCSharpSameLineContainerCandidatesByLine(), + lookups.GetCSharpSameLineContainerCandidatesByLine(), structuralLines[i], lineNumber, column); @@ -1067,7 +620,7 @@ bool IsCSharpRegexConstructorWithoutTimeout(int lineNumber, int column, string s SymbolRecord? ResolvePythonDefinitionContainer(int line, string kind) { - var pythonDefinitionContainersByLineAndKind = GetPythonDefinitionContainersByLineAndKind(); + var pythonDefinitionContainersByLineAndKind = lookups.GetPythonDefinitionContainersByLineAndKind(); if (pythonDefinitionContainersByLineAndKind == null) return null; return pythonDefinitionContainersByLineAndKind.TryGetValue((line, kind), out var symbol) @@ -1180,7 +733,7 @@ bool IsCSharpRegexConstructorWithoutTimeout(int lineNumber, int column, string s csharpQualifiedConstantPatternMemberLookup, csharpUsingAliases, csharpUsingStatics, - HasActiveSameFileCSharpTypeCandidate, + lookups.HasActiveSameFileCSharpTypeCandidate, references, seen, fileId, @@ -1290,19 +843,19 @@ bool TryGetDefinitionNameIndex(string resolvedName, out int definitionIndex) if (language is "csharp") { CSharpReferenceExtractor.EmitCtorChainReferences( - preparedLine, GetEnclosingTypeCandidates, containerCandidates, + preparedLine, lookups.GetEnclosingTypeCandidates, containerCandidates, structuralLines, references, seen, fileId, context, lineNumber, container); } else if (language is "java") { JavaReferenceExtractor.EmitCtorChainReferences( - preparedLine, GetEnclosingTypeCandidates, symbols, structuralLines, + preparedLine, lookups.GetEnclosingTypeCandidates, symbols, structuralLines, references, seen, fileId, context, lineNumber, container); } else if (language is "kotlin") { KotlinReferenceExtractor.EmitCtorDelegationReferences( - preparedLine, GetEnclosingTypeCandidates, symbols, structuralLines, + preparedLine, lookups.GetEnclosingTypeCandidates, symbols, structuralLines, references, seen, fileId, context, lineNumber, container); } @@ -1393,7 +946,7 @@ bool TryGetDefinitionNameIndex(string resolvedName, out int definitionIndex) csharpQualifiedTypePatternLookup, csharpUsingAliases, csharpUsingStatics, - HasActiveSameFileCSharpTypeCandidate, + lookups.HasActiveSameFileCSharpTypeCandidate, references, seen, fileId, @@ -1506,7 +1059,7 @@ bool TryGetDefinitionNameIndex(string resolvedName, out int definitionIndex) } else if (language == "rust") { - var rustEnumCandidatesForLine = GetRustEnumCandidates(); + var rustEnumCandidatesForLine = lookups.GetRustEnumCandidates(); var rustEnumContainer = rustEnumCandidatesForLine != null ? FindInnermostContainer(rustEnumCandidatesForLine, lineNumber) : null; @@ -2007,7 +1560,7 @@ bool TryAddCallLikeReference( && callIndex + name.Length < preparedLine.Length && preparedLine.AsSpan(callIndex + name.Length).TrimStart().StartsWith(".", StringComparison.Ordinal)) { - var receiverLookups = GetCSharpValueReceiverLookups(); + var receiverLookups = lookups.GetCSharpValueReceiverLookups(); if (HasCSharpValueReceiverConflict( normalizedName, normalizedName, @@ -2021,7 +1574,7 @@ bool TryAddCallLikeReference( if (containingType != null && receiverLookups.ByContainingType.TryGetValue(containingType, out var receiverNames) && (receiverNames.InstanceNames.Contains(normalizedName) || receiverNames.StaticNames.Contains(normalizedName)) - && HasCSharpPrivateProperty(containingType, normalizedName)) + && lookups.HasCSharpPrivateProperty(containingType, normalizedName)) { references.RemoveAll(reference => reference.FileId == fileId @@ -2147,7 +1700,7 @@ bool TryGetKnownPythonTypeCall(string candidate, out string canonicalName) if (leaf.Length == 0 || !char.IsUpper(leaf, 0)) return false; - if (HasSameFilePythonClass(candidate, leaf)) + if (lookups.HasSameFilePythonClass(candidate, leaf)) { return true; } @@ -2156,7 +1709,7 @@ bool TryGetKnownPythonTypeCall(string candidate, out string canonicalName) candidate, preparedLine, callIndex, - GetPythonImportedTypeCallLookup(), + lookups.GetPythonImportedTypeCallLookup(), out canonicalName); } } @@ -2207,7 +1760,7 @@ bool TryGetKnownPythonTypeCall(string candidate, out string canonicalName) PowerShellReferenceExtractor.EmitCallReferences(preparedLine, AddCallLikeReference); PowerShellReferenceExtractor.EmitSplatParameterReferences( preparedLine, - GetPowerShellSplatAssignments, + lookups.GetPowerShellSplatAssignments, lineNumber, AddPowerShellParameterReference); } @@ -2566,8 +2119,8 @@ void AddGradleDslReference(string name, int callIndex) csharpQualifiedEnumMemberLookup, csharpAttrRangesOnLine, csharpUsingAliases, - GetCSharpValueReceiverNames, - GetCSharpFunctionValueReceiverNames, + lookups.GetCSharpValueReceiverNames, + lookups.GetCSharpFunctionValueReceiverNames, references, seen, fileId, @@ -2728,7 +2281,7 @@ void AddGradleDslReference(string name, int callIndex) var pythonPreparedLine = preparedLine; var pythonHeaderMap = default(PythonLogicalHeaderReferenceLine?); SymbolRecord? pythonHeaderSymbol = null; - GetPythonHeaderSymbolsByLine()?.TryGetValue(lineNumber, out pythonHeaderSymbol); + lookups.GetPythonHeaderSymbolsByLine()?.TryGetValue(lineNumber, out pythonHeaderSymbol); if (pythonHeaderSymbol?.Signature != null && TryBuildPythonLogicalHeaderReferenceLine(lines, i, pythonHeaderSymbol.StartColumn ?? 0, out var builtPythonHeaderMap)) { @@ -3139,7 +2692,7 @@ void AddGradleDslReference(string name, int callIndex) csharpQualifiedTypePatternLookup, csharpUsingAliases, csharpUsingStatics, - HasActiveSameFileCSharpTypeCandidate, + lookups.HasActiveSameFileCSharpTypeCandidate, references, seen, fileId); @@ -3149,7 +2702,7 @@ void AddGradleDslReference(string name, int callIndex) csharpQualifiedConstantPatternMemberLookup, csharpUsingAliases, csharpUsingStatics, - HasActiveSameFileCSharpTypeCandidate, + lookups.HasActiveSameFileCSharpTypeCandidate, references, seen, fileId); @@ -3172,9 +2725,9 @@ void AddGradleDslReference(string name, int callIndex) if (tokenEnd >= line.Length || !line.AsSpan(tokenEnd).TrimStart().StartsWith(".", StringComparison.Ordinal)) continue; - var owner = FindCSharpContainerCandidate(reference.ContainerName, reference.Line); + var owner = lookups.FindCSharpContainerCandidate(reference.ContainerName, reference.Line); var containingType = GetContainingTypeQualifiedName(owner); - if (containingType == null || !HasCSharpPrivateProperty(containingType, reference.SymbolName)) + if (containingType == null || !lookups.HasCSharpPrivateProperty(containingType, reference.SymbolName)) { continue; } @@ -3184,9 +2737,9 @@ void AddGradleDslReference(string name, int callIndex) } } - ApplyCSharpUsingAliasReferenceNames(references); + lookups.ApplyCSharpUsingAliasReferenceNames(references); if (!ReferenceLimitReached(references)) - EmitCSharpBclRegexWithoutTimeoutReferences(references, seen); + lookups.EmitCSharpBclRegexWithoutTimeoutReferences(references, seen); MarkMutualRecursionReferences(references); return references; } diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs new file mode 100644 index 000000000..21a499a9f --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs @@ -0,0 +1,509 @@ +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private sealed class CoreExtractionLookups + { + private readonly ReferenceExtractionContext request; + private readonly string language; + private readonly IReadOnlyList symbols; + private readonly IReadOnlyList containerCandidates; + private readonly bool[]? csharpLinesInsideMultilineStringContent; + private readonly string[] preparedLines; + private readonly string[] structuralLines; + private readonly string[] lines; + private readonly IReadOnlySet csharpKnownTypeNames; + private readonly IReadOnlyList csharpUsingAliases; + private readonly IReadOnlyList csharpUsingNamespaces; + + private Dictionary>? csharpSameLineContainerCandidatesByLine; + private bool csharpSameLineContainerCandidatesResolved; + private IReadOnlyList? csharpXmlDocAttachmentScopeCandidates; + private bool csharpXmlDocAttachmentScopeCandidatesResolved; + private IReadOnlyList? enclosingTypeCandidates; + private bool enclosingTypeCandidatesResolved; + private IReadOnlyList? rustEnumCandidates; + private bool rustEnumCandidatesResolved; + private ( + IReadOnlyDictionary<(int Line, string Kind), SymbolRecord>? DefinitionContainersByLineAndKind, + IReadOnlyDictionary? HeaderSymbolsByLine) pythonSymbolLookups; + private bool pythonSymbolLookupsResolved; + private HashSet? pythonClassNames; + private bool pythonClassNamesResolved; + private PythonImportBindingResolver.ImportedTypeCallLookup? pythonImportedTypeCallLookup; + private HashSet<(string Container, string Name)>? csharpPrivateProperties; + private bool csharpPrivatePropertiesResolved; + private Dictionary>? csharpContainerCandidatesByName; + private List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container)>? recordPrimaryCtorRanges; + private bool recordPrimaryCtorRangesResolved; + private ( + IReadOnlyDictionary ByContainingType, + IReadOnlyDictionary> ByFunctionStartLine)? csharpValueReceiverLookups; + private bool csharpValueReceiverLookupsResolved; + private IReadOnlyDictionary>? powershellSplatAssignments; + private bool powershellSplatAssignmentsResolved; + + internal CoreExtractionLookups( + ReferenceExtractionContext request, + string language, + IReadOnlyList symbols, + IReadOnlyList containerCandidates, + bool[]? csharpLinesInsideMultilineStringContent, + string[] preparedLines, + string[] structuralLines, + string[] lines, + IReadOnlySet csharpKnownTypeNames, + IReadOnlyList csharpUsingAliases, + IReadOnlyList csharpUsingNamespaces) + { + this.request = request; + this.language = language; + this.symbols = symbols; + this.containerCandidates = containerCandidates; + this.csharpLinesInsideMultilineStringContent = csharpLinesInsideMultilineStringContent; + this.preparedLines = preparedLines; + this.structuralLines = structuralLines; + this.lines = lines; + this.csharpKnownTypeNames = csharpKnownTypeNames; + this.csharpUsingAliases = csharpUsingAliases; + this.csharpUsingNamespaces = csharpUsingNamespaces; + } + + internal bool HasSameFilePythonClass(string candidate, string leaf) + { + if (!pythonClassNamesResolved) + { + foreach (var symbol in symbols) + { + if (symbol.Kind == "class") + (pythonClassNames ??= new HashSet(StringComparer.Ordinal)).Add(symbol.Name); + } + pythonClassNamesResolved = true; + } + + return pythonClassNames != null + && (pythonClassNames.Contains(candidate) || pythonClassNames.Contains(leaf)); + } + + internal PythonImportBindingResolver.ImportedTypeCallLookup GetPythonImportedTypeCallLookup() + => pythonImportedTypeCallLookup ??= PythonImportBindingResolver.BuildImportedTypeCallLookup(symbols); + + internal bool HasCSharpPrivateProperty(string containingType, string propertyName) + { + if (!csharpPrivatePropertiesResolved) + { + foreach (var symbol in symbols) + { + if (symbol.Kind == "property" + && symbol.ContainerQualifiedName != null + && string.Equals(symbol.Visibility, "private", StringComparison.OrdinalIgnoreCase)) + { + (csharpPrivateProperties ??= []).Add((symbol.ContainerQualifiedName, symbol.Name)); + } + } + csharpPrivatePropertiesResolved = true; + } + + return csharpPrivateProperties?.Contains((containingType, propertyName)) == true; + } + + internal SymbolRecord? FindCSharpContainerCandidate(string? containerName, int lineNumber) + { + if (containerName == null) + return null; + + if (csharpContainerCandidatesByName == null) + { + csharpContainerCandidatesByName = new Dictionary>(StringComparer.Ordinal); + foreach (var candidate in containerCandidates) + { + if (!csharpContainerCandidatesByName.TryGetValue(candidate.Name, out var candidates)) + { + candidates = []; + csharpContainerCandidatesByName.Add(candidate.Name, candidates); + } + candidates.Add(candidate); + } + } + + if (!csharpContainerCandidatesByName.TryGetValue(containerName, out var namedCandidates)) + return null; + + foreach (var candidate in namedCandidates) + { + if (candidate.BodyStartLine <= lineNumber && candidate.BodyEndLine >= lineNumber) + return candidate; + } + + return null; + } + + // Workspace-wide same-name type rescue needs cross-file visibility, so the + // extractor leaves ambiguous unqualified using-static pattern heads for the + // read path to disambiguate. + // ワークスペース全体の同名型 rescue には cross-file 可視性が必要なため、 + // extractor は曖昧な unqualified using-static pattern head を残し、 + // read path 側で判定させる。 + internal bool HasActiveSameFileCSharpTypeCandidate(string typeExpression, int lineNumber) + { + var normalized = NormalizeCSharpAliasTargetForTypeLookup(typeExpression); + if (string.IsNullOrWhiteSpace(normalized)) + return false; + + normalized = TrimLeadingCSharpGlobalQualifier(normalized); + if (csharpKnownTypeNames.Contains(normalized)) + return true; + + var shortName = GetLastQualifiedSegment(normalized); + for (var aliasIndex = csharpUsingAliases.Count - 1; aliasIndex >= 0; aliasIndex--) + { + var alias = csharpUsingAliases[aliasIndex]; + if (alias.TargetsType + && alias.Line <= lineNumber + && lineNumber >= alias.ScopeStartLine + && lineNumber <= alias.ScopeEndLine + && string.Equals(alias.AliasName, shortName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + internal Dictionary>? GetCSharpSameLineContainerCandidatesByLine() + { + if (!csharpSameLineContainerCandidatesResolved) + { + csharpSameLineContainerCandidatesByLine = BuildCSharpSameLineContainerCandidatesByLine(language, containerCandidates); + csharpSameLineContainerCandidatesResolved = true; + } + + return csharpSameLineContainerCandidatesByLine; + } + + internal IReadOnlyList? GetCSharpXmlDocAttachmentScopeCandidates() + { + if (!csharpXmlDocAttachmentScopeCandidatesResolved) + { + csharpXmlDocAttachmentScopeCandidates = csharpLinesInsideMultilineStringContent != null + ? BuildCSharpXmlDocAttachmentScopeCandidates(language, symbols, request.ReportDiagnostic) + : null; + csharpXmlDocAttachmentScopeCandidatesResolved = true; + } + + return csharpXmlDocAttachmentScopeCandidates; + } + + internal IReadOnlyList GetEnclosingTypeCandidates() + { + if (!enclosingTypeCandidatesResolved) + { + enclosingTypeCandidates = language is "csharp" or "java" or "kotlin" + ? BuildEnclosingTypeCandidates(symbols, request.ReportDiagnostic) + : []; + enclosingTypeCandidatesResolved = true; + } + + return enclosingTypeCandidates!; + } + + internal IReadOnlyList? GetRustEnumCandidates() + { + if (!rustEnumCandidatesResolved) + { + rustEnumCandidates = language == "rust" + ? BuildRustEnumCandidates(symbols) + : null; + rustEnumCandidatesResolved = true; + } + + return rustEnumCandidates; + } + + private ( + IReadOnlyDictionary<(int Line, string Kind), SymbolRecord>? DefinitionContainersByLineAndKind, + IReadOnlyDictionary? HeaderSymbolsByLine) GetPythonSymbolLookups() + { + if (!pythonSymbolLookupsResolved) + { + pythonSymbolLookups = language == "python" + ? BuildPythonSymbolLookups(symbols) + : default; + pythonSymbolLookupsResolved = true; + } + + return pythonSymbolLookups; + } + + internal IReadOnlyDictionary<(int Line, string Kind), SymbolRecord>? GetPythonDefinitionContainersByLineAndKind() => + GetPythonSymbolLookups().DefinitionContainersByLineAndKind; + + internal IReadOnlyDictionary? GetPythonHeaderSymbolsByLine() => + GetPythonSymbolLookups().HeaderSymbolsByLine; + + internal IReadOnlyDictionary> GetPowerShellSplatAssignments() + { + if (!powershellSplatAssignmentsResolved) + { + powershellSplatAssignments = PowerShellReferenceExtractor.BuildSplatAssignments(preparedLines); + powershellSplatAssignmentsResolved = true; + } + + return powershellSplatAssignments!; + } + + internal List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container)> GetRecordPrimaryCtorRanges() + { + if (!recordPrimaryCtorRangesResolved) + { + recordPrimaryCtorRanges = BuildCSharpPrimaryCtorContainers(language, symbols, structuralLines); + recordPrimaryCtorRangesResolved = true; + } + + return recordPrimaryCtorRanges!; + } + + internal ( + IReadOnlyDictionary ByContainingType, + IReadOnlyDictionary> ByFunctionStartLine) GetCSharpValueReceiverLookups() + { + if (!csharpValueReceiverLookupsResolved) + { + csharpValueReceiverLookups = BuildCSharpValueReceiverNameLookups( + language, + symbols, + structuralLines, + csharpKnownTypeNames, + csharpUsingAliases); + csharpValueReceiverLookupsResolved = true; + } + + return csharpValueReceiverLookups!.Value; + } + + internal IReadOnlyDictionary GetCSharpValueReceiverNames() => + GetCSharpValueReceiverLookups().ByContainingType; + + internal IReadOnlyDictionary> GetCSharpFunctionValueReceiverNames() => + GetCSharpValueReceiverLookups().ByFunctionStartLine; + + internal string ResolveCSharpUsingAliasReferenceName(string referenceName, int lineNumber) + { + if (language != "csharp") + return referenceName; + + for (var aliasIndex = csharpUsingAliases.Count - 1; aliasIndex >= 0; aliasIndex--) + { + var alias = csharpUsingAliases[aliasIndex]; + if (alias.Line > lineNumber + || lineNumber < alias.ScopeStartLine + || lineNumber > alias.ScopeEndLine + || !string.Equals(alias.AliasName, referenceName, StringComparison.Ordinal)) + { + continue; + } + + var targetName = GetLastQualifiedSegment(TrimLeadingCSharpGlobalQualifier(alias.TargetQualifiedName)); + return string.IsNullOrWhiteSpace(targetName) ? referenceName : targetName; + } + + return referenceName; + } + + internal void ApplyCSharpUsingAliasReferenceNames(List references) + { + if (language != "csharp") + return; + + var aliasNameChanged = false; + foreach (var reference in references) + { + if (reference.ReferenceKind is not ("instantiate" or "attribute")) + continue; + if (reference.Line <= 0 || reference.Line > lines.Length || reference.Column <= 0) + continue; + if (!IsUnqualifiedCSharpTokenAtColumn(reference.Line, reference.Column, reference.SymbolName)) + continue; + + var resolvedName = ResolveCSharpUsingAliasReferenceName(reference.SymbolName, reference.Line); + if (string.Equals(resolvedName, reference.SymbolName, StringComparison.Ordinal)) + continue; + + reference.SymbolName = resolvedName; + reference.IsSelfReference = IsSameReferenceName(reference.ContainerName, resolvedName); + aliasNameChanged = true; + } + + if (aliasNameChanged) + CompactCSharpUsingAliasReferences(references, language); + } + + bool IsUnqualifiedCSharpTokenAtColumn(int lineNumber, int column, string symbolName) + { + if (lineNumber <= 0 + || lineNumber > lines.Length + || column <= 0 + || string.IsNullOrWhiteSpace(symbolName)) + return false; + + var line = lines[lineNumber - 1]; + var tokenStart = column - 1; + if (tokenStart >= line.Length) + return false; + + var tokenNameStart = tokenStart; + if (line[tokenNameStart] == '@') + tokenNameStart++; + + if (tokenNameStart + symbolName.Length > line.Length) + return false; + if (!line.AsSpan(tokenNameStart, symbolName.Length).Equals(symbolName, StringComparison.Ordinal)) + return false; + + var previousIndex = tokenStart - 1; + var nextIndex = tokenNameStart + symbolName.Length; + var hasQualifiedPrefix = HasCSharpQualifiedSeparatorBeforeToken(line, tokenStart) + || (previousIndex >= 0 && IsCSharpIdentifierPart(line[previousIndex])); + var hasIdentifierSuffix = nextIndex < line.Length && IsCSharpIdentifierPart(line[nextIndex]); + return !hasQualifiedPrefix && !hasIdentifierSuffix; + } + + bool HasActiveCSharpUsingNamespace(string targetQualifiedName, int lineNumber) + { + var normalizedTarget = NormalizeCSharpBclRegexQualifiedName(targetQualifiedName); + for (var importIndex = csharpUsingNamespaces.Count - 1; importIndex >= 0; importIndex--) + { + var import = csharpUsingNamespaces[importIndex]; + if (import.Line > lineNumber + || lineNumber < import.ScopeStartLine + || lineNumber > import.ScopeEndLine) + { + continue; + } + + if (string.Equals(NormalizeCSharpBclRegexQualifiedName(import.TargetQualifiedName), normalizedTarget, StringComparison.Ordinal)) + return true; + } + + return false; + } + + CSharpUsingAliasRecord? FindActiveCSharpUsingAlias(string aliasName, int lineNumber) + { + for (var aliasIndex = csharpUsingAliases.Count - 1; aliasIndex >= 0; aliasIndex--) + { + var alias = csharpUsingAliases[aliasIndex]; + if (alias.Line > lineNumber + || lineNumber < alias.ScopeStartLine + || lineNumber > alias.ScopeEndLine + || !string.Equals(alias.AliasName, aliasName, StringComparison.Ordinal)) + { + continue; + } + + return alias; + } + + return null; + } + + internal void EmitCSharpBclRegexWithoutTimeoutReferences(List references, ReferenceDedupeSet seen) + { + if (language != "csharp") + return; + + var referenceCount = references.Count; + for (var referenceIndex = 0; referenceIndex < referenceCount; referenceIndex++) + { + var reference = references[referenceIndex]; + if (reference.ReferenceKind != "instantiate" + || !string.Equals(reference.SymbolName, "Regex", StringComparison.Ordinal) + || reference.Line <= 0 + || reference.Line > lines.Length + || reference.Column <= 0 + || !IsCSharpBclRegexInstantiateReference(reference) + || !IsCSharpRegexConstructorWithoutTimeout(reference.Line, reference.Column, reference.SymbolName)) + { + continue; + } + + var dedupeKey = CreateReferenceDedupeKey( + reference.FileId, + language, + reference.Line, + reference.Column, + "bcl_regex_without_timeout", + reference.SymbolName, + reference.ContainerKind, + reference.ContainerName); + if (!seen.Add(dedupeKey)) + continue; + + if (!TryAddReference(references, new ReferenceRecord + { + FileId = reference.FileId, + SymbolName = reference.SymbolName, + ReferenceKind = "bcl_regex_without_timeout", + Line = reference.Line, + Column = reference.Column, + Context = reference.Context, + ContainerKind = reference.ContainerKind, + ContainerName = reference.ContainerName, + IsSelfReference = reference.IsSelfReference, + })) + { + return; + } + } + } + + bool IsCSharpBclRegexInstantiateReference(ReferenceRecord reference) + { + var line = lines[reference.Line - 1]; + if (!TryGetCSharpIdentifierAtColumn(line, reference.Column, out _, out _, out var tokenName)) + return false; + + if (TryGetCSharpQualifiedPrefixAtColumn(line, reference.Column, tokenName, out var prefix) + && string.Equals(NormalizeCSharpBclRegexQualifiedName($"{prefix}.{tokenName}"), "System.Text.RegularExpressions.Regex", StringComparison.Ordinal)) + { + return true; + } + + var alias = FindActiveCSharpUsingAlias(tokenName, reference.Line); + if (alias != null) + { + return string.Equals( + NormalizeCSharpBclRegexQualifiedName(alias.TargetQualifiedName), + "System.Text.RegularExpressions.Regex", + StringComparison.Ordinal); + } + + return string.Equals(tokenName, "Regex", StringComparison.Ordinal) + && !HasActiveSameFileCSharpTypeCandidate(tokenName, reference.Line) + && HasActiveCSharpUsingNamespace("System.Text.RegularExpressions", reference.Line); + } + + bool IsCSharpRegexConstructorWithoutTimeout(int lineNumber, int column, string symbolName) + { + var line = lines[lineNumber - 1]; + _ = symbolName; + if (!TryGetCSharpIdentifierAtColumn(line, column, out _, out var tokenNameStart, out var tokenName)) + return false; + + var cursor = tokenNameStart + tokenName.Length; + while (cursor < line.Length && char.IsWhiteSpace(line[cursor])) + cursor++; + if (cursor >= line.Length || line[cursor] != '(') + return false; + + if (!TryCollectCSharpInvocationArguments(lines, lineNumber - 1, cursor, out var args)) + return false; + + var argCount = CountTopLevelCSharpArguments(args.AsSpan(), out var hasNamedMatchTimeout); + return argCount is 1 or 2 && !hasNamedMatchTimeout; + } + } +} From be5152a43eb79382e2d9f59c150e4d4855ed2dff Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 19:59:33 +0900 Subject: [PATCH 064/101] Split full scan support concerns --- ...IndexCommandRunner.FullScan.Checkpoints.cs | 180 ++++ .../IndexCommandRunner.FullScan.Discovery.cs | 128 +++ .../Cli/IndexCommandRunner.FullScan.Errors.cs | 221 ++++ ...ndexCommandRunner.FullScan.Finalization.cs | 198 ++++ ...ndexCommandRunner.FullScan.Interruption.cs | 330 ++++++ .../Cli/IndexCommandRunner.FullScan.cs | 957 ------------------ 6 files changed, 1057 insertions(+), 957 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.Checkpoints.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.Discovery.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.Errors.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.Finalization.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.Interruption.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Checkpoints.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Checkpoints.cs new file mode 100644 index 000000000..3aa4a792c --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Checkpoints.cs @@ -0,0 +1,180 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Runtime.InteropServices; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + internal const int MaxScanCheckpointBytes = 1024 * 1024; + internal const int MaxScanCheckpointJsonDepth = 16; + internal const int MaxScanCheckpointDirectories = 4096; + internal const int MaxScanCheckpointDirectoryLength = 4096; + + internal static IReadOnlySet LoadScanCheckpoint(string path, string? currentHead) => + LoadScanCheckpointDetailed(path, currentHead).Directories; + + internal static ScanCheckpointLoadResult LoadScanCheckpointDetailed(string path, string? currentHead) + { + try + { + if (!File.Exists(path)) + return EmptyScanCheckpointLoadResult(); + if (string.IsNullOrWhiteSpace(currentHead)) + return IgnoredScanCheckpoint(path, "current Git HEAD is unavailable"); + + var text = DataDirectorySecurity.ReadTextWithinLimit(path, MaxScanCheckpointBytes, FileShare.ReadWrite); + if (text is null) + return IgnoredScanCheckpoint(path, $"file exceeds the scan checkpoint size limit of {MaxScanCheckpointBytes:N0} bytes"); + + var checkpoint = BoundedJson.Deserialize( + text, + MaxScanCheckpointBytes, + new JsonSerializerOptions { MaxDepth = MaxScanCheckpointJsonDepth }); + if (checkpoint is null) + return IgnoredScanCheckpoint(path, "JSON root is null or not a scan checkpoint object"); + if (checkpoint.Version != ScanCheckpointVersion) + return IgnoredScanCheckpoint(path, FormatScanCheckpointVersionMismatch(checkpoint.Version)); + if (!string.Equals(checkpoint.GitHead, currentHead, StringComparison.Ordinal)) + return IgnoredScanCheckpoint(path, "checkpoint GitHead does not match current HEAD; checkpoint is stale"); + if (!TryBuildScanCheckpointDirectories(checkpoint.Directories, out var directories, out var directoryFailureReason)) + return IgnoredScanCheckpoint(path, directoryFailureReason); + + return new ScanCheckpointLoadResult(directories, WarningMessage: null); + } + catch (Exception ex) when (ex is JsonException or InvalidDataException) + { + return IgnoredScanCheckpoint( + path, + $"malformed checkpoint JSON, exceeded the JSON byte limit, or depth exceeds {MaxScanCheckpointJsonDepth:N0} ({CommandErrorWriter.FormatSanitizedException(ex)})"); + } + catch (IOException ex) + { + return IgnoredScanCheckpoint(path, $"read failed ({CommandErrorWriter.FormatSanitizedException(ex)})"); + } + catch (UnauthorizedAccessException ex) + { + return IgnoredScanCheckpoint(path, $"read failed ({CommandErrorWriter.FormatSanitizedException(ex)})"); + } + } + + private static string FormatScanCheckpointVersionMismatch(int version) => + version > ScanCheckpointVersion + ? $"future checkpoint version {version:N0} exceeds supported version {ScanCheckpointVersion:N0}" + : $"unsupported checkpoint version {version:N0}; supported version is {ScanCheckpointVersion:N0}"; + + private static ScanCheckpointLoadResult EmptyScanCheckpointLoadResult() => + new(EmptyScanCheckpointDirectories(), WarningMessage: null); + + private static ScanCheckpointLoadResult IgnoredScanCheckpoint(string path, string reason) => + new( + EmptyScanCheckpointDirectories(), + $"scan checkpoint ignored for {ConsoleUi.FormatBoundedValue(path)}: {reason}; continuing with a full scan."); + + private static bool TryBuildScanCheckpointDirectories( + IReadOnlyList? rawDirectories, + out IReadOnlySet directories, + out string failureReason) + { + directories = EmptyScanCheckpointDirectories(); + failureReason = string.Empty; + if (rawDirectories is not { Count: > 0 }) + { + failureReason = "Directories must be a non-empty JSON array"; + return false; + } + if (rawDirectories.Count > MaxScanCheckpointDirectories) + { + failureReason = + $"Directories contains {rawDirectories.Count:N0} entries, exceeding the limit of {MaxScanCheckpointDirectories:N0}"; + return false; + } + + var result = new HashSet(StringComparer.Ordinal); + foreach (var directory in rawDirectories) + { + if (directory is null) + { + failureReason = "Directories contains a null entry"; + return false; + } + if (directory.Length == 0) + continue; + if (directory.Length > MaxScanCheckpointDirectoryLength) + { + failureReason = + $"Directories contains an entry longer than {MaxScanCheckpointDirectoryLength:N0} characters"; + return false; + } + + result.Add(directory); + } + + if (result.Count == 0) + { + failureReason = "Directories contains only empty entries"; + return false; + } + + directories = result; + return true; + } + + private static HashSet EmptyScanCheckpointDirectories() => new(StringComparer.Ordinal); + + private static void DeleteScanCheckpoint( + string path, + List warningList, + bool json, + bool quiet) + { + try + { + if (File.Exists(path)) + { + if (DeleteScanCheckpointForTesting != null) + DeleteScanCheckpointForTesting(path); + else + File.Delete(path); + } + } + catch (Exception ex) when (IsScanCheckpointPersistenceException(ex)) + { + RecordScanCheckpointPersistenceWarning(path, "delete", ex, warningList, json, quiet); + } + } + + private static bool IsScanCheckpointPersistenceException(Exception ex) + => ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or PathTooLongException; + + private static void RecordScanCheckpointPersistenceWarning( + string path, + string operation, + Exception ex, + List warningList, + bool json, + bool quiet) + { + var message = + $"scan checkpoint {operation} failed for {ConsoleUi.FormatBoundedValue(path)} " + + $"({CommandErrorWriter.FormatSanitizedException(ex)}); continuing without failing the scan."; + warningList.Add(new CliJsonMessage("", message)); + if (!json && !quiet) + ConsoleUi.PrintWarning(message); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Discovery.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Discovery.cs new file mode 100644 index 000000000..1c197cddb --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Discovery.cs @@ -0,0 +1,128 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Runtime.InteropServices; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed record FullScanDiscoveryResult( + FileIndexer.ScanFilesResult ScanResult, + IReadOnlyList Files, + List ErrorList, + List WarningList, + string ScanCheckpointPath, + FileIndexer.ScanInputSnapshot? InputSnapshot); + + private static FullScanDiscoveryResult DiscoverFullScanFiles( + FileIndexer indexer, + string projectRoot, + IndexCommandOptions options, + string[] spinnerFrames, + int? initialFileCapacity, + CancellationToken cancellationToken) + { + var actualMode = options.Rebuild ? "rebuild" : "incremental"; + CancellationTokenSource? spinnerCts = null; + if (!options.Json && !options.Quiet) + spinnerCts = ConsoleUi.StartSpinner("Scanning...", spinnerFrames); + + void ThrowIfDiscoveryCancelled() + { + if (!cancellationToken.IsCancellationRequested) + return; + + ConsoleUi.StopSpinner(spinnerCts); + throw new IndexInterruptedException(0, null, actualMode); + } + + var scanCheckpointPath = Path.Combine(projectRoot, ".cdidx", ScanCheckpointFileName); + WriteFullScanJsonLiveness(options, "scanning files..."); + var scanHeartbeat = StartFullScanJsonPhaseHeartbeat(options, "scanning files"); + FileIndexer.ScanFilesResult scanResult; + FileIndexer.ScanInputSnapshot? inputSnapshot = null; + try + { + ThrowIfDiscoveryCancelled(); + var scanWithSnapshots = indexer.ScanFilesDetailedWithDirectoryListingSnapshots( + new HashSet(StringComparer.Ordinal), + continueOnError: true, + initialFileCapacity: initialFileCapacity, + cancellationToken: cancellationToken); + scanResult = scanWithSnapshots.ScanResult; + inputSnapshot = scanWithSnapshots.InputSnapshot; + ThrowIfDiscoveryCancelled(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw new IndexInterruptedException(0, null, actualMode); + } + finally + { + StopFullScanJsonPhaseHeartbeat(scanHeartbeat); + } + var files = scanResult.Files; + ConsoleUi.StopSpinner(spinnerCts); + WriteFullScanJsonLiveness(options, $"found {ConsoleUi.Counted(files.Count, "file", format: "N0")}; preparing database..."); + var errorList = new List(); + var warningList = new List(); + foreach (var error in scanResult.Errors) + { + var message = new CliJsonMessage(error.Path, error.Message); + if (error.IsFatal) + errorList.Add(message); + else + warningList.Add(message); + } + if (!options.Json && !options.Quiet) + { + CommandOutputWriter.WriteLine($" Found {ConsoleUi.Counted(files.Count, "file", format: "N0")}"); + foreach (var error in scanResult.Errors) + ConsoleUi.PrintWarning($"{error.Path}: {error.Message}"); + CommandOutputWriter.WriteLine(); + } + + return new FullScanDiscoveryResult( + scanResult, + files, + errorList, + warningList, + scanCheckpointPath, + inputSnapshot); + } + + private static void WriteFullScanJsonLiveness(IndexCommandOptions options, string message) + { + if (!options.Json || options.Quiet) + return; + + ConsoleUi.TryWriteErrorLine($"cdidx: {message}"); + } + + private static (CancellationTokenSource Cts, Task Task)? StartFullScanJsonPhaseHeartbeat( + IndexCommandOptions options, + string phase, + Func? detailProvider = null) + { + return StartObservedJsonPhaseHeartbeat( + options.Json && !options.Quiet, + "cdidx-index", + phase, + ConsoleUi.TryWriteErrorLine, + detailProvider); + } + + private static void StopFullScanJsonPhaseHeartbeat((CancellationTokenSource Cts, Task Task)? heartbeat) + => StopObservedJsonPhaseHeartbeat(heartbeat); +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Errors.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Errors.cs new file mode 100644 index 000000000..0169b2a9c --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Errors.cs @@ -0,0 +1,221 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Runtime.InteropServices; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + internal static string FormatPerFileErrorLine(string label, string path, Exception ex) => + FormatPerFileErrorLine(label, path, ex, FormatIndexFileException(ex)); + + internal static string FormatPerFileErrorLine(string label, string path, Exception ex, string message) => + $" [{label}] {CollapseLineBreaks(path)}: {CollapseLineBreaks(message)}"; + + internal static void LogIndexFileFailure(string eventName, string path, Exception ex) => + LogIndexFileFailure(eventName, path, phase: null, ex); + + internal static void LogIndexFileFailure(string eventName, string path, string? phase, Exception ex) + { + var phaseSuffix = string.IsNullOrWhiteSpace(phase) ? string.Empty : $" phase={CollapseLineBreaks(phase)}"; + var detail = CollapseLineBreaks(FormatIndexFileException(ex)); + GlobalToolLog.Error($"{eventName} path={CollapseLineBreaks(path)}{phaseSuffix} detail={detail}", ex); + } + + [DoesNotReturn] + internal static void RethrowPreservingStackTrace(Exception ex) => + ExceptionDispatchInfo.Capture(ex).Throw(); + + internal static string FormatIndexFileException(Exception ex) => + ex switch + { + RegexMatchTimeoutException timeoutException => RuntimeSafety.FormatRegexTimeout(timeoutException), + IndexExtractionStalledException stalledException => FormatExtractionStalledMessage(stalledException), + SymbolExtractionWorkerFailureException workerException => + $"Symbol extraction worker failed. Worker diagnostic: {CollapseLineBreaks(workerException.WorkerError)}", + _ => CommandErrorWriter.FormatSanitizedException(ex), + }; + + internal static StatusIndexFileError BuildIndexFileError(string path, string? phase, Exception ex) + { + var stablePhase = string.IsNullOrWhiteSpace(phase) ? "unknown" : phase; + var category = ex switch + { + RegexMatchTimeoutException => "regex_timeout", + IndexExtractionStalledException => "extraction_stalled", + SqliteException => "persistence_error", + IOException or UnauthorizedAccessException when stablePhase is "reading" or "csharp_prepass" => "file_read_error", + _ when stablePhase == "csharp_workspace_validation" => "extraction_error", + _ when stablePhase is "chunking" or "symbols" or "references" or "validating" => "extraction_error", + _ when stablePhase == "committing" => "persistence_error", + _ => "index_file_error", + }; + var (line, column) = ex is JsonException jsonException + ? (jsonException.LineNumber + 1, jsonException.BytePositionInLine + 1) + : ((long?)null, (long?)null); + return new StatusIndexFileError + { + File = FileIndexer.NormalizePathSeparators(path), + Category = category, + Phase = stablePhase, + Detail = ex is SymbolExtractionWorkerFailureException + ? DiagnosticRedactor.BoundDiagnosticText(FormatIndexFileException(ex), maxChars: 512) + : CommandErrorWriter.FormatSanitizedExceptionDetail(ex), + Line = line, + Column = column, + }; + } + + private static string FormatExtractionStalledMessage(IndexExtractionStalledException ex) + { + var pathSuffix = string.IsNullOrWhiteSpace(ex.ActivePath) ? string.Empty : $" Last active phase: {ex.ActivePath}."; + return $"Index extraction made no progress for {ConsoleUi.FormatDuration(ex.Timeout)}.{pathSuffix}{FormatWorkerDiagnosticSuffix(ex.WorkerError)}"; + } + + private static string FormatWorkerDiagnosticSuffix(string? workerError) + => string.IsNullOrWhiteSpace(workerError) + ? string.Empty + : $" Worker diagnostic: {CollapseLineBreaks(workerError)}."; + + private static FileIssue BuildSymbolCountExceededIssue(string path, int symbolCount, int maxSymbolsPerFile) => + new() + { + Path = path, + Kind = "symbol_count_exceeded", + Line = 0, + Message = $"Symbol extraction produced {symbolCount:N0} symbols, exceeding the --max-symbols-per-file limit of {maxSymbolsPerFile:N0}; file content, symbols, and references were not indexed. Exclude the generated/pathological file or raise --max-symbols-per-file if this is expected.", + }; + + private static FileIssue BuildReferenceCountExceededIssue(string path, int referenceCount, int maxReferencesPerFile) => + new() + { + Path = path, + Kind = "reference_count_exceeded", + Line = 0, + Message = $"Reference extraction produced {referenceCount:N0} references, exceeding the --max-references-per-file limit of {maxReferencesPerFile:N0}; references were not indexed for this file. Exclude the generated/pathological file or raise --max-references-per-file if this is expected.", + }; + + internal static IReadOnlyList AppendReferenceExtractionDiagnosticIssues( + IReadOnlyList issues, + string path, + IReadOnlyList diagnostics) + { + foreach (var diagnostic in diagnostics) + { + issues = AppendIssue(issues, new FileIssue + { + Path = path, + Kind = diagnostic.Kind, + Line = 0, + Message = diagnostic.Message, + Severity = FileIssue.SeverityWarning, + }); + } + + return issues; + } + + internal static FileIssue BuildNullByteIssue(FileIndexer.BinaryFileSkippedException ex) => + new() + { + Path = ex.RelativePath, + Kind = "null_byte", + Line = 0, + Message = CommandErrorWriter.FormatSanitizedExceptionMessage(ex), + }; + + internal static FileIssue? BuildRegexTimeoutIssue(string path, BoundedRegex.RegexTimeoutCaptureScope capture) => + BuildRegexTimeoutIssue( + path, + capture.Language, + capture.PatternFamily, + capture.TimeoutCount, + capture.Diagnostics, + capture.DiagnosticsTruncated); + + internal static FileIssue? BuildRegexTimeoutIssue( + string path, + string? language, + string patternFamily, + int timeoutCount, + IReadOnlyList diagnostics, + bool diagnosticsTruncated) + { + if (timeoutCount <= 0) + return null; + + var normalizedLanguage = string.IsNullOrWhiteSpace(language) ? "unknown" : language; + var samples = diagnostics.Count == 0 + ? "none" + : string.Join(", ", diagnostics.Select(static diagnostic => + $"{diagnostic.Operation}:{diagnostic.PatternHash} len={diagnostic.PatternLength} timeout={diagnostic.TimeoutMs:0.###}ms")); + var truncationSuffix = diagnosticsTruncated ? "; additional timeout diagnostics omitted" : string.Empty; + return new FileIssue + { + Path = path, + Kind = "regex_timeout", + Line = 0, + Message = $"Regex timeout fallback occurred during {patternFamily} for language {normalizedLanguage} ({timeoutCount:N0} timeout(s); samples {samples}{truncationSuffix}); extraction used a safe no-match fallback and may be incomplete for this file.", + }; + } + + private static bool ExistingFileBlocksReuse( + DbWriter writer, + long fileId, + int maxSymbolsPerFile, + int maxReferencesPerFile, + FileIssue? generatedSuppressionIssue) => + ExistingFileBlocksReuse( + writer, + fileId, + maxSymbolsPerFile, + maxReferencesPerFile, + generatedSuppressionIssue != null); + + private static bool ExistingFileBlocksReuse( + DbWriter writer, + long fileId, + int maxSymbolsPerFile, + int maxReferencesPerFile, + bool generatedExtractionSuppressed) => + writer.HasReusableFileBlockingIssueForFile( + fileId, + maxSymbolsPerFile, + maxReferencesPerFile, + generatedExtractionSuppressed); + + internal static IReadOnlyList AppendIssue(IReadOnlyList issues, FileIssue issue) + { + if (issues.Count == 0) + return [issue]; + + var combined = issues.ToList(); + combined.Add(issue); + return combined; + } + + internal static IReadOnlyList AppendIssueIfMissing(IReadOnlyList issues, FileIssue issue) + { + for (var i = 0; i < issues.Count; i++) + { + if (string.Equals(issues[i].Kind, issue.Kind, StringComparison.Ordinal)) + return issues; + } + + return AppendIssue(issues, issue); + } + + internal static string FormatIndexPhasePath(string path, string phase) => + $"{path} ({phase})"; +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Finalization.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Finalization.cs new file mode 100644 index 000000000..3f491697d --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Finalization.cs @@ -0,0 +1,198 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Runtime.InteropServices; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private static bool PathsEqual(string? left, string? right) + { + if (left == null || right == null) + return false; + + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals(left, right, comparison); + } + + private static string GetFoldReadyReason(bool backfillReady, bool foldVersionMatchesCurrent, bool foldFingerprintMatchesCurrent) + { + if (!backfillReady) + return DegradationReasonCodes.MissingFoldBackfill; + + if (!foldVersionMatchesCurrent) + return DegradationReasonCodes.StaleFoldKeyVersion; + + if (!foldFingerprintMatchesCurrent) + return DegradationReasonCodes.StaleFoldKeyFingerprint; + + return DegradationReasonCodes.FoldRowsNotRestamped; + } + + private static string BuildFoldNotReadyExplanation(string? foldReadyReason) + => DegradationReasonCodes.BuildFoldNotReadyExplanation(foldReadyReason); + + private static string BuildFoldBackfillCommand(string resolvedDbPath) + => $"cdidx backfill-fold --db {QuoteCommandArgument(resolvedDbPath)}"; + + private static string BuildFoldRebuildCommand(string projectRoot, string resolvedDbPath) + => $"cdidx index {QuoteCommandArgument(projectRoot)} --db {QuoteCommandArgument(resolvedDbPath)} --rebuild"; + + private static IReadOnlyList ReassignChunkFileIds(IReadOnlyList chunks, long fileId) + { + foreach (var chunk in chunks) + chunk.FileId = fileId; + return chunks; + } + + private static IReadOnlyList ReassignSymbolFileIds(IReadOnlyList symbols, long fileId) + { + foreach (var symbol in symbols) + symbol.FileId = fileId; + return symbols; + } + + private static IReadOnlyList ReassignReferenceFileIds(IReadOnlyList references, long fileId) + { + foreach (var reference in references) + reference.FileId = fileId; + return references; + } + + private static IList AsMutableList(IReadOnlyList records) + { + if (records is IList mutable) + return mutable; + + throw new InvalidOperationException("Post-extraction hooks require mutable extraction result lists."); + } + + private static IReadOnlyList RequireWorkItemIssues(FullScanFileWorkItem item) + { + return item.Issues ?? throw new InvalidOperationException("Full-scan work item does not carry precomputed validation issues."); + } + + private static int AddPostExtractionHookWarnings(PostExtractionHookRunner? runner, List warningList) + { + if (runner == null) + return 0; + + var added = 0; + foreach (var diagnostic in runner.Diagnostics) + { + warningList.Add(new CliJsonMessage( + string.IsNullOrWhiteSpace(diagnostic.TypeName) ? diagnostic.AssemblyPath : diagnostic.TypeName, + diagnostic.Message)); + added++; + } + + return added; + } + + private static FoldOnlyRemediation? BuildFoldOnlyReadinessRemediation( + bool graphTableAvailable, + bool issuesTableAvailable, + bool sqlGraphContractReady, + bool hotspotFamilyReady, + bool csharpSymbolNameReady, + bool csharpMetadataTargetReady, + bool foldReady, + string? foldReadyReason, + string projectRoot, + string resolvedDbPath) + { + if (!IsFoldOnlyReadinessDegraded( + graphTableAvailable, + issuesTableAvailable, + sqlGraphContractReady, + hotspotFamilyReady, + csharpSymbolNameReady, + csharpMetadataTargetReady, + foldReady)) + { + return null; + } + + return new FoldOnlyRemediation( + BuildFoldNotReadyExplanation(foldReadyReason), + BuildFoldBackfillCommand(resolvedDbPath), + BuildFoldRebuildCommand(projectRoot, resolvedDbPath)); + } + + private static bool IsFoldOnlyReadinessDegraded( + bool graphTableAvailable, + bool issuesTableAvailable, + bool sqlGraphContractReady, + bool hotspotFamilyReady, + bool csharpSymbolNameReady, + bool csharpMetadataTargetReady, + bool foldReady) + => !foldReady + && graphTableAvailable + && issuesTableAvailable + && sqlGraphContractReady + && hotspotFamilyReady + && csharpSymbolNameReady + && csharpMetadataTargetReady; + + private static string GetIndexReadinessWarning(bool graphTableAvailable, bool issuesTableAvailable, bool sqlGraphContractReady, bool hotspotFamilyReady, bool csharpSymbolNameReady, bool csharpMetadataTargetReady, bool foldReady, string? foldReadyReason, string projectRoot, string resolvedDbPath) + { + var foldOnlyRemediation = BuildFoldOnlyReadinessRemediation( + graphTableAvailable, + issuesTableAvailable, + sqlGraphContractReady, + hotspotFamilyReady, + csharpSymbolNameReady, + csharpMetadataTargetReady, + foldReady, + foldReadyReason, + projectRoot, + resolvedDbPath); + if (foldOnlyRemediation != null) + { + return $"Index completed with fold-only degraded readiness (fold_ready=false). {foldOnlyRemediation.DegradedReason} Run `{foldOnlyRemediation.RecommendedAction}` to restamp folded-name columns in place, or `{foldOnlyRemediation.AlternativeAction}` for a full rebuild."; + } + + var degradedParts = new List(); + if (!graphTableAvailable) + degradedParts.Add(DegradationReasonCodes.GraphTableMissing); + if (!issuesTableAvailable) + degradedParts.Add(DegradationReasonCodes.IssuesTableMissing); + if (!sqlGraphContractReady) + degradedParts.Add(DegradationReasonCodes.SqlGraphContractNotReady); + if (!hotspotFamilyReady) + degradedParts.Add(DegradationReasonCodes.HotspotFamilyNotReady); + if (!csharpSymbolNameReady) + degradedParts.Add(DegradationReasonCodes.CSharpSymbolNameNotReady); + if (!csharpMetadataTargetReady) + degradedParts.Add(DegradationReasonCodes.CSharpMetadataTargetNotReady); + if (!foldReady) + degradedParts.Add(DegradationReasonCodes.FoldReadyNotReady); + + return $"Index completed with degraded readiness ({string.Join(", ", degradedParts)}). Run `cdidx status --db \"{resolvedDbPath}\" --json` to inspect the current DB state."; + } + + private static string QuoteCommandArgument(string value) + { + var fullPath = DbPathResolver.NormalizeDbPath(value); + if (!fullPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + fullPath = Path.GetFullPath(fullPath); + + return fullPath.IndexOfAny([' ', '\t', '"']) >= 0 + ? $"\"{fullPath.Replace("\"", "\\\"", StringComparison.Ordinal)}\"" + : fullPath; + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Interruption.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Interruption.cs new file mode 100644 index 000000000..b77261781 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Interruption.cs @@ -0,0 +1,330 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Runtime.InteropServices; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + internal static string? GetActiveCSharpPrepassPath(string?[] activePaths) + { + for (var index = 0; index < activePaths.Length; index++) + { + var path = Volatile.Read(ref activePaths[index]); + if (path != null) + return path; + } + + return null; + } + + internal static void SetActiveCSharpPrepassPath(string?[] activePaths, int index, string? path) => + Volatile.Write(ref activePaths[index], path); + + private sealed record ActiveExtractionPhase(string Path, string Phase) + { + public string Format() => FormatIndexPhasePath(Path, Phase); + } + + private static IEnumerable FormatActiveExtractionPhases(ActiveExtractionPhase?[] phases) + { + for (var index = 0; index < phases.Length; index++) + { + var phase = Volatile.Read(ref phases[index]); + if (phase != null) + yield return phase.Format(); + } + } + + internal static string? GetJsonIndexHeartbeatPath(string? currentFile, IEnumerable activeExtractionPhases) + { + if (!string.IsNullOrEmpty(currentFile)) + return currentFile; + + return activeExtractionPhases.FirstOrDefault(static phase => !string.IsNullOrEmpty(phase)); + } + + internal static bool TryGetFullScanExtractionStallPath( + int filesProcessed, + int filesTotal, + TimeSpan timeout, + long lastProgressTimestamp, + string? currentFile, + IEnumerable activeExtractionPhases, + out string? activePath) + { + activePath = null; + if (filesTotal <= 0 || filesProcessed >= filesTotal || timeout <= TimeSpan.Zero) + return false; + + if (Stopwatch.GetElapsedTime(lastProgressTimestamp) < timeout) + return false; + + activePath = GetJsonIndexHeartbeatPath(currentFile, activeExtractionPhases); + return true; + } + + private static void ThrowIfFullScanExtractionStalled( + int filesProcessed, + int filesTotal, + TimeSpan timeout, + long lastProgressTimestamp, + string? currentFile, + ActiveExtractionPhase?[] activeExtractionPhases, + Action cancelStalledWork) + { + if (!TryGetFullScanExtractionStallPath( + filesProcessed, + filesTotal, + timeout, + lastProgressTimestamp, + currentFile, + FormatActiveExtractionPhases(activeExtractionPhases), + out var activePath)) + { + return; + } + + cancelStalledWork(); + throw new IndexExtractionStalledException(filesProcessed, filesTotal, timeout, activePath); + } + + private sealed record SymbolExtractionResult(List Symbols, FileIssue? RegexTimeoutIssue); + + private static SymbolExtractionResult ExtractSymbolsWithStallTimeout( + long fileId, + string? lang, + string content, + string filePath, + string projectRoot, + string issuePath, + string phasePath, + bool contentIsNormalized, + bool? hasOversizeLine, + int? conflictMarkerLine, + SymbolExtractionWorkerClient worker, + CancellationToken cancellationToken) + { + var timeout = IndexExtractionStallTimeoutForTesting?.Invoke() ?? IndexExtractionStallTimeout; + if (timeout <= TimeSpan.Zero) + { + using var regexTimeouts = BoundedRegex.CaptureTimeouts(lang, "symbol_extraction"); + var symbols = contentIsNormalized && hasOversizeLine is { } knownHasOversizeLine + ? SymbolExtractor.ExtractNormalized(fileId, lang, content, knownHasOversizeLine, filePath, projectRoot, cancellationToken, conflictMarkerLine, patternConfigsAlreadyLoaded: true) + : SymbolExtractor.ExtractWithPatternConfigsLoaded(fileId, lang, content, filePath, projectRoot, cancellationToken); + return new SymbolExtractionResult(symbols, BuildRegexTimeoutIssue(issuePath, regexTimeouts)); + } + + cancellationToken.ThrowIfCancellationRequested(); + var result = worker.Invoke( + fileId, + lang, + content, + filePath, + projectRoot, + contentIsNormalized, + hasOversizeLine, + conflictMarkerLine, + timeout, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (result.TimedOut) + throw new IndexExtractionStalledException(0, null, timeout, phasePath, result.WorkerError); + if (!result.Success) + throw new SymbolExtractionWorkerFailureException(result.WorkerError ?? "isolated symbol extraction worker failed."); + + var regexTimeoutIssue = BuildRegexTimeoutIssue( + issuePath, + lang, + "symbol_extraction", + result.RegexTimeoutCount, + result.RegexTimeoutDiagnostics ?? [], + result.RegexTimeoutDiagnosticsTruncated); + return new SymbolExtractionResult(result.Symbols ?? [], regexTimeoutIssue); + } + + private static string CollapseLineBreaks(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + if (value.IndexOfAny(['\r', '\n']) < 0) + return value; + var buffer = new System.Text.StringBuilder(value.Length); + foreach (var ch in value) + buffer.Append(ch == '\r' || ch == '\n' ? ' ' : ch); + return buffer.ToString(); + } + + private static int? RejectUnresolvedMergeState( + string projectRoot, + bool json, + JsonSerializerOptions jsonOptions, + CancellationToken cancellationToken) + { + var status = GitHelper.TryGetWorktreeStatus(projectRoot, cancellationToken); + if (status == null || status.UnresolvedMergeFiles.Count == 0) + return null; + + var paths = string.Join(", ", status.UnresolvedMergeFiles.Take(5)); + if (status.UnresolvedMergeFiles.Count > 5) + paths += $", ... {status.UnresolvedMergeFiles.Count - 5:N0} more"; + + return WriteCommandError( + json, + jsonOptions, + $"unresolved merge conflicts detected; refusing to index conflicted files ({paths})", + CommandExitCodes.UsageError, + "Resolve the conflicts and run `git merge --continue`, or abort the merge with `git merge --abort`, then rerun `cdidx index`.", + CommandErrorCodes.UsageError); + } + + private static int WriteCommandError(bool json, JsonSerializerOptions jsonOptions, string message, int exitCode, string? hint = null, string? errorCode = null) + => CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, message, exitCode, hint, errorCode: errorCode); + + private static bool InterruptedProgressIsPersisted(string mode, long filesProcessed) + => string.Equals(mode, "update", StringComparison.Ordinal) && filesProcessed > 0; + + private static string BuildInterruptedRecoveryHint(string mode, bool progressPersisted) + { + if (progressPersisted) + return "Rerun `cdidx index` to finish refreshing the remaining files; completed update-mode file transactions remain in the index. Press Ctrl-C again during a future run to force-exit."; + + if (string.Equals(mode, "update", StringComparison.Ordinal)) + return "Rerun `cdidx index` to retry the update; no update-mode file transaction completed before the interruption. Press Ctrl-C again during a future run to force-exit."; + + return "Rerun `cdidx index` to retry from the previous durable index; interrupted full-scan and rebuild writes are rolled back. Press Ctrl-C again during a future run to force-exit."; + } + + private static int WriteInterruptedResult( + bool json, + JsonSerializerOptions jsonOptions, + int filesProcessed, + int? filesTotal, + string mode, + bool progressPersisted) + { + var totalSuffix = filesTotal is > 0 ? $" of {filesTotal.Value:N0}" : string.Empty; + var progressDescription = progressPersisted + ? "completed update progress was saved" + : string.Equals(mode, "update", StringComparison.Ordinal) + ? "no update progress was saved" + : $"{DescribeInterruptedRollbackMode(mode)} progress was rolled back"; + return WriteCommandError( + json, + jsonOptions, + $"Interrupted; {progressDescription} ({filesProcessed:N0}{totalSuffix} files processed).", + CommandExitCodes.Interrupted, + BuildInterruptedRecoveryHint(mode, progressPersisted), + CommandErrorCodes.Interrupted); + } + + private static string DescribeInterruptedRollbackMode(string mode) + => string.Equals(mode, "rebuild", StringComparison.Ordinal) + ? "rebuild" + : "full-scan"; + + private static int WriteExtractionStalledResult(bool json, JsonSerializerOptions jsonOptions, IndexExtractionStalledException ex) + { + var totalSuffix = ex.FilesTotal is > 0 ? $" of {ex.FilesTotal.Value:N0}" : string.Empty; + var pathSuffix = string.IsNullOrWhiteSpace(ex.ActivePath) ? string.Empty : $" Last active phase: {ex.ActivePath}."; + return WriteCommandError( + json, + jsonOptions, + $"Index extraction made no progress for {ConsoleUi.FormatDuration(ex.Timeout)} ({ex.FilesProcessed:N0}{totalSuffix} files processed).{pathSuffix}{FormatWorkerDiagnosticSuffix(ex.WorkerError)}", + CommandExitCodes.CancelledBySignal, + "Rerun with `--verbose` to inspect progress, lower `--parallelism`, exclude the reported file, or lower `--max-symbols-per-file` to skip pathological symbol output.", + CommandErrorCodes.IndexExtractionStalled); + } + + internal static bool HandleIndexCancelKeyPress(CancellationTokenSource cancellation, ref bool firstCancelHandled) + { + if (!firstCancelHandled && !cancellation.IsCancellationRequested) + { + firstCancelHandled = true; + cancellation.Cancel(); + return true; + } + + return false; + } + + private static IDisposable RegisterIndexCancelKeyPress(CancellationTokenSource cancellation) + { + var firstCancelHandled = false; + ConsoleCancelEventHandler handler = (_, e) => + { + e.Cancel = HandleIndexCancelKeyPress(cancellation, ref firstCancelHandled); + }; + + try + { + Console.CancelKeyPress += handler; + return new CancelKeyPressRegistration(handler); + } + catch (PlatformNotSupportedException) + { + return NullDisposable.Instance; + } + } + + private static IDisposable RegisterIndexTerminateSignal(CancellationTokenSource cancellation) + { + if (OperatingSystem.IsWindows()) + return NullDisposable.Instance; + + try + { + return PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => + { + context.Cancel = true; + cancellation.Cancel(); + }); + } + catch (PlatformNotSupportedException) + { + return NullDisposable.Instance; + } + } + + private static int WriteDatabaseFilesystemError(bool json, JsonSerializerOptions jsonOptions, string dbPath, Exception ex) + { + var transient = ex is SqliteException { SqliteErrorCode: 5 or 6 }; + GlobalToolLog.Error($"index_database_filesystem_error db={CollapseLineBreaks(dbPath)}\n{GlobalToolLog.FormatExceptionChain(ex)}"); + return WriteCommandError( + json, + jsonOptions, + $"database write failed for {dbPath}: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}", + transient ? CommandExitCodes.TransientDatabaseError : CommandExitCodes.DatabaseError, + transient + ? "Another process may be holding the database. Wait for it to finish, or retry with backoff." + : BuildDatabaseFilesystemHint(ex), + transient ? CommandErrorCodes.DbLocked : CommandErrorCodes.DbNotWritable); + } + + private static string BuildDatabaseFilesystemHint(Exception ex) + { + if (ex is SqliteException sqlite && MacProfileDetector.IsPermissionStyleSqliteError(sqlite)) + return MacProfileDetector.BuildDatabaseHint(MacProfileDetector.DetectCurrent()); + + if (ex is UnauthorizedAccessException) + return MacProfileDetector.BuildDatabaseHint(MacProfileDetector.DetectCurrent()); + + return "Check that the database file and parent directory exist and are writable, then retry `cdidx index`."; + } + + private static bool IsDatabaseFilesystemError(Exception ex) => + ex is UnauthorizedAccessException + || ex is IOException + || ex is SqliteException { SqliteErrorCode: 5 or 6 or 8 or 10 or 14 }; +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index e4468f733..c3d0a1c5b 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -21,788 +21,9 @@ public static partial class IndexCommandRunner private const int PartialIndexFileErrorLimit = 50; - internal static string FormatPerFileErrorLine(string label, string path, Exception ex) => - FormatPerFileErrorLine(label, path, ex, FormatIndexFileException(ex)); - internal static string FormatPerFileErrorLine(string label, string path, Exception ex, string message) => - $" [{label}] {CollapseLineBreaks(path)}: {CollapseLineBreaks(message)}"; - internal static void LogIndexFileFailure(string eventName, string path, Exception ex) => - LogIndexFileFailure(eventName, path, phase: null, ex); - internal static void LogIndexFileFailure(string eventName, string path, string? phase, Exception ex) - { - var phaseSuffix = string.IsNullOrWhiteSpace(phase) ? string.Empty : $" phase={CollapseLineBreaks(phase)}"; - var detail = CollapseLineBreaks(FormatIndexFileException(ex)); - GlobalToolLog.Error($"{eventName} path={CollapseLineBreaks(path)}{phaseSuffix} detail={detail}", ex); - } - - [DoesNotReturn] - internal static void RethrowPreservingStackTrace(Exception ex) => - ExceptionDispatchInfo.Capture(ex).Throw(); - - internal static string FormatIndexFileException(Exception ex) => - ex switch - { - RegexMatchTimeoutException timeoutException => RuntimeSafety.FormatRegexTimeout(timeoutException), - IndexExtractionStalledException stalledException => FormatExtractionStalledMessage(stalledException), - SymbolExtractionWorkerFailureException workerException => - $"Symbol extraction worker failed. Worker diagnostic: {CollapseLineBreaks(workerException.WorkerError)}", - _ => CommandErrorWriter.FormatSanitizedException(ex), - }; - - internal static StatusIndexFileError BuildIndexFileError(string path, string? phase, Exception ex) - { - var stablePhase = string.IsNullOrWhiteSpace(phase) ? "unknown" : phase; - var category = ex switch - { - RegexMatchTimeoutException => "regex_timeout", - IndexExtractionStalledException => "extraction_stalled", - SqliteException => "persistence_error", - IOException or UnauthorizedAccessException when stablePhase is "reading" or "csharp_prepass" => "file_read_error", - _ when stablePhase == "csharp_workspace_validation" => "extraction_error", - _ when stablePhase is "chunking" or "symbols" or "references" or "validating" => "extraction_error", - _ when stablePhase == "committing" => "persistence_error", - _ => "index_file_error", - }; - var (line, column) = ex is JsonException jsonException - ? (jsonException.LineNumber + 1, jsonException.BytePositionInLine + 1) - : ((long?)null, (long?)null); - return new StatusIndexFileError - { - File = FileIndexer.NormalizePathSeparators(path), - Category = category, - Phase = stablePhase, - Detail = ex is SymbolExtractionWorkerFailureException - ? DiagnosticRedactor.BoundDiagnosticText(FormatIndexFileException(ex), maxChars: 512) - : CommandErrorWriter.FormatSanitizedExceptionDetail(ex), - Line = line, - Column = column, - }; - } - - private static string FormatExtractionStalledMessage(IndexExtractionStalledException ex) - { - var pathSuffix = string.IsNullOrWhiteSpace(ex.ActivePath) ? string.Empty : $" Last active phase: {ex.ActivePath}."; - return $"Index extraction made no progress for {ConsoleUi.FormatDuration(ex.Timeout)}.{pathSuffix}{FormatWorkerDiagnosticSuffix(ex.WorkerError)}"; - } - - private static string FormatWorkerDiagnosticSuffix(string? workerError) - => string.IsNullOrWhiteSpace(workerError) - ? string.Empty - : $" Worker diagnostic: {CollapseLineBreaks(workerError)}."; - - private static FileIssue BuildSymbolCountExceededIssue(string path, int symbolCount, int maxSymbolsPerFile) => - new() - { - Path = path, - Kind = "symbol_count_exceeded", - Line = 0, - Message = $"Symbol extraction produced {symbolCount:N0} symbols, exceeding the --max-symbols-per-file limit of {maxSymbolsPerFile:N0}; file content, symbols, and references were not indexed. Exclude the generated/pathological file or raise --max-symbols-per-file if this is expected.", - }; - - private static FileIssue BuildReferenceCountExceededIssue(string path, int referenceCount, int maxReferencesPerFile) => - new() - { - Path = path, - Kind = "reference_count_exceeded", - Line = 0, - Message = $"Reference extraction produced {referenceCount:N0} references, exceeding the --max-references-per-file limit of {maxReferencesPerFile:N0}; references were not indexed for this file. Exclude the generated/pathological file or raise --max-references-per-file if this is expected.", - }; - - internal static IReadOnlyList AppendReferenceExtractionDiagnosticIssues( - IReadOnlyList issues, - string path, - IReadOnlyList diagnostics) - { - foreach (var diagnostic in diagnostics) - { - issues = AppendIssue(issues, new FileIssue - { - Path = path, - Kind = diagnostic.Kind, - Line = 0, - Message = diagnostic.Message, - Severity = FileIssue.SeverityWarning, - }); - } - - return issues; - } - - internal static FileIssue BuildNullByteIssue(FileIndexer.BinaryFileSkippedException ex) => - new() - { - Path = ex.RelativePath, - Kind = "null_byte", - Line = 0, - Message = CommandErrorWriter.FormatSanitizedExceptionMessage(ex), - }; - - internal static FileIssue? BuildRegexTimeoutIssue(string path, BoundedRegex.RegexTimeoutCaptureScope capture) => - BuildRegexTimeoutIssue( - path, - capture.Language, - capture.PatternFamily, - capture.TimeoutCount, - capture.Diagnostics, - capture.DiagnosticsTruncated); - - internal static FileIssue? BuildRegexTimeoutIssue( - string path, - string? language, - string patternFamily, - int timeoutCount, - IReadOnlyList diagnostics, - bool diagnosticsTruncated) - { - if (timeoutCount <= 0) - return null; - - var normalizedLanguage = string.IsNullOrWhiteSpace(language) ? "unknown" : language; - var samples = diagnostics.Count == 0 - ? "none" - : string.Join(", ", diagnostics.Select(static diagnostic => - $"{diagnostic.Operation}:{diagnostic.PatternHash} len={diagnostic.PatternLength} timeout={diagnostic.TimeoutMs:0.###}ms")); - var truncationSuffix = diagnosticsTruncated ? "; additional timeout diagnostics omitted" : string.Empty; - return new FileIssue - { - Path = path, - Kind = "regex_timeout", - Line = 0, - Message = $"Regex timeout fallback occurred during {patternFamily} for language {normalizedLanguage} ({timeoutCount:N0} timeout(s); samples {samples}{truncationSuffix}); extraction used a safe no-match fallback and may be incomplete for this file.", - }; - } - - private static bool ExistingFileBlocksReuse( - DbWriter writer, - long fileId, - int maxSymbolsPerFile, - int maxReferencesPerFile, - FileIssue? generatedSuppressionIssue) => - ExistingFileBlocksReuse( - writer, - fileId, - maxSymbolsPerFile, - maxReferencesPerFile, - generatedSuppressionIssue != null); - - private static bool ExistingFileBlocksReuse( - DbWriter writer, - long fileId, - int maxSymbolsPerFile, - int maxReferencesPerFile, - bool generatedExtractionSuppressed) => - writer.HasReusableFileBlockingIssueForFile( - fileId, - maxSymbolsPerFile, - maxReferencesPerFile, - generatedExtractionSuppressed); - - internal static IReadOnlyList AppendIssue(IReadOnlyList issues, FileIssue issue) - { - if (issues.Count == 0) - return [issue]; - - var combined = issues.ToList(); - combined.Add(issue); - return combined; - } - - internal static IReadOnlyList AppendIssueIfMissing(IReadOnlyList issues, FileIssue issue) - { - for (var i = 0; i < issues.Count; i++) - { - if (string.Equals(issues[i].Kind, issue.Kind, StringComparison.Ordinal)) - return issues; - } - - return AppendIssue(issues, issue); - } - - internal static string FormatIndexPhasePath(string path, string phase) => - $"{path} ({phase})"; - - internal static string? GetActiveCSharpPrepassPath(string?[] activePaths) - { - for (var index = 0; index < activePaths.Length; index++) - { - var path = Volatile.Read(ref activePaths[index]); - if (path != null) - return path; - } - - return null; - } - - internal static void SetActiveCSharpPrepassPath(string?[] activePaths, int index, string? path) => - Volatile.Write(ref activePaths[index], path); - - private sealed record ActiveExtractionPhase(string Path, string Phase) - { - public string Format() => FormatIndexPhasePath(Path, Phase); - } - - private static IEnumerable FormatActiveExtractionPhases(ActiveExtractionPhase?[] phases) - { - for (var index = 0; index < phases.Length; index++) - { - var phase = Volatile.Read(ref phases[index]); - if (phase != null) - yield return phase.Format(); - } - } - - internal static string? GetJsonIndexHeartbeatPath(string? currentFile, IEnumerable activeExtractionPhases) - { - if (!string.IsNullOrEmpty(currentFile)) - return currentFile; - - return activeExtractionPhases.FirstOrDefault(static phase => !string.IsNullOrEmpty(phase)); - } - - internal static bool TryGetFullScanExtractionStallPath( - int filesProcessed, - int filesTotal, - TimeSpan timeout, - long lastProgressTimestamp, - string? currentFile, - IEnumerable activeExtractionPhases, - out string? activePath) - { - activePath = null; - if (filesTotal <= 0 || filesProcessed >= filesTotal || timeout <= TimeSpan.Zero) - return false; - - if (Stopwatch.GetElapsedTime(lastProgressTimestamp) < timeout) - return false; - - activePath = GetJsonIndexHeartbeatPath(currentFile, activeExtractionPhases); - return true; - } - - private static void ThrowIfFullScanExtractionStalled( - int filesProcessed, - int filesTotal, - TimeSpan timeout, - long lastProgressTimestamp, - string? currentFile, - ActiveExtractionPhase?[] activeExtractionPhases, - Action cancelStalledWork) - { - if (!TryGetFullScanExtractionStallPath( - filesProcessed, - filesTotal, - timeout, - lastProgressTimestamp, - currentFile, - FormatActiveExtractionPhases(activeExtractionPhases), - out var activePath)) - { - return; - } - - cancelStalledWork(); - throw new IndexExtractionStalledException(filesProcessed, filesTotal, timeout, activePath); - } - - private sealed record SymbolExtractionResult(List Symbols, FileIssue? RegexTimeoutIssue); - - private static SymbolExtractionResult ExtractSymbolsWithStallTimeout( - long fileId, - string? lang, - string content, - string filePath, - string projectRoot, - string issuePath, - string phasePath, - bool contentIsNormalized, - bool? hasOversizeLine, - int? conflictMarkerLine, - SymbolExtractionWorkerClient worker, - CancellationToken cancellationToken) - { - var timeout = IndexExtractionStallTimeoutForTesting?.Invoke() ?? IndexExtractionStallTimeout; - if (timeout <= TimeSpan.Zero) - { - using var regexTimeouts = BoundedRegex.CaptureTimeouts(lang, "symbol_extraction"); - var symbols = contentIsNormalized && hasOversizeLine is { } knownHasOversizeLine - ? SymbolExtractor.ExtractNormalized(fileId, lang, content, knownHasOversizeLine, filePath, projectRoot, cancellationToken, conflictMarkerLine, patternConfigsAlreadyLoaded: true) - : SymbolExtractor.ExtractWithPatternConfigsLoaded(fileId, lang, content, filePath, projectRoot, cancellationToken); - return new SymbolExtractionResult(symbols, BuildRegexTimeoutIssue(issuePath, regexTimeouts)); - } - - cancellationToken.ThrowIfCancellationRequested(); - var result = worker.Invoke( - fileId, - lang, - content, - filePath, - projectRoot, - contentIsNormalized, - hasOversizeLine, - conflictMarkerLine, - timeout, - cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - if (result.TimedOut) - throw new IndexExtractionStalledException(0, null, timeout, phasePath, result.WorkerError); - if (!result.Success) - throw new SymbolExtractionWorkerFailureException(result.WorkerError ?? "isolated symbol extraction worker failed."); - - var regexTimeoutIssue = BuildRegexTimeoutIssue( - issuePath, - lang, - "symbol_extraction", - result.RegexTimeoutCount, - result.RegexTimeoutDiagnostics ?? [], - result.RegexTimeoutDiagnosticsTruncated); - return new SymbolExtractionResult(result.Symbols ?? [], regexTimeoutIssue); - } - - private static string CollapseLineBreaks(string value) - { - if (string.IsNullOrEmpty(value)) - return value; - if (value.IndexOfAny(['\r', '\n']) < 0) - return value; - var buffer = new System.Text.StringBuilder(value.Length); - foreach (var ch in value) - buffer.Append(ch == '\r' || ch == '\n' ? ' ' : ch); - return buffer.ToString(); - } - - private static int? RejectUnresolvedMergeState( - string projectRoot, - bool json, - JsonSerializerOptions jsonOptions, - CancellationToken cancellationToken) - { - var status = GitHelper.TryGetWorktreeStatus(projectRoot, cancellationToken); - if (status == null || status.UnresolvedMergeFiles.Count == 0) - return null; - - var paths = string.Join(", ", status.UnresolvedMergeFiles.Take(5)); - if (status.UnresolvedMergeFiles.Count > 5) - paths += $", ... {status.UnresolvedMergeFiles.Count - 5:N0} more"; - - return WriteCommandError( - json, - jsonOptions, - $"unresolved merge conflicts detected; refusing to index conflicted files ({paths})", - CommandExitCodes.UsageError, - "Resolve the conflicts and run `git merge --continue`, or abort the merge with `git merge --abort`, then rerun `cdidx index`.", - CommandErrorCodes.UsageError); - } - - private static int WriteCommandError(bool json, JsonSerializerOptions jsonOptions, string message, int exitCode, string? hint = null, string? errorCode = null) - => CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, message, exitCode, hint, errorCode: errorCode); - - private static bool InterruptedProgressIsPersisted(string mode, long filesProcessed) - => string.Equals(mode, "update", StringComparison.Ordinal) && filesProcessed > 0; - - private static string BuildInterruptedRecoveryHint(string mode, bool progressPersisted) - { - if (progressPersisted) - return "Rerun `cdidx index` to finish refreshing the remaining files; completed update-mode file transactions remain in the index. Press Ctrl-C again during a future run to force-exit."; - - if (string.Equals(mode, "update", StringComparison.Ordinal)) - return "Rerun `cdidx index` to retry the update; no update-mode file transaction completed before the interruption. Press Ctrl-C again during a future run to force-exit."; - - return "Rerun `cdidx index` to retry from the previous durable index; interrupted full-scan and rebuild writes are rolled back. Press Ctrl-C again during a future run to force-exit."; - } - - private static int WriteInterruptedResult( - bool json, - JsonSerializerOptions jsonOptions, - int filesProcessed, - int? filesTotal, - string mode, - bool progressPersisted) - { - var totalSuffix = filesTotal is > 0 ? $" of {filesTotal.Value:N0}" : string.Empty; - var progressDescription = progressPersisted - ? "completed update progress was saved" - : string.Equals(mode, "update", StringComparison.Ordinal) - ? "no update progress was saved" - : $"{DescribeInterruptedRollbackMode(mode)} progress was rolled back"; - return WriteCommandError( - json, - jsonOptions, - $"Interrupted; {progressDescription} ({filesProcessed:N0}{totalSuffix} files processed).", - CommandExitCodes.Interrupted, - BuildInterruptedRecoveryHint(mode, progressPersisted), - CommandErrorCodes.Interrupted); - } - - private static string DescribeInterruptedRollbackMode(string mode) - => string.Equals(mode, "rebuild", StringComparison.Ordinal) - ? "rebuild" - : "full-scan"; - - private static int WriteExtractionStalledResult(bool json, JsonSerializerOptions jsonOptions, IndexExtractionStalledException ex) - { - var totalSuffix = ex.FilesTotal is > 0 ? $" of {ex.FilesTotal.Value:N0}" : string.Empty; - var pathSuffix = string.IsNullOrWhiteSpace(ex.ActivePath) ? string.Empty : $" Last active phase: {ex.ActivePath}."; - return WriteCommandError( - json, - jsonOptions, - $"Index extraction made no progress for {ConsoleUi.FormatDuration(ex.Timeout)} ({ex.FilesProcessed:N0}{totalSuffix} files processed).{pathSuffix}{FormatWorkerDiagnosticSuffix(ex.WorkerError)}", - CommandExitCodes.CancelledBySignal, - "Rerun with `--verbose` to inspect progress, lower `--parallelism`, exclude the reported file, or lower `--max-symbols-per-file` to skip pathological symbol output.", - CommandErrorCodes.IndexExtractionStalled); - } - - internal static bool HandleIndexCancelKeyPress(CancellationTokenSource cancellation, ref bool firstCancelHandled) - { - if (!firstCancelHandled && !cancellation.IsCancellationRequested) - { - firstCancelHandled = true; - cancellation.Cancel(); - return true; - } - - return false; - } - - private static IDisposable RegisterIndexCancelKeyPress(CancellationTokenSource cancellation) - { - var firstCancelHandled = false; - ConsoleCancelEventHandler handler = (_, e) => - { - e.Cancel = HandleIndexCancelKeyPress(cancellation, ref firstCancelHandled); - }; - - try - { - Console.CancelKeyPress += handler; - return new CancelKeyPressRegistration(handler); - } - catch (PlatformNotSupportedException) - { - return NullDisposable.Instance; - } - } - - private static IDisposable RegisterIndexTerminateSignal(CancellationTokenSource cancellation) - { - if (OperatingSystem.IsWindows()) - return NullDisposable.Instance; - - try - { - return PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => - { - context.Cancel = true; - cancellation.Cancel(); - }); - } - catch (PlatformNotSupportedException) - { - return NullDisposable.Instance; - } - } - - private static int WriteDatabaseFilesystemError(bool json, JsonSerializerOptions jsonOptions, string dbPath, Exception ex) - { - var transient = ex is SqliteException { SqliteErrorCode: 5 or 6 }; - GlobalToolLog.Error($"index_database_filesystem_error db={CollapseLineBreaks(dbPath)}\n{GlobalToolLog.FormatExceptionChain(ex)}"); - return WriteCommandError( - json, - jsonOptions, - $"database write failed for {dbPath}: {CommandErrorWriter.FormatSanitizedExceptionMessage(ex)}", - transient ? CommandExitCodes.TransientDatabaseError : CommandExitCodes.DatabaseError, - transient - ? "Another process may be holding the database. Wait for it to finish, or retry with backoff." - : BuildDatabaseFilesystemHint(ex), - transient ? CommandErrorCodes.DbLocked : CommandErrorCodes.DbNotWritable); - } - - private static string BuildDatabaseFilesystemHint(Exception ex) - { - if (ex is SqliteException sqlite && MacProfileDetector.IsPermissionStyleSqliteError(sqlite)) - return MacProfileDetector.BuildDatabaseHint(MacProfileDetector.DetectCurrent()); - - if (ex is UnauthorizedAccessException) - return MacProfileDetector.BuildDatabaseHint(MacProfileDetector.DetectCurrent()); - - return "Check that the database file and parent directory exist and are writable, then retry `cdidx index`."; - } - - private static bool IsDatabaseFilesystemError(Exception ex) => - ex is UnauthorizedAccessException - || ex is IOException - || ex is SqliteException { SqliteErrorCode: 5 or 6 or 8 or 10 or 14 }; - - internal const int MaxScanCheckpointBytes = 1024 * 1024; - internal const int MaxScanCheckpointJsonDepth = 16; - internal const int MaxScanCheckpointDirectories = 4096; - internal const int MaxScanCheckpointDirectoryLength = 4096; - - internal static IReadOnlySet LoadScanCheckpoint(string path, string? currentHead) => - LoadScanCheckpointDetailed(path, currentHead).Directories; - - internal static ScanCheckpointLoadResult LoadScanCheckpointDetailed(string path, string? currentHead) - { - try - { - if (!File.Exists(path)) - return EmptyScanCheckpointLoadResult(); - if (string.IsNullOrWhiteSpace(currentHead)) - return IgnoredScanCheckpoint(path, "current Git HEAD is unavailable"); - - var text = DataDirectorySecurity.ReadTextWithinLimit(path, MaxScanCheckpointBytes, FileShare.ReadWrite); - if (text is null) - return IgnoredScanCheckpoint(path, $"file exceeds the scan checkpoint size limit of {MaxScanCheckpointBytes:N0} bytes"); - - var checkpoint = BoundedJson.Deserialize( - text, - MaxScanCheckpointBytes, - new JsonSerializerOptions { MaxDepth = MaxScanCheckpointJsonDepth }); - if (checkpoint is null) - return IgnoredScanCheckpoint(path, "JSON root is null or not a scan checkpoint object"); - if (checkpoint.Version != ScanCheckpointVersion) - return IgnoredScanCheckpoint(path, FormatScanCheckpointVersionMismatch(checkpoint.Version)); - if (!string.Equals(checkpoint.GitHead, currentHead, StringComparison.Ordinal)) - return IgnoredScanCheckpoint(path, "checkpoint GitHead does not match current HEAD; checkpoint is stale"); - if (!TryBuildScanCheckpointDirectories(checkpoint.Directories, out var directories, out var directoryFailureReason)) - return IgnoredScanCheckpoint(path, directoryFailureReason); - - return new ScanCheckpointLoadResult(directories, WarningMessage: null); - } - catch (Exception ex) when (ex is JsonException or InvalidDataException) - { - return IgnoredScanCheckpoint( - path, - $"malformed checkpoint JSON, exceeded the JSON byte limit, or depth exceeds {MaxScanCheckpointJsonDepth:N0} ({CommandErrorWriter.FormatSanitizedException(ex)})"); - } - catch (IOException ex) - { - return IgnoredScanCheckpoint(path, $"read failed ({CommandErrorWriter.FormatSanitizedException(ex)})"); - } - catch (UnauthorizedAccessException ex) - { - return IgnoredScanCheckpoint(path, $"read failed ({CommandErrorWriter.FormatSanitizedException(ex)})"); - } - } - - private static string FormatScanCheckpointVersionMismatch(int version) => - version > ScanCheckpointVersion - ? $"future checkpoint version {version:N0} exceeds supported version {ScanCheckpointVersion:N0}" - : $"unsupported checkpoint version {version:N0}; supported version is {ScanCheckpointVersion:N0}"; - - private static ScanCheckpointLoadResult EmptyScanCheckpointLoadResult() => - new(EmptyScanCheckpointDirectories(), WarningMessage: null); - - private static ScanCheckpointLoadResult IgnoredScanCheckpoint(string path, string reason) => - new( - EmptyScanCheckpointDirectories(), - $"scan checkpoint ignored for {ConsoleUi.FormatBoundedValue(path)}: {reason}; continuing with a full scan."); - - private static bool TryBuildScanCheckpointDirectories( - IReadOnlyList? rawDirectories, - out IReadOnlySet directories, - out string failureReason) - { - directories = EmptyScanCheckpointDirectories(); - failureReason = string.Empty; - if (rawDirectories is not { Count: > 0 }) - { - failureReason = "Directories must be a non-empty JSON array"; - return false; - } - if (rawDirectories.Count > MaxScanCheckpointDirectories) - { - failureReason = - $"Directories contains {rawDirectories.Count:N0} entries, exceeding the limit of {MaxScanCheckpointDirectories:N0}"; - return false; - } - - var result = new HashSet(StringComparer.Ordinal); - foreach (var directory in rawDirectories) - { - if (directory is null) - { - failureReason = "Directories contains a null entry"; - return false; - } - if (directory.Length == 0) - continue; - if (directory.Length > MaxScanCheckpointDirectoryLength) - { - failureReason = - $"Directories contains an entry longer than {MaxScanCheckpointDirectoryLength:N0} characters"; - return false; - } - - result.Add(directory); - } - - if (result.Count == 0) - { - failureReason = "Directories contains only empty entries"; - return false; - } - - directories = result; - return true; - } - - private static HashSet EmptyScanCheckpointDirectories() => new(StringComparer.Ordinal); - - private static void DeleteScanCheckpoint( - string path, - List warningList, - bool json, - bool quiet) - { - try - { - if (File.Exists(path)) - { - if (DeleteScanCheckpointForTesting != null) - DeleteScanCheckpointForTesting(path); - else - File.Delete(path); - } - } - catch (Exception ex) when (IsScanCheckpointPersistenceException(ex)) - { - RecordScanCheckpointPersistenceWarning(path, "delete", ex, warningList, json, quiet); - } - } - - private static bool IsScanCheckpointPersistenceException(Exception ex) - => ex is IOException - or UnauthorizedAccessException - or ArgumentException - or NotSupportedException - or PathTooLongException; - - private static void RecordScanCheckpointPersistenceWarning( - string path, - string operation, - Exception ex, - List warningList, - bool json, - bool quiet) - { - var message = - $"scan checkpoint {operation} failed for {ConsoleUi.FormatBoundedValue(path)} " + - $"({CommandErrorWriter.FormatSanitizedException(ex)}); continuing without failing the scan."; - warningList.Add(new CliJsonMessage("", message)); - if (!json && !quiet) - ConsoleUi.PrintWarning(message); - } - - private sealed record FullScanDiscoveryResult( - FileIndexer.ScanFilesResult ScanResult, - IReadOnlyList Files, - List ErrorList, - List WarningList, - string ScanCheckpointPath, - FileIndexer.ScanInputSnapshot? InputSnapshot); - - private static FullScanDiscoveryResult DiscoverFullScanFiles( - FileIndexer indexer, - string projectRoot, - IndexCommandOptions options, - string[] spinnerFrames, - int? initialFileCapacity, - CancellationToken cancellationToken) - { - var actualMode = options.Rebuild ? "rebuild" : "incremental"; - CancellationTokenSource? spinnerCts = null; - if (!options.Json && !options.Quiet) - spinnerCts = ConsoleUi.StartSpinner("Scanning...", spinnerFrames); - - void ThrowIfDiscoveryCancelled() - { - if (!cancellationToken.IsCancellationRequested) - return; - - ConsoleUi.StopSpinner(spinnerCts); - throw new IndexInterruptedException(0, null, actualMode); - } - - var scanCheckpointPath = Path.Combine(projectRoot, ".cdidx", ScanCheckpointFileName); - WriteFullScanJsonLiveness(options, "scanning files..."); - var scanHeartbeat = StartFullScanJsonPhaseHeartbeat(options, "scanning files"); - FileIndexer.ScanFilesResult scanResult; - FileIndexer.ScanInputSnapshot? inputSnapshot = null; - try - { - ThrowIfDiscoveryCancelled(); - var scanWithSnapshots = indexer.ScanFilesDetailedWithDirectoryListingSnapshots( - new HashSet(StringComparer.Ordinal), - continueOnError: true, - initialFileCapacity: initialFileCapacity, - cancellationToken: cancellationToken); - scanResult = scanWithSnapshots.ScanResult; - inputSnapshot = scanWithSnapshots.InputSnapshot; - ThrowIfDiscoveryCancelled(); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw new IndexInterruptedException(0, null, actualMode); - } - finally - { - StopFullScanJsonPhaseHeartbeat(scanHeartbeat); - } - var files = scanResult.Files; - ConsoleUi.StopSpinner(spinnerCts); - WriteFullScanJsonLiveness(options, $"found {ConsoleUi.Counted(files.Count, "file", format: "N0")}; preparing database..."); - var errorList = new List(); - var warningList = new List(); - foreach (var error in scanResult.Errors) - { - var message = new CliJsonMessage(error.Path, error.Message); - if (error.IsFatal) - errorList.Add(message); - else - warningList.Add(message); - } - if (!options.Json && !options.Quiet) - { - CommandOutputWriter.WriteLine($" Found {ConsoleUi.Counted(files.Count, "file", format: "N0")}"); - foreach (var error in scanResult.Errors) - ConsoleUi.PrintWarning($"{error.Path}: {error.Message}"); - CommandOutputWriter.WriteLine(); - } - - return new FullScanDiscoveryResult( - scanResult, - files, - errorList, - warningList, - scanCheckpointPath, - inputSnapshot); - } - - private static void WriteFullScanJsonLiveness(IndexCommandOptions options, string message) - { - if (!options.Json || options.Quiet) - return; - - ConsoleUi.TryWriteErrorLine($"cdidx: {message}"); - } - - private static (CancellationTokenSource Cts, Task Task)? StartFullScanJsonPhaseHeartbeat( - IndexCommandOptions options, - string phase, - Func? detailProvider = null) - { - return StartObservedJsonPhaseHeartbeat( - options.Json && !options.Quiet, - "cdidx-index", - phase, - ConsoleUi.TryWriteErrorLine, - detailProvider); - } - - private static void StopFullScanJsonPhaseHeartbeat((CancellationTokenSource Cts, Task Task)? heartbeat) - => StopObservedJsonPhaseHeartbeat(heartbeat); private static int RunFullScan( DbContext db, @@ -3824,183 +3045,5 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) : CommandExitCodes.Success; } - private static bool PathsEqual(string? left, string? right) - { - if (left == null || right == null) - return false; - - var comparison = OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - return string.Equals(left, right, comparison); - } - - private static string GetFoldReadyReason(bool backfillReady, bool foldVersionMatchesCurrent, bool foldFingerprintMatchesCurrent) - { - if (!backfillReady) - return DegradationReasonCodes.MissingFoldBackfill; - - if (!foldVersionMatchesCurrent) - return DegradationReasonCodes.StaleFoldKeyVersion; - - if (!foldFingerprintMatchesCurrent) - return DegradationReasonCodes.StaleFoldKeyFingerprint; - - return DegradationReasonCodes.FoldRowsNotRestamped; - } - - private static string BuildFoldNotReadyExplanation(string? foldReadyReason) - => DegradationReasonCodes.BuildFoldNotReadyExplanation(foldReadyReason); - - private static string BuildFoldBackfillCommand(string resolvedDbPath) - => $"cdidx backfill-fold --db {QuoteCommandArgument(resolvedDbPath)}"; - - private static string BuildFoldRebuildCommand(string projectRoot, string resolvedDbPath) - => $"cdidx index {QuoteCommandArgument(projectRoot)} --db {QuoteCommandArgument(resolvedDbPath)} --rebuild"; - - private static IReadOnlyList ReassignChunkFileIds(IReadOnlyList chunks, long fileId) - { - foreach (var chunk in chunks) - chunk.FileId = fileId; - return chunks; - } - - private static IReadOnlyList ReassignSymbolFileIds(IReadOnlyList symbols, long fileId) - { - foreach (var symbol in symbols) - symbol.FileId = fileId; - return symbols; - } - - private static IReadOnlyList ReassignReferenceFileIds(IReadOnlyList references, long fileId) - { - foreach (var reference in references) - reference.FileId = fileId; - return references; - } - - private static IList AsMutableList(IReadOnlyList records) - { - if (records is IList mutable) - return mutable; - - throw new InvalidOperationException("Post-extraction hooks require mutable extraction result lists."); - } - - private static IReadOnlyList RequireWorkItemIssues(FullScanFileWorkItem item) - { - return item.Issues ?? throw new InvalidOperationException("Full-scan work item does not carry precomputed validation issues."); - } - - private static int AddPostExtractionHookWarnings(PostExtractionHookRunner? runner, List warningList) - { - if (runner == null) - return 0; - - var added = 0; - foreach (var diagnostic in runner.Diagnostics) - { - warningList.Add(new CliJsonMessage( - string.IsNullOrWhiteSpace(diagnostic.TypeName) ? diagnostic.AssemblyPath : diagnostic.TypeName, - diagnostic.Message)); - added++; - } - - return added; - } - - private static FoldOnlyRemediation? BuildFoldOnlyReadinessRemediation( - bool graphTableAvailable, - bool issuesTableAvailable, - bool sqlGraphContractReady, - bool hotspotFamilyReady, - bool csharpSymbolNameReady, - bool csharpMetadataTargetReady, - bool foldReady, - string? foldReadyReason, - string projectRoot, - string resolvedDbPath) - { - if (!IsFoldOnlyReadinessDegraded( - graphTableAvailable, - issuesTableAvailable, - sqlGraphContractReady, - hotspotFamilyReady, - csharpSymbolNameReady, - csharpMetadataTargetReady, - foldReady)) - { - return null; - } - - return new FoldOnlyRemediation( - BuildFoldNotReadyExplanation(foldReadyReason), - BuildFoldBackfillCommand(resolvedDbPath), - BuildFoldRebuildCommand(projectRoot, resolvedDbPath)); - } - - private static bool IsFoldOnlyReadinessDegraded( - bool graphTableAvailable, - bool issuesTableAvailable, - bool sqlGraphContractReady, - bool hotspotFamilyReady, - bool csharpSymbolNameReady, - bool csharpMetadataTargetReady, - bool foldReady) - => !foldReady - && graphTableAvailable - && issuesTableAvailable - && sqlGraphContractReady - && hotspotFamilyReady - && csharpSymbolNameReady - && csharpMetadataTargetReady; - - private static string GetIndexReadinessWarning(bool graphTableAvailable, bool issuesTableAvailable, bool sqlGraphContractReady, bool hotspotFamilyReady, bool csharpSymbolNameReady, bool csharpMetadataTargetReady, bool foldReady, string? foldReadyReason, string projectRoot, string resolvedDbPath) - { - var foldOnlyRemediation = BuildFoldOnlyReadinessRemediation( - graphTableAvailable, - issuesTableAvailable, - sqlGraphContractReady, - hotspotFamilyReady, - csharpSymbolNameReady, - csharpMetadataTargetReady, - foldReady, - foldReadyReason, - projectRoot, - resolvedDbPath); - if (foldOnlyRemediation != null) - { - return $"Index completed with fold-only degraded readiness (fold_ready=false). {foldOnlyRemediation.DegradedReason} Run `{foldOnlyRemediation.RecommendedAction}` to restamp folded-name columns in place, or `{foldOnlyRemediation.AlternativeAction}` for a full rebuild."; - } - - var degradedParts = new List(); - if (!graphTableAvailable) - degradedParts.Add(DegradationReasonCodes.GraphTableMissing); - if (!issuesTableAvailable) - degradedParts.Add(DegradationReasonCodes.IssuesTableMissing); - if (!sqlGraphContractReady) - degradedParts.Add(DegradationReasonCodes.SqlGraphContractNotReady); - if (!hotspotFamilyReady) - degradedParts.Add(DegradationReasonCodes.HotspotFamilyNotReady); - if (!csharpSymbolNameReady) - degradedParts.Add(DegradationReasonCodes.CSharpSymbolNameNotReady); - if (!csharpMetadataTargetReady) - degradedParts.Add(DegradationReasonCodes.CSharpMetadataTargetNotReady); - if (!foldReady) - degradedParts.Add(DegradationReasonCodes.FoldReadyNotReady); - - return $"Index completed with degraded readiness ({string.Join(", ", degradedParts)}). Run `cdidx status --db \"{resolvedDbPath}\" --json` to inspect the current DB state."; - } - - private static string QuoteCommandArgument(string value) - { - var fullPath = DbPathResolver.NormalizeDbPath(value); - if (!fullPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - fullPath = Path.GetFullPath(fullPath); - - return fullPath.IndexOfAny([' ', '\t', '"']) >= 0 - ? $"\"{fullPath.Replace("\"", "\\\"", StringComparison.Ordinal)}\"" - : fullPath; - } } From 7ed5396c8593745d9b64b11a0c0f2ff7f262b10d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 20:03:24 +0900 Subject: [PATCH 065/101] Decompose database argument parsing --- .../Cli/DbCommandRunner.ArgumentParser.cs | 294 ++++++++++++++++++ .../Cli/DbCommandRunner.Arguments.cs | 261 +--------------- 2 files changed, 295 insertions(+), 260 deletions(-) create mode 100644 src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs diff --git a/src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs new file mode 100644 index 000000000..b8a1e4d67 --- /dev/null +++ b/src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs @@ -0,0 +1,294 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +public static partial class DbCommandRunner +{ + private sealed class DbArgumentParser + { + private string dbPath = Path.Combine(".cdidx", "codeindex.db"); + private bool json; + private bool integrityCheck; + private bool schema; + private bool prune; + private bool pruneDryRun; + private bool pruneApply; + private bool checkpoint; + private bool listCheckpoints; + private bool restore; + private bool restoreBackups; + private bool checkpointsList; + private bool checkpointsDelete; + private bool checkpointsPrune; + private int checkpointsKeep = DefaultRestoreBackupKeepCount; + private bool restoreBackupsList; + private bool restoreBackupsPrune; + private int restoreBackupsKeep = DefaultRestoreBackupKeepCount; + private bool schemaSummaryOnly; + private int schemaEntryLimit = SchemaEntryLimit; + private int schemaSqlTextLimit = SchemaSqlTextLimit; + private bool? schemaIncludeInternal; + private bool schemaSpecificOptionSeen; + private string? parsedSchemaType; + private string? parsedSchemaName; + private string? name; + private string? parseError; + + internal DbCommandOptions Parse(string[] args) + { + for (var i = 0; i < args.Length; i++) + { + var immediateResult = ParseArgument(args, ref i); + if (immediateResult != null) + return immediateResult; + if (parseError != null) + break; + } + + ValidateOptionCombinations(); + return BuildOptions(); + } + + private DbCommandOptions? ParseArgument(string[] args, ref int i) + { + switch (args[i]) + { + case "--db" when i + 1 < args.Length: + dbPath = args[++i]; + break; + case "--db": + parseError = "--db requires a value"; + break; + case "--json": + json = true; + break; + case "--integrity-check": + integrityCheck = true; + break; + case "integrity": + integrityCheck = true; + break; + case "schema": + schema = true; + break; + case "--type" when i + 1 < args.Length: + schemaSpecificOptionSeen = true; + var schemaType = args[++i].Trim().ToLowerInvariant(); + if (!SchemaObjectTypes.Contains(schemaType, StringComparer.Ordinal)) + parseError = "--type must be one of table, index, trigger, or view"; + else + parsedSchemaType = schemaType; + break; + case "--type": + parseError = "--type requires a value"; + break; + case "--name" when i + 1 < args.Length: + schemaSpecificOptionSeen = true; + parsedSchemaName = args[++i]; + break; + case "--name": + parseError = "--name requires a value"; + break; + case "--summary-only": + schemaSpecificOptionSeen = true; + schemaSummaryOnly = true; + break; + case "--limit" when i + 1 < args.Length: + schemaSpecificOptionSeen = true; + if (!int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out schemaEntryLimit) + || schemaEntryLimit < 0 + || schemaEntryLimit > SchemaEntryLimit) + { + parseError = $"--limit must be an integer from 0 to {SchemaEntryLimit}"; + } + break; + case "--limit": + parseError = "--limit requires a value"; + break; + case "--max-sql-chars" when i + 1 < args.Length: + schemaSpecificOptionSeen = true; + if (!int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out schemaSqlTextLimit) + || schemaSqlTextLimit < 0 + || schemaSqlTextLimit > SchemaSqlTextLimit) + { + parseError = $"--max-sql-chars must be an integer from 0 to {SchemaSqlTextLimit}"; + } + break; + case "--max-sql-chars": + parseError = "--max-sql-chars requires a value"; + break; + case "--include-internal": + schemaSpecificOptionSeen = true; + if (schemaIncludeInternal == false) + parseError = "--include-internal and --exclude-internal cannot be combined"; + else + schemaIncludeInternal = true; + break; + case "--exclude-internal": + schemaSpecificOptionSeen = true; + if (schemaIncludeInternal == true) + parseError = "--include-internal and --exclude-internal cannot be combined"; + else + schemaIncludeInternal = false; + break; + case "prune": + prune = true; + break; + case "checkpoint": + checkpoint = true; + if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) + name = args[++i]; + break; + case "checkpoints": + listCheckpoints = true; + break; + case "restore": + restore = true; + if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) + name = args[++i]; + else + parseError = "restore requires a checkpoint name"; + break; + case "restore-backups": + restoreBackups = true; + break; + case "--dry-run": + pruneDryRun = true; + break; + case "--apply": + pruneApply = true; + break; + case "--prune": + if (restoreBackups) + restoreBackupsPrune = true; + else if (listCheckpoints) + checkpointsPrune = true; + else + parseError = "--prune is only valid with `cdidx db checkpoints --prune` or `cdidx db restore-backups --prune`"; + break; + case "--delete" when i + 1 < args.Length + && !args[i + 1].StartsWith("-", StringComparison.Ordinal): + if (!listCheckpoints) + { + parseError = "--delete is only valid with `cdidx db checkpoints --delete `"; + break; + } + + checkpointsDelete = true; + name = args[++i]; + break; + case "--delete": + parseError = "--delete requires a checkpoint name"; + break; + case "--keep" when i + 1 < args.Length: + if (!restoreBackups && !checkpointsPrune) + { + parseError = "--keep is only valid with checkpoint or restore-backup pruning"; + break; + } + + if (!int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var parsedKeep) + || parsedKeep < 0 + || parsedKeep > MaxRestoreBackupKeepCount) + { + parseError = $"--keep must be an integer from 0 to {MaxRestoreBackupKeepCount}"; + } + else if (restoreBackups) + { + restoreBackupsKeep = parsedKeep; + } + else + { + checkpointsKeep = parsedKeep; + } + break; + case "--keep": + parseError = "--keep requires a value"; + break; + case "--list": + if (listCheckpoints) + { + checkpointsList = true; + break; + } + if (restoreBackups) + { + restoreBackupsList = true; + break; + } + + parseError = "--list is only valid with `cdidx db checkpoints --list`"; + break; + case "--help" or "-h": + return new DbCommandOptions { ShowHelp = true, DbPath = dbPath, Json = json }; + default: + if (args[i].StartsWith('-')) + parseError = $"db does not support option: '{args[i]}'"; + else + parseError = $"unknown db command or argument: '{args[i]}'"; + break; + } + + return null; + } + + private void ValidateOptionCombinations() + { + if (parseError is null && restoreBackups && pruneApply) + parseError = "--apply is not supported with `cdidx db restore-backups`; `--prune` is the explicit mutation opt-in."; + if (parseError is null && pruneDryRun && restoreBackups && !restoreBackupsPrune) + parseError = "--dry-run is only valid with `cdidx db restore-backups --prune`."; + if (parseError is null && pruneDryRun && listCheckpoints && !checkpointsDelete && !checkpointsPrune) + parseError = "--dry-run is only valid with checkpoint deletion or pruning."; + if (parseError is null && !schema && schemaSpecificOptionSeen) + parseError = "--type, --name, --summary-only, --limit, --max-sql-chars, --include-internal, and --exclude-internal are only valid with `cdidx db schema`."; + if (parseError is null && pruneDryRun && !prune && !checkpoint && !restore && !restoreBackups && !listCheckpoints) + parseError = "--dry-run is only valid with a supported preview operation."; + if (parseError is null && pruneApply && !prune) + parseError = "--apply is only valid with `cdidx db prune --apply`."; + } + + private DbCommandOptions BuildOptions() + { + return new DbCommandOptions + { + DbPath = dbPath, + Json = json, + IntegrityCheck = integrityCheck, + Schema = schema, + Prune = prune, + PruneDryRun = pruneDryRun, + PruneApply = pruneApply, + Checkpoint = checkpoint, + ListCheckpoints = listCheckpoints, + CheckpointsList = checkpointsList, + CheckpointsDelete = checkpointsDelete, + CheckpointsPrune = checkpointsPrune, + CheckpointsKeep = checkpointsKeep, + CheckpointsDryRun = listCheckpoints && pruneDryRun, + Restore = restore, + RestoreDryRun = restore && pruneDryRun, + RestoreBackups = restoreBackups, + RestoreBackupsList = restoreBackupsList, + RestoreBackupsPrune = restoreBackupsPrune, + RestoreBackupsKeep = restoreBackupsKeep, + RestoreBackupsDryRun = restoreBackups && pruneDryRun, + SchemaSummaryOnly = schemaSummaryOnly, + SchemaEntryLimit = schemaEntryLimit, + SchemaSqlTextLimit = schemaSqlTextLimit, + SchemaIncludeInternal = schemaIncludeInternal ?? true, + SchemaType = parsedSchemaType, + SchemaName = parsedSchemaName, + CheckpointDryRun = checkpoint && pruneDryRun, + Name = name, + ParseError = parseError, + }; + } + } +} diff --git a/src/CodeIndex/Cli/DbCommandRunner.Arguments.cs b/src/CodeIndex/Cli/DbCommandRunner.Arguments.cs index 8d6a08e93..a6597e94d 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.Arguments.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.Arguments.cs @@ -11,264 +11,5 @@ namespace CodeIndex.Cli; public static partial class DbCommandRunner { internal static DbCommandOptions ParseArgs(string[] args) - { - var dbPath = Path.Combine(".cdidx", "codeindex.db"); - var json = false; - var integrityCheck = false; - var schema = false; - var prune = false; - var pruneDryRun = false; - var pruneApply = false; - var checkpoint = false; - var listCheckpoints = false; - var restore = false; - var restoreBackups = false; - var checkpointsList = false; - var checkpointsDelete = false; - var checkpointsPrune = false; - var checkpointsKeep = DefaultRestoreBackupKeepCount; - var restoreBackupsList = false; - var restoreBackupsPrune = false; - var restoreBackupsKeep = DefaultRestoreBackupKeepCount; - var schemaSummaryOnly = false; - var schemaEntryLimit = SchemaEntryLimit; - var schemaSqlTextLimit = SchemaSqlTextLimit; - bool? schemaIncludeInternal = null; - var schemaSpecificOptionSeen = false; - string? parsedSchemaType = null; - string? parsedSchemaName = null; - string? name = null; - string? parseError = null; - - for (var i = 0; i < args.Length; i++) - { - switch (args[i]) - { - case "--db" when i + 1 < args.Length: - dbPath = args[++i]; - break; - case "--db": - parseError = "--db requires a value"; - break; - case "--json": - json = true; - break; - case "--integrity-check": - integrityCheck = true; - break; - case "integrity": - integrityCheck = true; - break; - case "schema": - schema = true; - break; - case "--type" when i + 1 < args.Length: - schemaSpecificOptionSeen = true; - var schemaType = args[++i].Trim().ToLowerInvariant(); - if (!SchemaObjectTypes.Contains(schemaType, StringComparer.Ordinal)) - parseError = "--type must be one of table, index, trigger, or view"; - else - parsedSchemaType = schemaType; - break; - case "--type": - parseError = "--type requires a value"; - break; - case "--name" when i + 1 < args.Length: - schemaSpecificOptionSeen = true; - parsedSchemaName = args[++i]; - break; - case "--name": - parseError = "--name requires a value"; - break; - case "--summary-only": - schemaSpecificOptionSeen = true; - schemaSummaryOnly = true; - break; - case "--limit" when i + 1 < args.Length: - schemaSpecificOptionSeen = true; - if (!int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out schemaEntryLimit) - || schemaEntryLimit < 0 - || schemaEntryLimit > SchemaEntryLimit) - { - parseError = $"--limit must be an integer from 0 to {SchemaEntryLimit}"; - } - break; - case "--limit": - parseError = "--limit requires a value"; - break; - case "--max-sql-chars" when i + 1 < args.Length: - schemaSpecificOptionSeen = true; - if (!int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out schemaSqlTextLimit) - || schemaSqlTextLimit < 0 - || schemaSqlTextLimit > SchemaSqlTextLimit) - { - parseError = $"--max-sql-chars must be an integer from 0 to {SchemaSqlTextLimit}"; - } - break; - case "--max-sql-chars": - parseError = "--max-sql-chars requires a value"; - break; - case "--include-internal": - schemaSpecificOptionSeen = true; - if (schemaIncludeInternal == false) - parseError = "--include-internal and --exclude-internal cannot be combined"; - else - schemaIncludeInternal = true; - break; - case "--exclude-internal": - schemaSpecificOptionSeen = true; - if (schemaIncludeInternal == true) - parseError = "--include-internal and --exclude-internal cannot be combined"; - else - schemaIncludeInternal = false; - break; - case "prune": - prune = true; - break; - case "checkpoint": - checkpoint = true; - if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) - name = args[++i]; - break; - case "checkpoints": - listCheckpoints = true; - break; - case "restore": - restore = true; - if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) - name = args[++i]; - else - parseError = "restore requires a checkpoint name"; - break; - case "restore-backups": - restoreBackups = true; - break; - case "--dry-run": - pruneDryRun = true; - break; - case "--apply": - pruneApply = true; - break; - case "--prune": - if (restoreBackups) - restoreBackupsPrune = true; - else if (listCheckpoints) - checkpointsPrune = true; - else - parseError = "--prune is only valid with `cdidx db checkpoints --prune` or `cdidx db restore-backups --prune`"; - break; - case "--delete" when i + 1 < args.Length - && !args[i + 1].StartsWith("-", StringComparison.Ordinal): - if (!listCheckpoints) - { - parseError = "--delete is only valid with `cdidx db checkpoints --delete `"; - break; - } - - checkpointsDelete = true; - name = args[++i]; - break; - case "--delete": - parseError = "--delete requires a checkpoint name"; - break; - case "--keep" when i + 1 < args.Length: - if (!restoreBackups && !checkpointsPrune) - { - parseError = "--keep is only valid with checkpoint or restore-backup pruning"; - break; - } - - if (!int.TryParse(args[++i], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var parsedKeep) - || parsedKeep < 0 - || parsedKeep > MaxRestoreBackupKeepCount) - { - parseError = $"--keep must be an integer from 0 to {MaxRestoreBackupKeepCount}"; - } - else if (restoreBackups) - { - restoreBackupsKeep = parsedKeep; - } - else - { - checkpointsKeep = parsedKeep; - } - break; - case "--keep": - parseError = "--keep requires a value"; - break; - case "--list": - if (listCheckpoints) - { - checkpointsList = true; - break; - } - if (restoreBackups) - { - restoreBackupsList = true; - break; - } - - parseError = "--list is only valid with `cdidx db checkpoints --list`"; - break; - case "--help" or "-h": - return new DbCommandOptions { ShowHelp = true, DbPath = dbPath, Json = json }; - default: - if (args[i].StartsWith('-')) - parseError = $"db does not support option: '{args[i]}'"; - else - parseError = $"unknown db command or argument: '{args[i]}'"; - break; - } - - if (parseError != null) - break; - } - - if (parseError is null && restoreBackups && pruneApply) - parseError = "--apply is not supported with `cdidx db restore-backups`; `--prune` is the explicit mutation opt-in."; - if (parseError is null && pruneDryRun && restoreBackups && !restoreBackupsPrune) - parseError = "--dry-run is only valid with `cdidx db restore-backups --prune`."; - if (parseError is null && pruneDryRun && listCheckpoints && !checkpointsDelete && !checkpointsPrune) - parseError = "--dry-run is only valid with checkpoint deletion or pruning."; - if (parseError is null && !schema && schemaSpecificOptionSeen) - parseError = "--type, --name, --summary-only, --limit, --max-sql-chars, --include-internal, and --exclude-internal are only valid with `cdidx db schema`."; - if (parseError is null && pruneDryRun && !prune && !checkpoint && !restore && !restoreBackups && !listCheckpoints) - parseError = "--dry-run is only valid with a supported preview operation."; - if (parseError is null && pruneApply && !prune) - parseError = "--apply is only valid with `cdidx db prune --apply`."; - - return new DbCommandOptions - { - DbPath = dbPath, - Json = json, - IntegrityCheck = integrityCheck, - Schema = schema, - Prune = prune, - PruneDryRun = pruneDryRun, - PruneApply = pruneApply, - Checkpoint = checkpoint, - ListCheckpoints = listCheckpoints, - CheckpointsList = checkpointsList, - CheckpointsDelete = checkpointsDelete, - CheckpointsPrune = checkpointsPrune, - CheckpointsKeep = checkpointsKeep, - CheckpointsDryRun = listCheckpoints && pruneDryRun, - Restore = restore, - RestoreDryRun = restore && pruneDryRun, - RestoreBackups = restoreBackups, - RestoreBackupsList = restoreBackupsList, - RestoreBackupsPrune = restoreBackupsPrune, - RestoreBackupsKeep = restoreBackupsKeep, - RestoreBackupsDryRun = restoreBackups && pruneDryRun, - SchemaSummaryOnly = schemaSummaryOnly, - SchemaEntryLimit = schemaEntryLimit, - SchemaSqlTextLimit = schemaSqlTextLimit, - SchemaIncludeInternal = schemaIncludeInternal ?? true, - SchemaType = parsedSchemaType, - SchemaName = parsedSchemaName, - CheckpointDryRun = checkpoint && pruneDryRun, - Name = name, - ParseError = parseError, - }; - } + => new DbArgumentParser().Parse(args); } From 5b2bf5c01458dfc2408dfe4a95746efe92bf61ce Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 20:11:28 +0900 Subject: [PATCH 066/101] Split language-specific core reference emission --- .../ReferenceExtractor.CoreExtraction.cs | 534 +---------------- .../ReferenceExtractor.CoreLanguageLines.cs | 558 ++++++++++++++++++ 2 files changed, 578 insertions(+), 514 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLanguageLines.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs index 13dcb40d0..30f9741d6 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs @@ -2129,6 +2129,22 @@ void AddGradleDslReference(string name, int callIndex) ResolveContainerForCall); } + var lineContext = new CoreReferenceLineContext( + fileId, + language, + lines, + preparedLines, + i, + preparedLine, + originalLine, + context, + lineNumber, + references, + seen, + container, + definitionNames, + ResolveContainerForCall); + // issue #268: JS/TS tagged template literal call sites. The structural masker // already located each template opener and captured its preceding tag identifier; // emit one `call` row per hit so `gql\`...\`` / `styled.div\`...\`` / `sql\`...${x}...\`` @@ -2140,36 +2156,7 @@ void AddGradleDslReference(string name, int callIndex) if (jsTaggedTemplatesByLine != null && jsTaggedTemplatesByLine.TryGetValue(lineNumber, out var tagHitsOnLine)) { - foreach (var hit in tagHitsOnLine) - { - var name = hit.Name; - // Bare-name suppression (shared ignore list + tagged-template - // operator denylist) is bypassed for member-access tags because - // any reserved / keyword-ish identifier is a legal property name - // in JS/TS — `obj.return\`x\``, `obj.await\`y\``, `obj.yield\`z\``, - // `obj.default\`w\``, `obj.finally\`v\`` all evaluate to real - // tagged-template calls. Only bare-keyword forms such as - // `yield \`x\``, `await \`x\``, `export default \`x\``, - // `try {} finally \`x\`` should remain suppressed. - // bare-name による抑止(共有 ignore list と tagged-template 演算子 - // denylist)は member-access のタグでは迂回する。JS/TS ではすべての - // 予約語相当 identifier が property 名になれるため - // `obj.return\`x\``・`obj.await\`y\``・`obj.yield\`z\``・ - // `obj.default\`w\``・`obj.finally\`v\`` はすべて正当なタグ呼び出し。 - // `yield \`x\``・`await \`x\``・`export default \`x\``・ - // `try {} finally \`x\`` のような bare-keyword 形のみ抑止する。 - if (!hit.IsMemberAccess) - { - if (IsIgnoredCallName(language, name)) - continue; - if (JsTaggedTemplateOperatorNames.Contains(name)) - continue; - } - if (definitionNames != null && definitionNames.Contains(name)) - continue; - var tagContainer = ResolveContainerForCall(hit.Column - 1); - AddChainReference(references, seen, fileId, name, hit.Column, "call", context, lineNumber, tagContainer); - } + EmitJavaScriptTaggedTemplateReferences(lineContext, tagHitsOnLine); } // issue #293: bare no-arg attributes / annotations are invisible to CallRegex because @@ -2177,89 +2164,7 @@ void AddGradleDslReference(string name, int callIndex) // and their siblings still populate the reference table. // issue #293: 引数なしの属性・アノテーションは `(` が必須な CallRegex では拾えないため、 // 専用 regex から `[Serializable]` / `@Deprecated` などの素形を reference テーブルへ反映する。 - if (language == "csharp" && csharpAttrTopLevelOnLine != null && csharpAttrTopLevelOnLine.Count > 0) - { - foreach (Match match in CSharpNoArgAttributeRegex.Matches(preparedLine)) - { - var rawName = match.Groups["name"].Value; - var name = NormalizeCSharpIdentifier(rawName); - var nameIndex = match.Groups["name"].Index; - // Gate on the attribute-section top-level (paren-depth 0) zones only, so - // identifiers that sit inside an attribute's argument list (e.g. - // `ConverterStrategy.AllowNumbers` in `[JsonConverter(...)]`) are not - // misclassified as no-arg attributes. - // 属性セクションの top-level(paren 深さ 0)ゾーンでのみ採用する。属性の - // 引数リスト内にある識別子(`[JsonConverter(ConverterStrategy.AllowNumbers)]` - // の `AllowNumbers` など)を no-arg 属性として誤分類しないため。 - if (!IsInsideCSharpAttributeRange(csharpAttrTopLevelOnLine, nameIndex)) - continue; - if (IsIgnoredCallName(language, rawName)) - continue; - if (definitionNames != null && definitionNames.Contains(name)) - continue; - AddReference(references, seen, fileId, name, nameIndex, "attribute", context, lineNumber, container, language); - var genericStart = nameIndex + rawName.Length; - while (genericStart < preparedLine.Length && char.IsWhiteSpace(preparedLine[genericStart])) - genericStart++; - if (genericStart < preparedLine.Length && preparedLine[genericStart] == '<') - { - var genericEnd = genericStart; - if (TrySkipBalancedGenericArgs(preparedLine, ref genericEnd, out _) - && genericEnd > genericStart + 2) - { - AddTypeExpressionSegments( - references, - seen, - fileId, - preparedLine.Substring(genericStart + 1, genericEnd - genericStart - 2), - genericStart + 1, - context, - lineNumber, - container, - "csharp"); - } - } - if (CSharpReferenceExtractor.TryGetCallerInfoAttributeTypeName(rawName, preparedLine, nameIndex) is { } callerInfoAttributeTypeName) - { - AddReference( - references, - seen, - fileId, - callerInfoAttributeTypeName, - nameIndex, - "type_reference", - context, - lineNumber, - container); - } - } - } - else if (AnnotationLanguages.Contains(language)) - { - if (language == "kotlin") - { - foreach (Match match in KotlinBacktickAnnotationRegex.Matches(preparedLine)) - { - var nameGroup = match.Groups["name"]; - var name = NormalizeKotlinBacktickIdentifier(nameGroup.Value); - if (IsIgnoredCallName(language, name)) - continue; - if (definitionNames != null && definitionNames.Contains(name)) - continue; - AddReference(references, seen, fileId, name, nameGroup.Index, "annotation", context, lineNumber, container); - } - } - - foreach (Match match in NoArgAnnotationRegex.Matches(preparedLine)) - { - var name = match.Groups["name"].Value; - if (IsIgnoredCallName(language, name)) - continue; - if (definitionNames != null && definitionNames.Contains(name)) - continue; - AddReference(references, seen, fileId, match, "annotation", context, lineNumber, container); - } - } + EmitMetadataLineReferences(lineContext, csharpAttrTopLevelOnLine); if (isRazorFile && language == "csharp") { @@ -2277,409 +2182,10 @@ void AddGradleDslReference(string name, int callIndex) } if (language == "python") - { - var pythonPreparedLine = preparedLine; - var pythonHeaderMap = default(PythonLogicalHeaderReferenceLine?); - SymbolRecord? pythonHeaderSymbol = null; - lookups.GetPythonHeaderSymbolsByLine()?.TryGetValue(lineNumber, out pythonHeaderSymbol); - if (pythonHeaderSymbol?.Signature != null - && TryBuildPythonLogicalHeaderReferenceLine(lines, i, pythonHeaderSymbol.StartColumn ?? 0, out var builtPythonHeaderMap)) - { - pythonPreparedLine = builtPythonHeaderMap.Text; - pythonHeaderMap = builtPythonHeaderMap; - } - var pythonTypeFactoryLine = preparedLine; - var pythonTypeFactoryMap = default(PythonLogicalHeaderReferenceLine?); - if (preparedLine.Contains("TypeVar", StringComparison.Ordinal) - || preparedLine.Contains("ParamSpec", StringComparison.Ordinal)) - { - var typeFactoryStartColumn = originalLine.IndexOfAny(['T', 'P']); - if (typeFactoryStartColumn < 0) - typeFactoryStartColumn = 0; - if (TryBuildPythonLogicalStatementReferenceLine(lines, i, typeFactoryStartColumn, out var builtPythonTypeFactoryMap)) - { - pythonTypeFactoryLine = builtPythonTypeFactoryMap.Text; - pythonTypeFactoryMap = builtPythonTypeFactoryMap; - } - } - var pythonHeaderContainer = pythonHeaderSymbol ?? container; - - var pythonReferenceStart = references.Count; - PythonReferenceExtractor.EmitDecoratorReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - definitionNames, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitRaiseReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitExceptReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitIsInstanceReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitIsSubclassReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitCastReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitAssertTypeReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitClassBaseReferences( - pythonPreparedLine, - references, - seen, - fileId, - context, - lineNumber, - pythonHeaderContainer, - index => pythonHeaderContainer ?? ResolveContainerForCall(index) ?? ResolvePythonDefinitionContainer(lineNumber, "class"), - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitFunctionReturnReferences( - pythonPreparedLine, - references, - seen, - fileId, - context, - lineNumber, - pythonHeaderContainer, - index => pythonHeaderContainer ?? ResolveContainerForCall(index) ?? ResolvePythonDefinitionContainer(lineNumber, "function"), - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitFunctionParameterReferences( - pythonPreparedLine, - references, - seen, - fileId, - context, - lineNumber, - pythonHeaderContainer, - index => pythonHeaderContainer ?? ResolveContainerForCall(index) ?? ResolvePythonDefinitionContainer(lineNumber, "function"), - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitVariableAnnotationReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitTypeAliasReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitNewTypeReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - var pythonTypeFactoryReferenceStart = references.Count; - PythonReferenceExtractor.EmitTypeVarBoundReferences( - pythonTypeFactoryLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitTypeVarConstraintReferences( - pythonTypeFactoryLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitGetTypeHintsReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitDataclassesFieldsReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitDataclassFieldReferences( - preparedLines, - lines, - i, - references, - seen, - fileId, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitAttrsFieldsReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitPydanticTypeAdapterReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitPytestRaisesReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - PythonReferenceExtractor.EmitContextlibSuppressReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - name => IsIgnoredCallName(language, name)); - - if (pythonTypeFactoryMap.HasValue) - RemapPythonLogicalHeaderReferences(references, pythonTypeFactoryReferenceStart, pythonTypeFactoryMap.Value, lines); - PythonReferenceExtractor.EmitDynamicImportReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container); - if (pythonHeaderMap.HasValue) - RemapPythonLogicalHeaderReferences(references, pythonReferenceStart, pythonHeaderMap.Value, lines); - } + EmitPythonLineReferences(lineContext, lookups, ResolvePythonDefinitionContainer); if (language == "r") - { - RReferenceExtractor.EmitNamespaceReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - definitionNames); - RReferenceExtractor.EmitNamespaceDirectiveReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container); - RReferenceExtractor.EmitS4DispatchReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container); - RReferenceExtractor.EmitBacktickCallReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - definitionNames); - RReferenceExtractor.EmitInfixOperatorCallReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - definitionNames); - RReferenceExtractor.EmitSourceFileReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container); - RReferenceExtractor.EmitLoadAllReferences( - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container); - RReferenceExtractor.EmitDataCallReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container); - RReferenceExtractor.EmitSystemFileReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container); - RReferenceExtractor.EmitVignetteReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container); - RReferenceExtractor.EmitHelpExampleReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container); - RReferenceExtractor.EmitInstallPackagesReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container); - RReferenceExtractor.EmitNamespacePackageInstallReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container); - RReferenceExtractor.EmitGitHubPackageInstallReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container); - RReferenceExtractor.EmitDollarMemberReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - definitionNames); - RReferenceExtractor.EmitBracketMemberReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - container, - definitionNames); - RReferenceExtractor.EmitSlotMemberReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - definitionNames); - } + EmitRLineReferences(lineContext); } if (!ReferenceLimitReached(references) && language == "csharp") diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLanguageLines.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLanguageLines.cs new file mode 100644 index 000000000..04feb081f --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLanguageLines.cs @@ -0,0 +1,558 @@ +using System.Text.RegularExpressions; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private readonly record struct CoreReferenceLineContext( + long FileId, + string Language, + string[] Lines, + string[] PreparedLines, + int LineIndex, + string PreparedLine, + string OriginalLine, + string Context, + int LineNumber, + List References, + ReferenceDedupeSet Seen, + SymbolRecord? Container, + HashSet? DefinitionNames, + Func ResolveContainerForCall); + + private static void EmitJavaScriptTaggedTemplateReferences( + CoreReferenceLineContext line, + IReadOnlyList tagHitsOnLine) + { + foreach (var hit in tagHitsOnLine) + { + var name = hit.Name; + // Bare-name suppression (shared ignore list + tagged-template + // operator denylist) is bypassed for member-access tags because + // any reserved / keyword-ish identifier is a legal property name + // in JS/TS — `obj.return\`x\``, `obj.await\`y\``, `obj.yield\`z\``, + // `obj.default\`w\``, `obj.finally\`v\`` all evaluate to real + // tagged-template calls. Only bare-keyword forms such as + // `yield \`x\``, `await \`x\``, `export default \`x\``, + // `try {} finally \`x\`` should remain suppressed. + // bare-name による抑止(共有 ignore list と tagged-template 演算子 + // denylist)は member-access のタグでは迂回する。JS/TS ではすべての + // 予約語相当 identifier が property 名になれるため + // `obj.return\`x\``・`obj.await\`y\``・`obj.yield\`z\``・ + // `obj.default\`w\``・`obj.finally\`v\`` はすべて正当なタグ呼び出し。 + // `yield \`x\``・`await \`x\``・`export default \`x\``・ + // `try {} finally \`x\`` のような bare-keyword 形のみ抑止する。 + if (!hit.IsMemberAccess) + { + if (IsIgnoredCallName(line.Language, name)) + continue; + if (JsTaggedTemplateOperatorNames.Contains(name)) + continue; + } + if (line.DefinitionNames != null && line.DefinitionNames.Contains(name)) + continue; + var tagContainer = line.ResolveContainerForCall(hit.Column - 1); + AddChainReference(line.References, line.Seen, line.FileId, name, hit.Column, "call", line.Context, line.LineNumber, tagContainer); + } + } + + private static void EmitMetadataLineReferences( + CoreReferenceLineContext line, + List<(int start, int end)>? csharpAttrTopLevelOnLine) + { + if (line.Language == "csharp" && csharpAttrTopLevelOnLine != null && csharpAttrTopLevelOnLine.Count > 0) + { + foreach (Match match in CSharpNoArgAttributeRegex.Matches(line.PreparedLine)) + { + var rawName = match.Groups["name"].Value; + var name = NormalizeCSharpIdentifier(rawName); + var nameIndex = match.Groups["name"].Index; + // Gate on the attribute-section top-level (paren-depth 0) zones only, so + // identifiers that sit inside an attribute's argument list (e.g. + // `ConverterStrategy.AllowNumbers` in `[JsonConverter(...)]`) are not + // misclassified as no-arg attributes. + // 属性セクションの top-level(paren 深さ 0)ゾーンでのみ採用する。属性の + // 引数リスト内にある識別子(`[JsonConverter(ConverterStrategy.AllowNumbers)]` + // の `AllowNumbers` など)を no-arg 属性として誤分類しないため。 + if (!IsInsideCSharpAttributeRange(csharpAttrTopLevelOnLine, nameIndex)) + continue; + if (IsIgnoredCallName(line.Language, rawName)) + continue; + if (line.DefinitionNames != null && line.DefinitionNames.Contains(name)) + continue; + AddReference(line.References, line.Seen, line.FileId, name, nameIndex, "attribute", line.Context, line.LineNumber, line.Container, line.Language); + var genericStart = nameIndex + rawName.Length; + while (genericStart < line.PreparedLine.Length && char.IsWhiteSpace(line.PreparedLine[genericStart])) + genericStart++; + if (genericStart < line.PreparedLine.Length && line.PreparedLine[genericStart] == '<') + { + var genericEnd = genericStart; + if (TrySkipBalancedGenericArgs(line.PreparedLine, ref genericEnd, out _) + && genericEnd > genericStart + 2) + { + AddTypeExpressionSegments( + line.References, + line.Seen, + line.FileId, + line.PreparedLine.Substring(genericStart + 1, genericEnd - genericStart - 2), + genericStart + 1, + line.Context, + line.LineNumber, + line.Container, + "csharp"); + } + } + if (CSharpReferenceExtractor.TryGetCallerInfoAttributeTypeName(rawName, line.PreparedLine, nameIndex) is { } callerInfoAttributeTypeName) + { + AddReference( + line.References, + line.Seen, + line.FileId, + callerInfoAttributeTypeName, + nameIndex, + "type_reference", + line.Context, + line.LineNumber, + line.Container); + } + } + } + else if (AnnotationLanguages.Contains(line.Language)) + { + if (line.Language == "kotlin") + { + foreach (Match match in KotlinBacktickAnnotationRegex.Matches(line.PreparedLine)) + { + var nameGroup = match.Groups["name"]; + var name = NormalizeKotlinBacktickIdentifier(nameGroup.Value); + if (IsIgnoredCallName(line.Language, name)) + continue; + if (line.DefinitionNames != null && line.DefinitionNames.Contains(name)) + continue; + AddReference(line.References, line.Seen, line.FileId, name, nameGroup.Index, "annotation", line.Context, line.LineNumber, line.Container); + } + } + + foreach (Match match in NoArgAnnotationRegex.Matches(line.PreparedLine)) + { + var name = match.Groups["name"].Value; + if (IsIgnoredCallName(line.Language, name)) + continue; + if (line.DefinitionNames != null && line.DefinitionNames.Contains(name)) + continue; + AddReference(line.References, line.Seen, line.FileId, match, "annotation", line.Context, line.LineNumber, line.Container); + } + } + } + + private static void EmitPythonLineReferences( + CoreReferenceLineContext line, + CoreExtractionLookups lookups, + Func resolvePythonDefinitionContainer) + { + + var pythonPreparedLine = line.PreparedLine; + var pythonHeaderMap = default(PythonLogicalHeaderReferenceLine?); + SymbolRecord? pythonHeaderSymbol = null; + lookups.GetPythonHeaderSymbolsByLine()?.TryGetValue(line.LineNumber, out pythonHeaderSymbol); + if (pythonHeaderSymbol?.Signature != null + && TryBuildPythonLogicalHeaderReferenceLine(line.Lines, line.LineIndex, pythonHeaderSymbol.StartColumn ?? 0, out var builtPythonHeaderMap)) + { + pythonPreparedLine = builtPythonHeaderMap.Text; + pythonHeaderMap = builtPythonHeaderMap; + } + var pythonTypeFactoryLine = line.PreparedLine; + var pythonTypeFactoryMap = default(PythonLogicalHeaderReferenceLine?); + if (line.PreparedLine.Contains("TypeVar", StringComparison.Ordinal) + || line.PreparedLine.Contains("ParamSpec", StringComparison.Ordinal)) + { + var typeFactoryStartColumn = line.OriginalLine.IndexOfAny(['T', 'P']); + if (typeFactoryStartColumn < 0) + typeFactoryStartColumn = 0; + if (TryBuildPythonLogicalStatementReferenceLine(line.Lines, line.LineIndex, typeFactoryStartColumn, out var builtPythonTypeFactoryMap)) + { + pythonTypeFactoryLine = builtPythonTypeFactoryMap.Text; + pythonTypeFactoryMap = builtPythonTypeFactoryMap; + } + } + var pythonHeaderContainer = pythonHeaderSymbol ?? line.Container; + + var pythonReferenceStart = line.References.Count; + PythonReferenceExtractor.EmitDecoratorReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + line.DefinitionNames, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitRaiseReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitExceptReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitIsInstanceReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitIsSubclassReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitCastReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitAssertTypeReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitClassBaseReferences( + pythonPreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + pythonHeaderContainer, + index => pythonHeaderContainer ?? line.ResolveContainerForCall(index) ?? resolvePythonDefinitionContainer(line.LineNumber, "class"), + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitFunctionReturnReferences( + pythonPreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + pythonHeaderContainer, + index => pythonHeaderContainer ?? line.ResolveContainerForCall(index) ?? resolvePythonDefinitionContainer(line.LineNumber, "function"), + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitFunctionParameterReferences( + pythonPreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + pythonHeaderContainer, + index => pythonHeaderContainer ?? line.ResolveContainerForCall(index) ?? resolvePythonDefinitionContainer(line.LineNumber, "function"), + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitVariableAnnotationReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitTypeAliasReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitNewTypeReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + var pythonTypeFactoryReferenceStart = line.References.Count; + PythonReferenceExtractor.EmitTypeVarBoundReferences( + pythonTypeFactoryLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitTypeVarConstraintReferences( + pythonTypeFactoryLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitGetTypeHintsReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitDataclassesFieldsReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitDataclassFieldReferences( + line.PreparedLines, + line.Lines, + line.LineIndex, + line.References, + line.Seen, + line.FileId, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitAttrsFieldsReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitPydanticTypeAdapterReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitPytestRaisesReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + PythonReferenceExtractor.EmitContextlibSuppressReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + name => IsIgnoredCallName(line.Language, name)); + + if (pythonTypeFactoryMap.HasValue) + RemapPythonLogicalHeaderReferences(line.References, pythonTypeFactoryReferenceStart, pythonTypeFactoryMap.Value, line.Lines); + PythonReferenceExtractor.EmitDynamicImportReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + if (pythonHeaderMap.HasValue) + RemapPythonLogicalHeaderReferences(line.References, pythonReferenceStart, pythonHeaderMap.Value, line.Lines); + } + + private static void EmitRLineReferences(CoreReferenceLineContext line) + { + + RReferenceExtractor.EmitNamespaceReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + line.DefinitionNames); + RReferenceExtractor.EmitNamespaceDirectiveReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + RReferenceExtractor.EmitS4DispatchReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + RReferenceExtractor.EmitBacktickCallReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + line.DefinitionNames); + RReferenceExtractor.EmitInfixOperatorCallReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + line.DefinitionNames); + RReferenceExtractor.EmitSourceFileReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + RReferenceExtractor.EmitLoadAllReferences( + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + RReferenceExtractor.EmitDataCallReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + RReferenceExtractor.EmitSystemFileReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + RReferenceExtractor.EmitVignetteReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + RReferenceExtractor.EmitHelpExampleReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + RReferenceExtractor.EmitInstallPackagesReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + RReferenceExtractor.EmitNamespacePackageInstallReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + RReferenceExtractor.EmitGitHubPackageInstallReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + RReferenceExtractor.EmitDollarMemberReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + line.DefinitionNames); + RReferenceExtractor.EmitBracketMemberReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + line.DefinitionNames); + RReferenceExtractor.EmitSlotMemberReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + line.DefinitionNames); + } +} From 9f2c9c8bab915c11fb461ce047f55a029a88cbf4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 20:18:23 +0900 Subject: [PATCH 067/101] Separate core call reference scanning --- .../ReferenceExtractor.CoreCallReferences.cs | 631 +++++++++++++++ .../ReferenceExtractor.CoreDefinitionState.cs | 111 +++ .../ReferenceExtractor.CoreExtraction.cs | 738 +----------------- 3 files changed, 785 insertions(+), 695 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CoreDefinitionState.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs new file mode 100644 index 000000000..41ba43f1d --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs @@ -0,0 +1,631 @@ +using System.Text.RegularExpressions; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private readonly record struct CoreCallReferenceContext( + CoreReferenceLineContext Line, + CoreExtractionLookups Lookups, + (SymbolRecord Synthetic, int NameIndex, int OpenBraceIndex, int CloseBraceIndex)? JavaSameLineCtor, + List<(int start, int end)>? CSharpAttributeRanges, + HashSet? KotlinConstructorTypeNames, + HashSet? KotlinInfixFunctionNames, + HashSet? ShellCallableNames, + HashSet? ShellGlobalAliasNames, + DynamicDeclarativeReferenceExtractor.ExtractionState? DynamicDeclarativeState, + string ReferenceStructuralLine, + int ScientificNativeDependencyLimit, + Action? ReportDiagnostic, + HashSet? SqlSuppressedCallIndices, + HashSet<(int LineNumber, int ColumnIndex)>? SqlWindowFunctionCallSiteSuppressions, + CoreLineDefinitionState Definitions); + + private static void EmitCoreCallReferences(CoreCallReferenceContext call) + { + var line = call.Line; + if (line.Language is "javascript" or "typescript") + { + JavaScriptReferenceExtractor.EmitOptionalMemberChainReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + + JavaScriptReferenceExtractor.EmitDiscriminantStringGuardReferences( + call.ReferenceStructuralLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + + JavaScriptReferenceExtractor.EmitParenlessConstructorReferences( + line.PreparedLine, + line.PreparedLines, + line.LineIndex, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + } + + void AddCallLikeReference(string name, int callIndex) => + _ = TryAddCallLikeReference( + name, + callIndex, + ScientificNativeReferenceExtractor.Supports(line.Language) + ? ScientificNativeReferenceExtractor.GetParenthesizedCallTargetQualifier( + line.Language, + line.PreparedLine, + callIndex) + : null); + + void AddPowerShellParameterReference(string name, int callIndex) + { + var callContainer = line.ResolveContainerForCall(callIndex); + AddReference(line.References, line.Seen, line.FileId, name, callIndex, "parameter", line.Context, line.LineNumber, callContainer, line.Language); + } + + bool TryAddCallLikeReference( + string name, + int callIndex, + string? targetQualifier = null) + { + var normalizedName = line.Language == "fsharp" && FSharpReferenceExtractor.IsOperatorCallName(name) + ? $"operator {name}" + : line.Language == "rust" + ? RustReferenceExtractor.NormalizeIdentifier(name) + : NormalizeAtPrefixedIdentifier(name); + + // In tuple-return declarations such as `private static (int Value, string Error) + // Resolve(...)`, CallRegex sees the modifier token as `static(`. It is a C# keyword, + // never a callable identifier, so suppress the phantom edge before graph ingestion. + // `private static (int Value, string Error) Resolve(...)` のような tuple return 宣言では + // CallRegex が modifier を `static(` と誤認する。C# keyword は呼び出し対象にならないため、 + // graph に入る前に phantom edge を除外する。 + if (line.Language == "csharp" && name == "static") + return false; + + if (line.Language == "rust" && RustReferenceExtractor.IsFunctionDeclarationCallSite(line.PreparedLine, callIndex)) + return false; + if (line.Language == "rust" && RustReferenceExtractor.IsDeriveAttributeCallSite(line.PreparedLine, normalizedName, callIndex)) + return false; + if (line.Language == "wgsl" && name.StartsWith('@')) + return false; + if (line.Language == "kotlin" && KotlinReferenceExtractor.IsInfixFunctionDeclarationSite(line.PreparedLine, callIndex)) + return false; + + // Suppress the same-line Java ctor declarator's self-call. CallRegex matches + // `CtorName(` at the declarator once per same-line ctor, but it is a declaration + // site — not a call — so attributing it to `class:CtorName` produces a phantom + // `CtorName|call|class|CtorName` edge. `line.DefinitionNames` does not cover this + // because same-line ctors do not appear in the symbol table. + // 同一行 ctor の宣言子 `CtorName(` は呼び出しではないため CallRegex の対象から除外する。 + if (call.JavaSameLineCtor != null + && callIndex == call.JavaSameLineCtor.Value.NameIndex + && string.Equals(normalizedName, call.JavaSameLineCtor.Value.Synthetic.Name, StringComparison.Ordinal)) + { + return false; + } + + // C# positional patterns such as `case Point(var x, var y):` are type-pattern + // heads, not calls. `CallRegex` still sees `Point(` and would otherwise emit a + // phantom `call` edge alongside the real `type_reference`. + // C# の positional pattern (`case Point(var x, var y):`) は型パターンの先頭であり、 + // 呼び出しではない。`CallRegex` が `Point(` を拾ってしまうため、そのままだと + // 本物の `type_reference` に加えて phantom な `call` エッジが出る。 + var isCSharpPatternHeadCallSite = line.Language == "csharp" + && CSharpReferenceExtractor.IsPatternHeadCallSite(line.PreparedLines, line.LineIndex, line.PreparedLine, callIndex); + if (isCSharpPatternHeadCallSite) + return false; + if (line.Language == "typescript" && TypeScriptReferenceExtractor.IsSatisfiesTypeOperand(line.PreparedLine, callIndex)) + return false; + if (call.Definitions.ShouldSuppressDefinitionCall(normalizedName, name, callIndex)) + return false; + + var callContainer = line.ResolveContainerForCall(callIndex); + if (line.Language == "csharp" + && callIndex + name.Length < line.PreparedLine.Length + && line.PreparedLine.AsSpan(callIndex + name.Length).TrimStart().StartsWith(".", StringComparison.Ordinal)) + { + var receiverLookups = call.Lookups.GetCSharpValueReceiverLookups(); + if (HasCSharpValueReceiverConflict( + normalizedName, + normalizedName, + line.LineNumber, + callIndex, + callContainer, + receiverLookups.ByContainingType, + receiverLookups.ByFunctionStartLine)) + { + var containingType = GetContainingTypeQualifiedName(callContainer); + if (containingType != null + && receiverLookups.ByContainingType.TryGetValue(containingType, out var receiverNames) + && (receiverNames.InstanceNames.Contains(normalizedName) || receiverNames.StaticNames.Contains(normalizedName)) + && call.Lookups.HasCSharpPrivateProperty(containingType, normalizedName)) + { + line.References.RemoveAll(reference => + reference.FileId == line.FileId + && reference.Line == line.LineNumber + && reference.Column == callIndex + 1 + && reference.ReferenceKind == "type_reference" + && string.Equals(reference.SymbolName, normalizedName, StringComparison.Ordinal)); + AddReference( + line.References, + line.Seen, + line.FileId, + $"{containingType}.{normalizedName}", + callIndex, + "reference", + line.Context, + line.LineNumber, + callContainer, + line.Language); + } + + return false; + } + } + if (IsConstructorCallName(line.Language, line.PreparedLine, callIndex)) + { + AddReference( + line.References, + line.Seen, + line.FileId, + normalizedName, + callIndex, + "instantiate", + line.Context, + line.LineNumber, + callContainer, + line.Language, + targetQualifier); + return true; + } + if (line.Language == "rust" + && RustReferenceExtractor.IsLikelyInstantiationCallName(name, normalizedName, line.PreparedLine, callIndex)) + { + AddReference(line.References, line.Seen, line.FileId, normalizedName, callIndex, "instantiate", line.Context, line.LineNumber, callContainer, line.Language); + return true; + } + if (line.Language == "python" && TryGetKnownPythonTypeCall(normalizedName, out var pythonTypeName)) + { + AddReference(line.References, line.Seen, line.FileId, pythonTypeName, callIndex, "instantiate", line.Context, line.LineNumber, callContainer, line.Language); + return true; + } + if (line.Language == "csharp" + && CSharpReferenceExtractor.ShouldSuppressQualifiedCommonMemberCall(line.PreparedLine, normalizedName, callIndex)) + { + return false; + } + if (IsIgnoredCallName(line.Language, name)) + { + if (!(line.Language == "scala" && string.Equals(name, "foreach", StringComparison.Ordinal))) + return false; + } + + // issue #293: reclassify C# attribute / Java/Kotlin/Scala/TypeScript annotation + // usages with arguments so they do not pollute the call-graph as phantom `call` rows. + // issue #293: 引数付きの C# attribute と Java/Kotlin/Scala/TypeScript annotation 使用を + // `call` ではなく専用の種別に分類し、call-graph の phantom エッジを防ぐ。 + var insideCSharpAttributeRange = call.CSharpAttributeRanges != null + && IsInsideCSharpAttributeRange(call.CSharpAttributeRanges, callIndex); + var metadataKind = TryClassifyMetadataReference(line.Language, line.PreparedLine, callIndex, insideCSharpAttributeRange); + if (metadataKind != null) + { + AddReference(line.References, line.Seen, line.FileId, normalizedName, callIndex, metadataKind, line.Context, line.LineNumber, callContainer, line.Language); + if (line.Language == "csharp" + && metadataKind == "attribute" + && CSharpReferenceExtractor.TryGetCallerInfoAttributeTypeName(name, line.PreparedLine, callIndex) is { } callerInfoAttributeTypeName) + { + AddReference( + line.References, + line.Seen, + line.FileId, + callerInfoAttributeTypeName, + callIndex, + "type_reference", + line.Context, + line.LineNumber, + callContainer, + line.Language); + } + return true; + } + + if (line.Language == "kotlin" && KotlinReferenceExtractor.IsConstructorCallName(normalizedName, call.KotlinConstructorTypeNames!)) + { + AddReference(line.References, line.Seen, line.FileId, normalizedName, callIndex, "instantiate", line.Context, line.LineNumber, callContainer); + return true; + } + + if (line.Language is "javascript" or "typescript" + && SymbolExtractor.IsJavaScriptTypeScriptReactHookName(normalizedName)) + { + AddReference(line.References, line.Seen, line.FileId, normalizedName, callIndex, "consumes_hook", line.Context, line.LineNumber, callContainer); + return true; + } + + AddReference( + line.References, + line.Seen, + line.FileId, + normalizedName, + callIndex, + "call", + line.Context, + line.LineNumber, + callContainer, + ScientificNativeReferenceExtractor.Supports(line.Language) ? line.Language : null, + targetQualifier: targetQualifier); + return true; + + bool TryGetKnownPythonTypeCall(string candidate, out string canonicalName) + { + canonicalName = candidate; + var separator = candidate.LastIndexOf('.'); + var leaf = separator >= 0 ? candidate[(separator + 1)..] : candidate; + if (leaf.Length == 0 || !char.IsUpper(leaf, 0)) + return false; + + if (call.Lookups.HasSameFilePythonClass(candidate, leaf)) + { + return true; + } + + return PythonImportBindingResolver.TryResolveImportedTypeCall( + candidate, + line.PreparedLine, + callIndex, + call.Lookups.GetPythonImportedTypeCallLookup(), + out canonicalName); + } + } + + if (line.Language is "batch") + BatchReferenceExtractor.EmitJumpTargetReferences( + line.OriginalLine, + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + + if (line.Language is "assembly") + AssemblyReferenceExtractor.EmitInstructionTargetReferences( + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + + HashSet? matchedCallIndices = null; + HashSet GetMatchedCallIndices() => matchedCallIndices ??= []; + var callScanLine = call.DynamicDeclarativeState?.GetCallScanLine( + line.Language, + line.LineNumber, + line.PreparedLine) ?? line.PreparedLine; + + if (line.Language is "commonlisp" or "racket") + { + LispReferenceExtractor.EmitReferences( + line.Language, + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + line.DefinitionNames); + } + else if (line.Language is "powershell") + { + PowerShellReferenceExtractor.EmitCallReferences(line.PreparedLine, AddCallLikeReference); + PowerShellReferenceExtractor.EmitSplatParameterReferences( + line.PreparedLine, + call.Lookups.GetPowerShellSplatAssignments, + line.LineNumber, + AddPowerShellParameterReference); + } + else if (line.Language is "shell") + { + ShellReferenceExtractor.EmitReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + call.ShellCallableNames, + call.ShellGlobalAliasNames, + line.ResolveContainerForCall, + AddCallLikeReference); + } + else if (line.Language is "assembly") + { + // Assembly line.References are operand-driven, not `name(...)` call syntax. + } + else + { + IReadOnlyList? + dTemplateArgumentCallSpans = null; + if (ScientificNativeReferenceExtractor.Supports(line.Language)) + { + dTemplateArgumentCallSpans = ScientificNativeReferenceExtractor.EmitReferences( + line.Language, + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + AddCallLikeReference, + call.ScientificNativeDependencyLimit, + call.ReportDiagnostic); + } + + var dTemplateArgumentCallSpanIndex = 0; + if (line.Language is not ("tcl" or "prolog")) + { + foreach (Match match in CallRegex.Matches(callScanLine)) + { + var name = match.Groups["name"].Value; + var callIndex = match.Groups["name"].Index; + if (line.Language == "rust" && RustReferenceExtractor.IsRawIdentifierPrefix(line.PreparedLine, callIndex)) + continue; + if (line.Language == "d" + && ScientificNativeReferenceExtractor.IsDTemplateArgumentCall( + dTemplateArgumentCallSpans, + ref dTemplateArgumentCallSpanIndex, + callIndex)) + { + continue; + } + if (line.Language == "ada" + && callIndex > 0 + && line.PreparedLine[callIndex - 1] == '\'') + { + continue; + } + if (line.Language == "objc" && IsObjCSelectorLiteralCall(line.PreparedLine, name, callIndex)) + continue; + if (call.SqlSuppressedCallIndices != null && call.SqlSuppressedCallIndices.Contains(callIndex)) + continue; + if (call.SqlWindowFunctionCallSiteSuppressions != null + && call.SqlWindowFunctionCallSiteSuppressions.Contains((line.LineNumber, callIndex))) + continue; + if (DynamicDeclarativeReferenceExtractor.ShouldSuppressGenericCall( + line.Language, + callScanLine, + name, + callIndex, + line.LineNumber, + call.DynamicDeclarativeState, + line.Language == "groovy" + ? line.ResolveContainerForCall(callIndex) + : null)) + { + continue; + } + GetMatchedCallIndices().Add(callIndex); + if (TryAddCallLikeReference( + name, + callIndex, + ScientificNativeReferenceExtractor.Supports(line.Language) + ? ScientificNativeReferenceExtractor.GetParenthesizedCallTargetQualifier( + line.Language, + line.PreparedLine, + callIndex) + : null)) + { + EmitGenericInvocationTypeArgumentReferences( + line.Language, + line.PreparedLine, + callIndex, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall(callIndex)); + } + if (line.Language == "ruby") + RubyReferenceExtractor.EmitCommandTargetReferences( + name, + callIndex, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + } + } + + if (line.Language == "ruby") + { + RubyReferenceExtractor.EmitAdditionalCallReferences( + line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + GetMatchedCallIndices(), + AddCallLikeReference); + } + else if (line.Language is "perl" or "ambiguous_pl") + { + PerlReferenceExtractor.EmitAdditionalReferences( + line.Language == "ambiguous_pl" ? callScanLine : line.PreparedLine, + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + AddCallLikeReference, + emitArrowCallReferences: line.Language != "ambiguous_pl" + || call.DynamicDeclarativeState?.HasPrologContainer(line.LineNumber) != true); + } + + if (call.DynamicDeclarativeState != null) + { + DynamicDeclarativeReferenceExtractor.EmitAdditionalReferences( + line.Language, + callScanLine, + call.ReferenceStructuralLine, + call.DynamicDeclarativeState, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + AddCallLikeReference); + } + + if (line.Language == "go") + LanguageReferenceExtractionSupport.EmitGoBranchLabelReferences(line.PreparedLine, AddCallLikeReference); + + if (line.Language == "swift") + SwiftReferenceExtractor.EmitTrailingClosureReferences(line.PreparedLine, AddCallLikeReference); + else if (line.Language == "kotlin") + { + KotlinReferenceExtractor.EmitInfixCallReferences( + line.PreparedLine, + line.OriginalLine, + call.KotlinInfixFunctionNames!, + AddCallLikeReference); + KotlinReferenceExtractor.EmitTrailingLambdaReferences(line.PreparedLine, AddCallLikeReference); + } + + if (line.Language == "fsharp") + { + FSharpReferenceExtractor.EmitAdditionalCallReferences( + line.PreparedLine, + AddCallLikeReference); + } + + if (line.Language == "scala") + { + ScalaReferenceExtractor.EmitTrailingBlockCallReferences( + line.PreparedLine, + AddCallLikeReference); + ScalaReferenceExtractor.EmitAdditionalReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + AddCallLikeReference); + } + else if (line.Language == "gradle") + { + void AddGradleDslReference(string name, int callIndex) + { + var normalizedName = NormalizeAtPrefixedIdentifier(name); + var callContainer = line.ResolveContainerForCall(callIndex); + AddReference(line.References, line.Seen, line.FileId, normalizedName, callIndex, "call", line.Context, line.LineNumber, callContainer, line.Language); + } + + GradleReferenceExtractor.EmitDslCallReferences( + line.PreparedLine, + AddGradleDslReference); + } + + if (line.Language == "fortran") + FortranReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference); + else if (line.Language == "pascal") + PascalReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference, line.DefinitionNames); + else if (line.Language == "objc") + ObjectiveCReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.ResolveContainerForCall); + else if (line.Language == "haskell") + HaskellReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference, line.DefinitionNames); + else if (line.Language == "elixir") + ElixirReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference, line.DefinitionNames); + else if (line.Language == "lua") + LuaReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.ResolveContainerForCall, line.DefinitionNames); + else if (line.Language == "smalltalk") + SmalltalkReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference, line.DefinitionNames); + else if (line.Language == "vb") + LanguageReferenceExtractionSupport.EmitAdditionalCallReferences( + "vb", + line.PreparedLine, + line.OriginalLine, + AddCallLikeReference, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + line.DefinitionNames); + + // The flat CallRegex misses nested generic tails like `>>(` because `<[^>\n]+>` + // stops at the first `>`. Add a depth-aware fallback so `Foo>()` and + // `new Dict>()` still emit call/instantiate rows. See issue #263. + // 平坦な CallRegex は `<[^>\n]+>` が最初の `>` で止まるため `>>(` 形を取りこぼす。 + // depth-aware な fallback を足し、`Foo>()` や `new Dict>()` でも + // `call` / `instantiate` を発行する。issue #263 参照。 + if (line.Language is not ("tcl" or "prolog" or "ambiguous_pl") + && MayContainNestedGenericSyntax(line.PreparedLine)) + { + foreach (var candidate in EnumerateNestedGenericCallCandidates(line.PreparedLine, matchedCallIndices ?? EmptyMatchedIndices)) + { + if (TryAddCallLikeReference(candidate.Name, candidate.NameIndex)) + { + EmitGenericInvocationTypeArgumentReferences( + line.Language, + line.PreparedLine, + candidate.NameIndex, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall(candidate.NameIndex)); + } + } + } + } + + if (line.Language == "rust") + { + RustReferenceExtractor.EmitAdditionalCallReferences( + line.PreparedLine, + AddCallLikeReference); + RustReferenceExtractor.EmitAttributeReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + } + } +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreDefinitionState.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreDefinitionState.cs new file mode 100644 index 000000000..79727daf6 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreDefinitionState.cs @@ -0,0 +1,111 @@ +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private sealed class CoreLineDefinitionState( + string language, + string context, + string preparedLine, + HashSet? definitionNames, + StringComparer definitionNamesComparer, + Dictionary>? scientificDefinitionNameIndices, + List? sqlDefinitionLeafSpans) + { + private Dictionary? definitionNameIndices; + + internal bool ShouldSuppressDefinitionCall(string resolvedName, string rawName, int callIndex) + { + if (definitionNames == null) + return false; + + if (language == "csharp") + { + if (context.Contains("when", StringComparison.Ordinal)) + return false; + + // A verbatim definition such as `void @static()` normalizes to `static`. + // Looking up only the normalized name can find an earlier modifier token on + // the same line, so compare the raw declaration token before the shared path. + // `void @static()` のような verbatim 定義は `static` に正規化される。 + // normalized name だけでは同じ行の先行 modifier を拾うため、共通処理より + // 先に raw declaration token の位置を比較する。 + if (rawName.Length > 1 + && rawName[0] == '@' + && definitionNames.Contains(resolvedName) + && preparedLine.IndexOf(rawName, StringComparison.Ordinal) == callIndex) + { + return true; + } + } + + if (scientificDefinitionNameIndices != null + && scientificDefinitionNameIndices.TryGetValue( + resolvedName, + out var scientificDefinitionIndices)) + { + return scientificDefinitionIndices.Contains(callIndex); + } + + if (language == "julia") + { + var targetQualifier = + ScientificNativeReferenceExtractor.GetParenthesizedCallTargetQualifier( + language, + preparedLine, + callIndex); + if (targetQualifier != null) + { + var qualifiedName = $"{targetQualifier}.{resolvedName}"; + var qualifiedDefinitionIndex = + preparedLine.IndexOf(qualifiedName, StringComparison.Ordinal); + if (qualifiedDefinitionIndex >= 0 + && callIndex == qualifiedDefinitionIndex + targetQualifier.Length + 1 + && definitionNames.Contains(qualifiedName)) + { + return true; + } + } + } + + if (language != "sql") + return TryGetDefinitionNameIndex(resolvedName, out var definitionIndex) + && callIndex == definitionIndex; + + return SqlReferenceExtractor.ShouldSuppressDefinitionCall( + sqlDefinitionLeafSpans, + resolvedName, + callIndex); + } + + private bool TryGetDefinitionNameIndex(string resolvedName, out int definitionIndex) + { + definitionIndex = -1; + if (definitionNames == null) + return false; + if (definitionNameIndices != null + && definitionNameIndices.TryGetValue(resolvedName, out definitionIndex)) + { + return true; + } + if (!definitionNames.Contains(resolvedName)) + return false; + + foreach (var definitionName in definitionNames) + { + if (!definitionNamesComparer.Equals(definitionName, resolvedName)) + continue; + + definitionIndex = preparedLine.IndexOf(definitionName, StringComparison.Ordinal); + if (definitionIndex < 0) + return false; + + (definitionNameIndices ??= + new Dictionary(definitionNamesComparer))[definitionName] = + definitionIndex; + return true; + } + + return false; + } + } +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs index 30f9741d6..ce701255c 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs @@ -515,11 +515,18 @@ internal static List ExtractCore(ReferenceExtractionContext req scientificDefinitionNameIndicesByLine?.TryGetValue( lineNumber, out scientificDefinitionNameIndices); - Dictionary? definitionNameIndices = null; List? sqlDefinitionLeafSpans = null; if (language == "sql") sqlDefinitionLeafSpansByLine?.TryGetValue(lineNumber, out sqlDefinitionLeafSpans); var container = containerResolver.Find(lineNumber); + var definitionState = new CoreLineDefinitionState( + language, + context, + preparedLine, + definitionNames, + definitionNamesComparer, + scientificDefinitionNameIndices, + sqlDefinitionLeafSpans); var csharpLineHasWhereClause = language == "csharp" && preparedLine.IndexOf("where", StringComparison.Ordinal) >= 0 && CSharpWhereClauseRegex.IsMatch(preparedLine); @@ -740,93 +747,6 @@ internal static List ExtractCore(ReferenceExtractionContext req ref pendingCSharpMultiLineTypePattern); } - bool ShouldSuppressDefinitionCall(string resolvedName, string rawName, int callIndex) - { - if (definitionNames == null) - return false; - - if (language == "csharp") - { - if (context.Contains("when", StringComparison.Ordinal)) - return false; - - // A verbatim definition such as `void @static()` normalizes to `static`. - // Looking up only the normalized name can find an earlier modifier token on - // the same line, so compare the raw declaration token before the shared path. - // `void @static()` のような verbatim 定義は `static` に正規化される。 - // normalized name だけでは同じ行の先行 modifier を拾うため、共通処理より - // 先に raw declaration token の位置を比較する。 - if (rawName.Length > 1 - && rawName[0] == '@' - && definitionNames.Contains(resolvedName) - && preparedLine.IndexOf(rawName, StringComparison.Ordinal) == callIndex) - { - return true; - } - } - - if (scientificDefinitionNameIndices != null - && scientificDefinitionNameIndices.TryGetValue( - resolvedName, - out var scientificDefinitionIndices)) - { - return scientificDefinitionIndices.Contains(callIndex); - } - - if (language == "julia") - { - var targetQualifier = - ScientificNativeReferenceExtractor.GetParenthesizedCallTargetQualifier( - language, - preparedLine, - callIndex); - if (targetQualifier != null) - { - var qualifiedName = $"{targetQualifier}.{resolvedName}"; - var qualifiedDefinitionIndex = - preparedLine.IndexOf(qualifiedName, StringComparison.Ordinal); - if (qualifiedDefinitionIndex >= 0 - && callIndex == qualifiedDefinitionIndex + targetQualifier.Length + 1 - && definitionNames.Contains(qualifiedName)) - { - return true; - } - } - } - - if (language != "sql") - return TryGetDefinitionNameIndex(resolvedName, out var definitionIndex) - && callIndex == definitionIndex; - - return SqlReferenceExtractor.ShouldSuppressDefinitionCall(sqlDefinitionLeafSpans, resolvedName, callIndex); - } - - bool TryGetDefinitionNameIndex(string resolvedName, out int definitionIndex) - { - definitionIndex = -1; - if (definitionNames == null) - return false; - if (definitionNameIndices != null && definitionNameIndices.TryGetValue(resolvedName, out definitionIndex)) - return true; - if (!definitionNames.Contains(resolvedName)) - return false; - - foreach (var definitionName in definitionNames) - { - if (!definitionNamesComparer.Equals(definitionName, resolvedName)) - continue; - - definitionIndex = preparedLine.IndexOf(definitionName, StringComparison.Ordinal); - if (definitionIndex < 0) - return false; - - (definitionNameIndices ??= new Dictionary(definitionNamesComparer))[definitionName] = definitionIndex; - return true; - } - - return false; - } - // Event subscription/unsubscription (C#) / イベント購読・解除 (C#) if (language is "csharp") { @@ -1201,7 +1121,11 @@ bool TryGetDefinitionNameIndex(string resolvedName, out int definitionIndex) sqlState!, ResolveContainerForCall, name => IsIgnoredCallName(language, name), - (resolvedName, callIndex) => ShouldSuppressDefinitionCall(resolvedName, resolvedName, callIndex)) + (resolvedName, callIndex) => + definitionState.ShouldSuppressDefinitionCall( + resolvedName, + resolvedName, + callIndex)) : null; if (language == "css") @@ -1448,599 +1372,39 @@ bool TryGetDefinitionNameIndex(string resolvedName, out int definitionIndex) container); } - if (language is "javascript" or "typescript") - { - JavaScriptReferenceExtractor.EmitOptionalMemberChainReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - - JavaScriptReferenceExtractor.EmitDiscriminantStringGuardReferences( - referenceStructuralLines[i], - originalLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - - JavaScriptReferenceExtractor.EmitParenlessConstructorReferences( - preparedLine, - preparedLines, - i, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - - void AddCallLikeReference(string name, int callIndex) => - _ = TryAddCallLikeReference( - name, - callIndex, - ScientificNativeReferenceExtractor.Supports(language) - ? ScientificNativeReferenceExtractor.GetParenthesizedCallTargetQualifier( - language, - preparedLine, - callIndex) - : null); - - void AddPowerShellParameterReference(string name, int callIndex) - { - var callContainer = ResolveContainerForCall(callIndex); - AddReference(references, seen, fileId, name, callIndex, "parameter", context, lineNumber, callContainer, language); - } - - bool TryAddCallLikeReference( - string name, - int callIndex, - string? targetQualifier = null) - { - var normalizedName = language == "fsharp" && FSharpReferenceExtractor.IsOperatorCallName(name) - ? $"operator {name}" - : language == "rust" - ? RustReferenceExtractor.NormalizeIdentifier(name) - : NormalizeAtPrefixedIdentifier(name); - - // In tuple-return declarations such as `private static (int Value, string Error) - // Resolve(...)`, CallRegex sees the modifier token as `static(`. It is a C# keyword, - // never a callable identifier, so suppress the phantom edge before graph ingestion. - // `private static (int Value, string Error) Resolve(...)` のような tuple return 宣言では - // CallRegex が modifier を `static(` と誤認する。C# keyword は呼び出し対象にならないため、 - // graph に入る前に phantom edge を除外する。 - if (language == "csharp" && name == "static") - return false; - - if (language == "rust" && RustReferenceExtractor.IsFunctionDeclarationCallSite(preparedLine, callIndex)) - return false; - if (language == "rust" && RustReferenceExtractor.IsDeriveAttributeCallSite(preparedLine, normalizedName, callIndex)) - return false; - if (language == "wgsl" && name.StartsWith('@')) - return false; - if (language == "kotlin" && KotlinReferenceExtractor.IsInfixFunctionDeclarationSite(preparedLine, callIndex)) - return false; - - // Suppress the same-line Java ctor declarator's self-call. CallRegex matches - // `CtorName(` at the declarator once per same-line ctor, but it is a declaration - // site — not a call — so attributing it to `class:CtorName` produces a phantom - // `CtorName|call|class|CtorName` edge. `definitionNames` does not cover this - // because same-line ctors do not appear in the symbol table. - // 同一行 ctor の宣言子 `CtorName(` は呼び出しではないため CallRegex の対象から除外する。 - if (javaSameLineCtor != null - && callIndex == javaSameLineCtor.Value.NameIndex - && string.Equals(normalizedName, javaSameLineCtor.Value.Synthetic.Name, StringComparison.Ordinal)) - { - return false; - } - - // C# positional patterns such as `case Point(var x, var y):` are type-pattern - // heads, not calls. `CallRegex` still sees `Point(` and would otherwise emit a - // phantom `call` edge alongside the real `type_reference`. - // C# の positional pattern (`case Point(var x, var y):`) は型パターンの先頭であり、 - // 呼び出しではない。`CallRegex` が `Point(` を拾ってしまうため、そのままだと - // 本物の `type_reference` に加えて phantom な `call` エッジが出る。 - var isCSharpPatternHeadCallSite = language == "csharp" - && CSharpReferenceExtractor.IsPatternHeadCallSite(preparedLines, i, preparedLine, callIndex); - if (isCSharpPatternHeadCallSite) - return false; - if (language == "typescript" && TypeScriptReferenceExtractor.IsSatisfiesTypeOperand(preparedLine, callIndex)) - return false; - if (ShouldSuppressDefinitionCall(normalizedName, name, callIndex)) - return false; - - var callContainer = ResolveContainerForCall(callIndex); - if (language == "csharp" - && callIndex + name.Length < preparedLine.Length - && preparedLine.AsSpan(callIndex + name.Length).TrimStart().StartsWith(".", StringComparison.Ordinal)) - { - var receiverLookups = lookups.GetCSharpValueReceiverLookups(); - if (HasCSharpValueReceiverConflict( - normalizedName, - normalizedName, - lineNumber, - callIndex, - callContainer, - receiverLookups.ByContainingType, - receiverLookups.ByFunctionStartLine)) - { - var containingType = GetContainingTypeQualifiedName(callContainer); - if (containingType != null - && receiverLookups.ByContainingType.TryGetValue(containingType, out var receiverNames) - && (receiverNames.InstanceNames.Contains(normalizedName) || receiverNames.StaticNames.Contains(normalizedName)) - && lookups.HasCSharpPrivateProperty(containingType, normalizedName)) - { - references.RemoveAll(reference => - reference.FileId == fileId - && reference.Line == lineNumber - && reference.Column == callIndex + 1 - && reference.ReferenceKind == "type_reference" - && string.Equals(reference.SymbolName, normalizedName, StringComparison.Ordinal)); - AddReference( - references, - seen, - fileId, - $"{containingType}.{normalizedName}", - callIndex, - "reference", - context, - lineNumber, - callContainer, - language); - } - - return false; - } - } - if (IsConstructorCallName(language, preparedLine, callIndex)) - { - AddReference( - references, - seen, - fileId, - normalizedName, - callIndex, - "instantiate", - context, - lineNumber, - callContainer, - language, - targetQualifier); - return true; - } - if (language == "rust" - && RustReferenceExtractor.IsLikelyInstantiationCallName(name, normalizedName, preparedLine, callIndex)) - { - AddReference(references, seen, fileId, normalizedName, callIndex, "instantiate", context, lineNumber, callContainer, language); - return true; - } - if (language == "python" && TryGetKnownPythonTypeCall(normalizedName, out var pythonTypeName)) - { - AddReference(references, seen, fileId, pythonTypeName, callIndex, "instantiate", context, lineNumber, callContainer, language); - return true; - } - if (language == "csharp" - && CSharpReferenceExtractor.ShouldSuppressQualifiedCommonMemberCall(preparedLine, normalizedName, callIndex)) - { - return false; - } - if (IsIgnoredCallName(language, name)) - { - if (!(language == "scala" && string.Equals(name, "foreach", StringComparison.Ordinal))) - return false; - } - - // issue #293: reclassify C# attribute / Java/Kotlin/Scala/TypeScript annotation - // usages with arguments so they do not pollute the call-graph as phantom `call` rows. - // issue #293: 引数付きの C# attribute と Java/Kotlin/Scala/TypeScript annotation 使用を - // `call` ではなく専用の種別に分類し、call-graph の phantom エッジを防ぐ。 - var insideCSharpAttributeRange = csharpAttrRangesOnLine != null - && IsInsideCSharpAttributeRange(csharpAttrRangesOnLine, callIndex); - var metadataKind = TryClassifyMetadataReference(language, preparedLine, callIndex, insideCSharpAttributeRange); - if (metadataKind != null) - { - AddReference(references, seen, fileId, normalizedName, callIndex, metadataKind, context, lineNumber, callContainer, language); - if (language == "csharp" - && metadataKind == "attribute" - && CSharpReferenceExtractor.TryGetCallerInfoAttributeTypeName(name, preparedLine, callIndex) is { } callerInfoAttributeTypeName) - { - AddReference( - references, - seen, - fileId, - callerInfoAttributeTypeName, - callIndex, - "type_reference", - context, - lineNumber, - callContainer, - language); - } - return true; - } - - if (language == "kotlin" && KotlinReferenceExtractor.IsConstructorCallName(normalizedName, kotlinConstructorTypeNames!)) - { - AddReference(references, seen, fileId, normalizedName, callIndex, "instantiate", context, lineNumber, callContainer); - return true; - } - - if (language is "javascript" or "typescript" - && SymbolExtractor.IsJavaScriptTypeScriptReactHookName(normalizedName)) - { - AddReference(references, seen, fileId, normalizedName, callIndex, "consumes_hook", context, lineNumber, callContainer); - return true; - } - - AddReference( - references, - seen, - fileId, - normalizedName, - callIndex, - "call", - context, - lineNumber, - callContainer, - ScientificNativeReferenceExtractor.Supports(language) ? language : null, - targetQualifier: targetQualifier); - return true; - - bool TryGetKnownPythonTypeCall(string candidate, out string canonicalName) - { - canonicalName = candidate; - var separator = candidate.LastIndexOf('.'); - var leaf = separator >= 0 ? candidate[(separator + 1)..] : candidate; - if (leaf.Length == 0 || !char.IsUpper(leaf, 0)) - return false; - - if (lookups.HasSameFilePythonClass(candidate, leaf)) - { - return true; - } - - return PythonImportBindingResolver.TryResolveImportedTypeCall( - candidate, - preparedLine, - callIndex, - lookups.GetPythonImportedTypeCallLookup(), - out canonicalName); - } - } - - if (language is "batch") - BatchReferenceExtractor.EmitJumpTargetReferences( - originalLine, - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - - if (language is "assembly") - AssemblyReferenceExtractor.EmitInstructionTargetReferences( - originalLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - - HashSet? matchedCallIndices = null; - HashSet GetMatchedCallIndices() => matchedCallIndices ??= []; - var callScanLine = dynamicDeclarativeState?.GetCallScanLine( + var lineContext = new CoreReferenceLineContext( + fileId, language, + lines, + preparedLines, + i, + preparedLine, + originalLine, + context, lineNumber, - preparedLine) ?? preparedLine; - - if (language is "commonlisp" or "racket") - { - LispReferenceExtractor.EmitReferences( - language, - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - definitionNames); - } - else if (language is "powershell") - { - PowerShellReferenceExtractor.EmitCallReferences(preparedLine, AddCallLikeReference); - PowerShellReferenceExtractor.EmitSplatParameterReferences( - preparedLine, - lookups.GetPowerShellSplatAssignments, - lineNumber, - AddPowerShellParameterReference); - } - else if (language is "shell") - { - ShellReferenceExtractor.EmitReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - shellCallableNames, - shellGlobalAliasNames, - ResolveContainerForCall, - AddCallLikeReference); - } - else if (language is "assembly") - { - // Assembly references are operand-driven, not `name(...)` call syntax. - } - else - { - IReadOnlyList? - dTemplateArgumentCallSpans = null; - if (ScientificNativeReferenceExtractor.Supports(language)) - { - dTemplateArgumentCallSpans = ScientificNativeReferenceExtractor.EmitReferences( - language, - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - AddCallLikeReference, - scientificNativeDependencyLimit, - request.ReportDiagnostic); - } - - var dTemplateArgumentCallSpanIndex = 0; - if (language is not ("tcl" or "prolog")) - { - foreach (Match match in CallRegex.Matches(callScanLine)) - { - var name = match.Groups["name"].Value; - var callIndex = match.Groups["name"].Index; - if (language == "rust" && RustReferenceExtractor.IsRawIdentifierPrefix(preparedLine, callIndex)) - continue; - if (language == "d" - && ScientificNativeReferenceExtractor.IsDTemplateArgumentCall( - dTemplateArgumentCallSpans, - ref dTemplateArgumentCallSpanIndex, - callIndex)) - { - continue; - } - if (language == "ada" - && callIndex > 0 - && preparedLine[callIndex - 1] == '\'') - { - continue; - } - if (language == "objc" && IsObjCSelectorLiteralCall(preparedLine, name, callIndex)) - continue; - if (sqlSuppressedCallIndices != null && sqlSuppressedCallIndices.Contains(callIndex)) - continue; - if (sqlWindowFunctionCallSiteSuppressions != null - && sqlWindowFunctionCallSiteSuppressions.Contains((lineNumber, callIndex))) - continue; - if (DynamicDeclarativeReferenceExtractor.ShouldSuppressGenericCall( - language, - callScanLine, - name, - callIndex, - lineNumber, - dynamicDeclarativeState, - language == "groovy" - ? ResolveContainerForCall(callIndex) - : null)) - { - continue; - } - GetMatchedCallIndices().Add(callIndex); - if (TryAddCallLikeReference( - name, - callIndex, - ScientificNativeReferenceExtractor.Supports(language) - ? ScientificNativeReferenceExtractor.GetParenthesizedCallTargetQualifier( - language, - preparedLine, - callIndex) - : null)) - { - EmitGenericInvocationTypeArgumentReferences( - language, - preparedLine, - callIndex, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall(callIndex)); - } - if (language == "ruby") - RubyReferenceExtractor.EmitCommandTargetReferences( - name, - callIndex, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - } - - if (language == "ruby") - { - RubyReferenceExtractor.EmitAdditionalCallReferences( - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - GetMatchedCallIndices(), - AddCallLikeReference); - } - else if (language is "perl" or "ambiguous_pl") - { - PerlReferenceExtractor.EmitAdditionalReferences( - language == "ambiguous_pl" ? callScanLine : preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - AddCallLikeReference, - emitArrowCallReferences: language != "ambiguous_pl" - || dynamicDeclarativeState?.HasPrologContainer(lineNumber) != true); - } - - if (dynamicDeclarativeState != null) - { - DynamicDeclarativeReferenceExtractor.EmitAdditionalReferences( - language, - callScanLine, - referenceStructuralLines[i], - dynamicDeclarativeState, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - AddCallLikeReference); - } - - if (language == "go") - LanguageReferenceExtractionSupport.EmitGoBranchLabelReferences(preparedLine, AddCallLikeReference); - - if (language == "swift") - SwiftReferenceExtractor.EmitTrailingClosureReferences(preparedLine, AddCallLikeReference); - else if (language == "kotlin") - { - KotlinReferenceExtractor.EmitInfixCallReferences( - preparedLine, - originalLine, - kotlinInfixFunctionNames!, - AddCallLikeReference); - KotlinReferenceExtractor.EmitTrailingLambdaReferences(preparedLine, AddCallLikeReference); - } - - if (language == "fsharp") - { - FSharpReferenceExtractor.EmitAdditionalCallReferences( - preparedLine, - AddCallLikeReference); - } - - if (language == "scala") - { - ScalaReferenceExtractor.EmitTrailingBlockCallReferences( - preparedLine, - AddCallLikeReference); - ScalaReferenceExtractor.EmitAdditionalReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - AddCallLikeReference); - } - else if (language == "gradle") - { - void AddGradleDslReference(string name, int callIndex) - { - var normalizedName = NormalizeAtPrefixedIdentifier(name); - var callContainer = ResolveContainerForCall(callIndex); - AddReference(references, seen, fileId, normalizedName, callIndex, "call", context, lineNumber, callContainer, language); - } - - GradleReferenceExtractor.EmitDslCallReferences( - preparedLine, - AddGradleDslReference); - } - - if (language == "fortran") - FortranReferenceExtractor.EmitAdditionalCallReferences(preparedLine, AddCallLikeReference); - else if (language == "pascal") - PascalReferenceExtractor.EmitAdditionalCallReferences(preparedLine, AddCallLikeReference, definitionNames); - else if (language == "objc") - ObjectiveCReferenceExtractor.EmitAdditionalCallReferences(preparedLine, AddCallLikeReference, references, seen, fileId, context, lineNumber, ResolveContainerForCall); - else if (language == "haskell") - HaskellReferenceExtractor.EmitAdditionalCallReferences(preparedLine, AddCallLikeReference, definitionNames); - else if (language == "elixir") - ElixirReferenceExtractor.EmitAdditionalCallReferences(preparedLine, AddCallLikeReference, definitionNames); - else if (language == "lua") - LuaReferenceExtractor.EmitAdditionalCallReferences(preparedLine, AddCallLikeReference, references, seen, fileId, context, lineNumber, ResolveContainerForCall, definitionNames); - else if (language == "smalltalk") - SmalltalkReferenceExtractor.EmitAdditionalCallReferences(preparedLine, AddCallLikeReference, definitionNames); - else if (language == "vb") - LanguageReferenceExtractionSupport.EmitAdditionalCallReferences( - "vb", - preparedLine, - originalLine, - AddCallLikeReference, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - definitionNames); - - // The flat CallRegex misses nested generic tails like `>>(` because `<[^>\n]+>` - // stops at the first `>`. Add a depth-aware fallback so `Foo>()` and - // `new Dict>()` still emit call/instantiate rows. See issue #263. - // 平坦な CallRegex は `<[^>\n]+>` が最初の `>` で止まるため `>>(` 形を取りこぼす。 - // depth-aware な fallback を足し、`Foo>()` や `new Dict>()` でも - // `call` / `instantiate` を発行する。issue #263 参照。 - if (language is not ("tcl" or "prolog" or "ambiguous_pl") - && MayContainNestedGenericSyntax(preparedLine)) - { - foreach (var candidate in EnumerateNestedGenericCallCandidates(preparedLine, matchedCallIndices ?? EmptyMatchedIndices)) - { - if (TryAddCallLikeReference(candidate.Name, candidate.NameIndex)) - { - EmitGenericInvocationTypeArgumentReferences( - language, - preparedLine, - candidate.NameIndex, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall(candidate.NameIndex)); - } - } - } - } + references, + seen, + container, + definitionNames, + ResolveContainerForCall); - if (language == "rust") - { - RustReferenceExtractor.EmitAdditionalCallReferences(preparedLine, AddCallLikeReference); - RustReferenceExtractor.EmitAttributeReferences(preparedLine, references, seen, fileId, context, lineNumber, container); - } + var callContext = new CoreCallReferenceContext( + lineContext, + lookups, + javaSameLineCtor, + csharpAttrRangesOnLine, + kotlinConstructorTypeNames, + kotlinInfixFunctionNames, + shellCallableNames, + shellGlobalAliasNames, + dynamicDeclarativeState, + referenceStructuralLines[i], + scientificNativeDependencyLimit, + request.ReportDiagnostic, + sqlSuppressedCallIndices, + sqlWindowFunctionCallSiteSuppressions, + definitionState); + EmitCoreCallReferences(callContext); if (language == "csharp") { @@ -2129,22 +1493,6 @@ void AddGradleDslReference(string name, int callIndex) ResolveContainerForCall); } - var lineContext = new CoreReferenceLineContext( - fileId, - language, - lines, - preparedLines, - i, - preparedLine, - originalLine, - context, - lineNumber, - references, - seen, - container, - definitionNames, - ResolveContainerForCall); - // issue #268: JS/TS tagged template literal call sites. The structural masker // already located each template opener and captured its preceding tag identifier; // emit one `call` row per hit so `gql\`...\`` / `styled.div\`...\`` / `sql\`...${x}...\`` From 5adde21ad327038a92c7415a3c5907bb04c65345 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 21:17:41 +0900 Subject: [PATCH 068/101] Split specialized line reference emitters --- .../ReferenceExtractor.CoreExtraction.cs | 406 ++---------------- ...ReferenceExtractor.CoreSpecializedLines.cs | 384 +++++++++++++++++ 2 files changed, 413 insertions(+), 377 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CoreSpecializedLines.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs index ce701255c..af4789c5f 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs @@ -650,6 +650,22 @@ internal static List ExtractCore(ReferenceExtractionContext req return ResolveContainerForCall(column); } + var lineContext = new CoreReferenceLineContext( + fileId, + language, + lines, + preparedLines, + i, + preparedLine, + originalLine, + context, + lineNumber, + references, + seen, + container, + definitionNames, + ResolveContainerForCall); + if (shaderState is not null) { ShaderReferenceExtractor.EmitLineReferences( @@ -665,70 +681,7 @@ internal static List ExtractCore(ReferenceExtractionContext req } if (isJsxFile && (language is "javascript" or "typescript")) - { - var jsxTypeArgumentSkipUntil = -1; - foreach (Match match in JsxElementOpenRegex.Matches(preparedLine)) - { - if (match.Index < jsxTypeArgumentSkipUntil) - continue; - - var fullName = match.Groups["name"].Value; - var nameIndex = match.Groups["name"].Index; - var jsxContainer = ResolveContainerForCall(nameIndex); - var firstDotIndex = fullName.IndexOf('.'); - var tagEndIndex = nameIndex + fullName.Length; - - AddReference( - references, - seen, - fileId, - firstDotIndex < 0 ? fullName : fullName[..firstDotIndex], - nameIndex, - "call", - context, - lineNumber, - jsxContainer); - - var dotIndex = fullName.LastIndexOf('.'); - if (dotIndex > 0 && dotIndex + 1 < fullName.Length) - { - AddReference( - references, - seen, - fileId, - fullName[(dotIndex + 1)..], - nameIndex + dotIndex + 1, - "call", - context, - lineNumber, - jsxContainer); - } - - if (language == "typescript") - { - var genericStart = SkipWhitespace(preparedLine, tagEndIndex); - if (genericStart < preparedLine.Length && preparedLine[genericStart] == '<') - { - var genericEnd = genericStart; - if (TrySkipTypeScriptJsxTypeArguments(preparedLine, ref genericEnd) - && genericEnd > genericStart + 2) - { - jsxTypeArgumentSkipUntil = Math.Max(jsxTypeArgumentSkipUntil, genericEnd); - AddTypeExpressionSegments( - references, - seen, - fileId, - preparedLine.Substring(genericStart + 1, genericEnd - genericStart - 2), - genericStart + 1, - context, - lineNumber, - jsxContainer, - "typescript"); - } - } - } - } - } + EmitJsxElementReferences(lineContext); if (language == "csharp") { @@ -1061,84 +1014,19 @@ internal static List ExtractCore(ReferenceExtractionContext req continue; } - if (language == "terraform") - { - TerraformReferenceExtractor.Emit( - preparedLine, - context, - lineNumber, - references, - seen, - fileId, - definitionNames, - container); - } - - if (language == "dockerfile") - { - DockerfileReferenceExtractor.EmitStageReferences( - preparedLine, - originalLine, - context, - lineNumber, - references, - seen, - fileId, - dockerfileStageNames, - container); - DockerfileReferenceExtractor.EmitVariableReferences( - preparedLine, - context, - lineNumber, - references, - seen, - fileId, - dockerfileVariableNames, - container); - } + EmitInfrastructureLineReferences( + lineContext, + dockerfileStageNames, + dockerfileVariableNames, + cobolCallableSymbols); - if (language == "cobol") - { - CobolReferenceExtractor.Emit( - lines[i], - context, - lineNumber, - references, - seen, - fileId, - container, - cobolCallableSymbols); - } + var sqlSuppressedCallIndices = EmitSqlLineReferences( + lineContext, + structuralLines[i], + sqlState, + definitionState); - var sqlSuppressedCallIndices = language is "sql" - ? SqlReferenceExtractor.Emit( - structuralLines[i], - context, - lineNumber, - references, - seen, - fileId, - sqlState!, - ResolveContainerForCall, - name => IsIgnoredCallName(language, name), - (resolvedName, callIndex) => - definitionState.ShouldSuppressDefinitionCall( - resolvedName, - resolvedName, - callIndex)) - : null; - if (language == "css") - { - CssReferenceExtractor.EmitScss( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - } // C# / Java parenless initializers: `new T { ... }` / `new T { ... }` / // `new T[] { ... }` etc. CallRegex requires a trailing `(`, so these forms slip @@ -1148,245 +1036,9 @@ internal static List ExtractCore(ReferenceExtractionContext req // 括弧省略の C# / Java インスタンス化 (`new T { ... }` 等) は CallRegex で拾えないため、 // 専用パスで `instantiate` を発行する。issue #286 参照。 if (language is "csharp" or "java") - { - HashSet? matchedInitializerIndices = null; - var mayContainNestedGenericInitializer = language == "csharp" && MayContainNestedGenericSyntax(preparedLine); - foreach (Match match in CSharpJavaInitializerRegex.Matches(preparedLine)) - { - var rawName = match.Groups["name"].Value; - var nameIndex = match.Groups["name"].Index; - (matchedInitializerIndices ??= []).Add(nameIndex); - if (ShouldSkipInitializerName(language, rawName)) - continue; - // Do NOT skip when the type is defined in the same file — the CallRegex - // `IsConstructorCallName` path emits `instantiate` without a definitionNames - // filter, so `new Foo { ... }` and `new Foo()` should behave the same way. - // 同一ファイル内定義でもスキップしない。`IsConstructorCallName` 経路の - // `instantiate` が同様の扱いをしているため、括弧あり/なしで挙動を揃える。 - var initContainer = ResolveContainerForCall(nameIndex); - var name = language == "csharp" ? NormalizeCSharpIdentifier(rawName) : rawName; - AddReference(references, seen, fileId, name, nameIndex, "instantiate", context, lineNumber, initContainer, language); - } - - // The initializer regex has the same one-level generic ceiling as CallRegex, - // so nested generic targets like `new Dictionary> { ... }` - // need a depth-aware fallback to keep the outer `instantiate` edge. - // initializer regex も CallRegex と同じく generic を 1 段までしか見ないため、 - // `new Dictionary> { ... }` の外側型は depth-aware fallback - // で補って `instantiate` を落とさないようにする。 - if (mayContainNestedGenericInitializer) - { - foreach (var candidate in EnumerateNestedGenericInitializerCandidates( - preparedLine, - matchedInitializerIndices ?? EmptyMatchedIndices, - requireOpeningBrace: true)) - { - if (ShouldSkipInitializerName(language, candidate.Name)) - continue; - - var initContainer = ResolveContainerForCall(candidate.NameIndex); - AddReference( - references, - seen, - fileId, - candidate.Name, - candidate.NameIndex, - "instantiate", - context, - lineNumber, - initContainer, - language); - } - } - - // Allman-style multi-line form: `new T` at end of current line with the - // opening `{` on the next non-blank prepared line. Peek forward to confirm - // before emitting, so trailing `new T` patterns that are not followed by `{` - // (e.g. `var a = new Foo\n;` or `var a = new Foo\n(1, 2);`) do not produce - // phantom `instantiate` rows. - // Allman スタイルの多行形式: 現在行末の `new T` と次の非空 prepared line 冒頭の - // `{` を合わせて 1 つの instantiate として扱う。`{` が続かない場合(`;` や `(` が - // 後続する等)には幻行を出さないため、peek で確認してから発行する。 - var trailingMatch = CSharpJavaInitializerTrailingRegex.Match(preparedLine); - var peek = i + 1; - while (peek < preparedLines.Length && string.IsNullOrWhiteSpace(preparedLines[peek])) - peek++; - if (peek < preparedLines.Length) - { - var nextContent = preparedLines[peek].TrimStart(); - if (nextContent.Length > 0 && nextContent[0] == '{') - { - if (trailingMatch.Success) - { - var rawName = trailingMatch.Groups["name"].Value; - var nameIndex = trailingMatch.Groups["name"].Index; - (matchedInitializerIndices ??= []).Add(nameIndex); - if (!ShouldSkipInitializerName(language, rawName)) - { - var initContainer = ResolveContainerForCall(nameIndex); - var name = language == "csharp" ? NormalizeCSharpIdentifier(rawName) : rawName; - AddReference(references, seen, fileId, name, nameIndex, "instantiate", context, lineNumber, initContainer); - } - - } - - if (mayContainNestedGenericInitializer) - { - foreach (var candidate in EnumerateNestedGenericInitializerCandidates( - preparedLine, - matchedInitializerIndices ?? EmptyMatchedIndices, - requireOpeningBrace: false)) - { - if (ShouldSkipInitializerName(language, candidate.Name)) - continue; - - var initContainer = ResolveContainerForCall(candidate.NameIndex); - var name = language == "csharp" ? NormalizeCSharpIdentifier(candidate.Name) : candidate.Name; - AddReference( - references, - seen, - fileId, - name, - candidate.NameIndex, - "instantiate", - context, - lineNumber, - initContainer); - } - } - } - } - } - - if (language == "css") - { - CssReferenceExtractor.EmitScss( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - } - - if (language == "php") - { - PhpReferenceExtractor.EmitStaticAccessReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - - PhpReferenceExtractor.EmitInstanceofReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - - PhpReferenceExtractor.EmitCatchTypeReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - - PhpReferenceExtractor.EmitReturnTypeReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - - PhpReferenceExtractor.EmitParameterTypeReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - - PhpReferenceExtractor.EmitPropertyTypeReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); + EmitParenlessInitializerReferences(lineContext); - PhpReferenceExtractor.EmitInheritanceTypeReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - - PhpReferenceExtractor.EmitUseTypeReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - - PhpReferenceExtractor.EmitUseFunctionReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - - PhpReferenceExtractor.EmitUseConstReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - - PhpReferenceExtractor.EmitObjectMemberAccessReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - } - - var lineContext = new CoreReferenceLineContext( - fileId, - language, - lines, - preparedLines, - i, - preparedLine, - originalLine, - context, - lineNumber, - references, - seen, - container, - definitionNames, - ResolveContainerForCall); + EmitPhpAndScssLineReferences(lineContext); var callContext = new CoreCallReferenceContext( lineContext, diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreSpecializedLines.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreSpecializedLines.cs new file mode 100644 index 000000000..3725dd262 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreSpecializedLines.cs @@ -0,0 +1,384 @@ +using System.Text.RegularExpressions; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static void EmitJsxElementReferences(CoreReferenceLineContext line) + { + var jsxTypeArgumentSkipUntil = -1; + foreach (Match match in JsxElementOpenRegex.Matches(line.PreparedLine)) + { + if (match.Index < jsxTypeArgumentSkipUntil) + continue; + + var fullName = match.Groups["name"].Value; + var nameIndex = match.Groups["name"].Index; + var jsxContainer = line.ResolveContainerForCall(nameIndex); + var firstDotIndex = fullName.IndexOf('.'); + var tagEndIndex = nameIndex + fullName.Length; + + AddReference( + line.References, + line.Seen, + line.FileId, + firstDotIndex < 0 ? fullName : fullName[..firstDotIndex], + nameIndex, + "call", + line.Context, + line.LineNumber, + jsxContainer); + + var dotIndex = fullName.LastIndexOf('.'); + if (dotIndex > 0 && dotIndex + 1 < fullName.Length) + { + AddReference( + line.References, + line.Seen, + line.FileId, + fullName[(dotIndex + 1)..], + nameIndex + dotIndex + 1, + "call", + line.Context, + line.LineNumber, + jsxContainer); + } + + if (line.Language == "typescript") + { + var genericStart = SkipWhitespace(line.PreparedLine, tagEndIndex); + if (genericStart < line.PreparedLine.Length && line.PreparedLine[genericStart] == '<') + { + var genericEnd = genericStart; + if (TrySkipTypeScriptJsxTypeArguments(line.PreparedLine, ref genericEnd) + && genericEnd > genericStart + 2) + { + jsxTypeArgumentSkipUntil = Math.Max(jsxTypeArgumentSkipUntil, genericEnd); + AddTypeExpressionSegments( + line.References, + line.Seen, + line.FileId, + line.PreparedLine.Substring(genericStart + 1, genericEnd - genericStart - 2), + genericStart + 1, + line.Context, + line.LineNumber, + jsxContainer, + "typescript"); + } + } + } + } + } + + private static void EmitInfrastructureLineReferences( + CoreReferenceLineContext line, + HashSet? dockerfileStageNames, + HashSet? dockerfileVariableNames, + IReadOnlyList? cobolCallableSymbols) + { + if (line.Language == "terraform") + { + TerraformReferenceExtractor.Emit( + line.PreparedLine, + line.Context, + line.LineNumber, + line.References, + line.Seen, + line.FileId, + line.DefinitionNames, + line.Container); + } + + if (line.Language == "dockerfile") + { + DockerfileReferenceExtractor.EmitStageReferences( + line.PreparedLine, + line.OriginalLine, + line.Context, + line.LineNumber, + line.References, + line.Seen, + line.FileId, + dockerfileStageNames, + line.Container); + DockerfileReferenceExtractor.EmitVariableReferences( + line.PreparedLine, + line.Context, + line.LineNumber, + line.References, + line.Seen, + line.FileId, + dockerfileVariableNames, + line.Container); + } + + if (line.Language == "cobol") + { + CobolReferenceExtractor.Emit( + line.Lines[line.LineIndex], + line.Context, + line.LineNumber, + line.References, + line.Seen, + line.FileId, + line.Container, + cobolCallableSymbols); + } + } + + private static HashSet? EmitSqlLineReferences( + CoreReferenceLineContext line, + string structuralLine, + SqlReferenceExtractor.State? sqlState, + CoreLineDefinitionState definitionState) + { + if (line.Language != "sql") + return null; + + return SqlReferenceExtractor.Emit( + structuralLine, + line.Context, + line.LineNumber, + line.References, + line.Seen, + line.FileId, + sqlState!, + line.ResolveContainerForCall, + name => IsIgnoredCallName(line.Language, name), + (resolvedName, callIndex) => + definitionState.ShouldSuppressDefinitionCall( + resolvedName, + resolvedName, + callIndex)); + } + + private static void EmitParenlessInitializerReferences(CoreReferenceLineContext line) + { + + HashSet? matchedInitializerIndices = null; + var mayContainNestedGenericInitializer = line.Language == "csharp" && MayContainNestedGenericSyntax(line.PreparedLine); + foreach (Match match in CSharpJavaInitializerRegex.Matches(line.PreparedLine)) + { + var rawName = match.Groups["name"].Value; + var nameIndex = match.Groups["name"].Index; + (matchedInitializerIndices ??= []).Add(nameIndex); + if (ShouldSkipInitializerName(line.Language, rawName)) + continue; + // Do NOT skip when the type is defined in the same file — the CallRegex + // `IsConstructorCallName` path emits `instantiate` without a line.DefinitionNames + // filter, so `new Foo { ... }` and `new Foo()` should behave the same way. + // 同一ファイル内定義でもスキップしない。`IsConstructorCallName` 経路の + // `instantiate` が同様の扱いをしているため、括弧あり/なしで挙動を揃える。 + var initContainer = line.ResolveContainerForCall(nameIndex); + var name = line.Language == "csharp" ? NormalizeCSharpIdentifier(rawName) : rawName; + AddReference(line.References, line.Seen, line.FileId, name, nameIndex, "instantiate", line.Context, line.LineNumber, initContainer, line.Language); + } + + // The initializer regex has the same one-level generic ceiling as CallRegex, + // so nested generic targets like `new Dictionary> { ... }` + // need a depth-aware fallback to keep the outer `instantiate` edge. + // initializer regex も CallRegex と同じく generic を 1 段までしか見ないため、 + // `new Dictionary> { ... }` の外側型は depth-aware fallback + // で補って `instantiate` を落とさないようにする。 + if (mayContainNestedGenericInitializer) + { + foreach (var candidate in EnumerateNestedGenericInitializerCandidates( + line.PreparedLine, + matchedInitializerIndices ?? EmptyMatchedIndices, + requireOpeningBrace: true)) + { + if (ShouldSkipInitializerName(line.Language, candidate.Name)) + continue; + + var initContainer = line.ResolveContainerForCall(candidate.NameIndex); + AddReference( + line.References, + line.Seen, + line.FileId, + candidate.Name, + candidate.NameIndex, + "instantiate", + line.Context, + line.LineNumber, + initContainer, + line.Language); + } + } + + // Allman-style multi-line form: `new T` at end of current line with the + // opening `{` on the next non-blank prepared line. Peek forward to confirm + // before emitting, so trailing `new T` patterns that are not followed by `{` + // (e.g. `var a = new Foo\n;` or `var a = new Foo\n(1, 2);`) do not produce + // phantom `instantiate` rows. + // Allman スタイルの多行形式: 現在行末の `new T` と次の非空 prepared line 冒頭の + // `{` を合わせて 1 つの instantiate として扱う。`{` が続かない場合(`;` や `(` が + // 後続する等)には幻行を出さないため、peek で確認してから発行する。 + var trailingMatch = CSharpJavaInitializerTrailingRegex.Match(line.PreparedLine); + var peek = line.LineIndex + 1; + while (peek < line.PreparedLines.Length && string.IsNullOrWhiteSpace(line.PreparedLines[peek])) + peek++; + if (peek < line.PreparedLines.Length) + { + var nextContent = line.PreparedLines[peek].TrimStart(); + if (nextContent.Length > 0 && nextContent[0] == '{') + { + if (trailingMatch.Success) + { + var rawName = trailingMatch.Groups["name"].Value; + var nameIndex = trailingMatch.Groups["name"].Index; + (matchedInitializerIndices ??= []).Add(nameIndex); + if (!ShouldSkipInitializerName(line.Language, rawName)) + { + var initContainer = line.ResolveContainerForCall(nameIndex); + var name = line.Language == "csharp" ? NormalizeCSharpIdentifier(rawName) : rawName; + AddReference(line.References, line.Seen, line.FileId, name, nameIndex, "instantiate", line.Context, line.LineNumber, initContainer); + } + + } + + if (mayContainNestedGenericInitializer) + { + foreach (var candidate in EnumerateNestedGenericInitializerCandidates( + line.PreparedLine, + matchedInitializerIndices ?? EmptyMatchedIndices, + requireOpeningBrace: false)) + { + if (ShouldSkipInitializerName(line.Language, candidate.Name)) + continue; + + var initContainer = line.ResolveContainerForCall(candidate.NameIndex); + var name = line.Language == "csharp" ? NormalizeCSharpIdentifier(candidate.Name) : candidate.Name; + AddReference( + line.References, + line.Seen, + line.FileId, + name, + candidate.NameIndex, + "instantiate", + line.Context, + line.LineNumber, + initContainer); + } + } + } + } + } + + private static void EmitPhpAndScssLineReferences(CoreReferenceLineContext line) + { + if (line.Language == "css") + { + CssReferenceExtractor.EmitScss( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + } + + if (line.Language == "php") + { + PhpReferenceExtractor.EmitStaticAccessReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + + PhpReferenceExtractor.EmitInstanceofReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + + PhpReferenceExtractor.EmitCatchTypeReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + + PhpReferenceExtractor.EmitReturnTypeReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + + PhpReferenceExtractor.EmitParameterTypeReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + + PhpReferenceExtractor.EmitPropertyTypeReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + + PhpReferenceExtractor.EmitInheritanceTypeReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + + PhpReferenceExtractor.EmitUseTypeReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + + PhpReferenceExtractor.EmitUseFunctionReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + + PhpReferenceExtractor.EmitUseConstReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + + PhpReferenceExtractor.EmitObjectMemberAccessReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + } + } +} From 4c5362d3e1808b8b78649fd663a3681c230c3705 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 21:21:33 +0900 Subject: [PATCH 069/101] Separate core type reference dispatch --- .../ReferenceExtractor.CoreExtraction.cs | 356 ++--------------- .../ReferenceExtractor.CoreTypeReferences.cs | 378 ++++++++++++++++++ 2 files changed, 407 insertions(+), 327 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CoreTypeReferences.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs index af4789c5f..651261d1b 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs @@ -683,333 +683,35 @@ internal static List ExtractCore(ReferenceExtractionContext req if (isJsxFile && (language is "javascript" or "typescript")) EmitJsxElementReferences(lineContext); - if (language == "csharp") - { - CSharpReferenceExtractor.AdvanceMultiLineTypePatternState( - preparedLine, - context, - lineNumber, - ResolveContainerForCall, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - lookups.HasActiveSameFileCSharpTypeCandidate, - references, - seen, - fileId, - ref pendingCSharpMultiLineTypePattern); - } - - // Event subscription/unsubscription (C#) / イベント購読・解除 (C#) - if (language is "csharp") - { - foreach (Match match in EventSubscriptionRegex.Matches(preparedLine)) - { - var eventContainer = ResolveContainerForCall(match.Groups["name"].Index); - AddReference(references, seen, fileId, match, "subscribe", context, lineNumber, eventContainer); - } - } - - // Constructor chain-call rewrites: C# `: this(...)` / `: base(...)`, Java `this(...)` / `super(...)`, - // and Kotlin `constructor(...) : this(...)` / `: super(...)`. - // コンストラクタ連鎖呼び出しの書き換え - if (language is "csharp") - { - CSharpReferenceExtractor.EmitCtorChainReferences( - preparedLine, lookups.GetEnclosingTypeCandidates, containerCandidates, - structuralLines, references, seen, fileId, context, lineNumber, container); - } - else if (language is "java") - { - JavaReferenceExtractor.EmitCtorChainReferences( - preparedLine, lookups.GetEnclosingTypeCandidates, symbols, structuralLines, - references, seen, fileId, context, lineNumber, container); - } - else if (language is "kotlin") - { - KotlinReferenceExtractor.EmitCtorDelegationReferences( - preparedLine, lookups.GetEnclosingTypeCandidates, symbols, structuralLines, - references, seen, fileId, context, lineNumber, container); - } - - // Compile-time type/member references that CallRegex cannot see because the - // argument has no trailing `(` of its own. See issue #253. - // 末尾の `(` を持たず CallRegex では取れないコンパイル時の型/メンバ参照。issue #253 参照。 - if (language is "csharp") - { - var csharpGenericParameterNames = CollectCSharpGenericParameterNamesForDeclaration(preparedLine); - foreach (Match match in CSharpTypeKeywordIntroRegex.Matches(preparedLine)) - { - int parenIndex = match.Index + match.Length - 1; // position of '(' / '(' の位置 - ExtractCSharpTypeKeywordSegments( - references, seen, fileId, preparedLine, parenIndex + 1, - context, lineNumber, container, language, csharpGenericParameterNames); - } - ExtractCSharpReflectionNameLiteralReferences( - references, seen, fileId, preparedLine, originalLine, context, lineNumber, container); - } - else if (language is "java") - { - JavaReferenceExtractor.EmitDotClassTypeLiteralReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - } - else if (language is "kotlin") - { - KotlinReferenceExtractor.EmitClassLiteralReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container); - KotlinReferenceExtractor.EmitBacktickConstructorReferences( - preparedLine, - kotlinConstructorTypeNames!, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - - // Type-position references without an introducing keyword-call: base lists, - // declaration types, generic constraints, throws clauses, type tests, and - // XML-doc crefs. These are dependency edges for `references` / `impact`, but - // not invocation edges for default `callers` / `callees`. See issue #256. - // キーワード呼び出しの外にある型位置参照(継承リスト、宣言型、generic 制約、 - // throws、型テスト、XML doc cref)。`references` / `impact` では依存として扱うが、 - // 既定の `callers` / `callees` では呼び出しエッジではない。issue #256 参照。 - if (language is "csharp" or "java" or "kotlin") - { - EmitCatchTypeReferences( - language, - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - - if (language == "csharp") - { - EmitCSharpLambdaCaptureReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - container, - csharpLocalNamesByFunction); - - CSharpReferenceExtractor.EmitTypePositionReferences( - preparedLine, - originalLine, - csharpQualifiedConstantPatternMemberLookup, - csharpQualifiedTypePatternLookup, - csharpUsingAliases, - csharpUsingStatics, - lookups.HasActiveSameFileCSharpTypeCandidate, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - container, - pendingCSharpWhereConstraint!, - ref pendingCSharpMultiLineTypePattern); - - if (CSharpReferenceExtractor.HasTrailingIsAsTypePatternIntro(preparedLine, originalLine)) - { - CSharpReferenceExtractor.StartWaitingForMultiLineTypePatternHead(ref pendingCSharpMultiLineTypePattern); - } - - if (CSharpReferenceExtractor.HasTrailingCaseTypePatternIntro(preparedLine, originalLine)) - { - CSharpReferenceExtractor.StartWaitingForMultiLineTypePatternHead(ref pendingCSharpMultiLineTypePattern); - } - - TrackCSharpLocalDeclarations(preparedLine, container, csharpLocalNamesByFunction); - } - else if (language == "java") - { - JavaReferenceExtractor.EmitModuleDirectiveReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - - JavaReferenceExtractor.EmitTypePositionReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - container); - } - else if (language == "typescript") - { - TypeScriptReferenceExtractor.EmitTypePositionReferences( - preparedLines, - lines, - i, - preparedLine, - lines[i], - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - typeScriptNamespaceAliases); - - TypeScriptReferenceExtractor.EmitDeclarationTypeReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - - TypeScriptReferenceExtractor.EmitAliasTargetReferences( - preparedLine, - typeScriptTypeAliases!, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - else if (language == "kotlin") - { - KotlinReferenceExtractor.EmitTypePositionReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - else if (language == "swift") - { - SwiftReferenceExtractor.EmitTypePositionReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - ResolveSwiftPropertyContainerForCall); - SwiftReferenceExtractor.EmitAliasTargetReferences( - preparedLine, - swiftTypeAliases!, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - else if (language == "rust") - { - var rustEnumCandidatesForLine = lookups.GetRustEnumCandidates(); - var rustEnumContainer = rustEnumCandidatesForLine != null - ? FindInnermostContainer(rustEnumCandidatesForLine, lineNumber) - : null; - var rustTypePositionLine = RustReferenceExtractor.MaskAttributeBodies(preparedLine); - RustReferenceExtractor.EmitTypePositionReferences( - rustTypePositionLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - container, - rustEnumContainer); - } - else if (language == "c") - CReferenceExtractor.EmitTypePositionReferences(preparedLine, originalLine, references, seen, fileId, context, lineNumber, ResolveContainerForCall); - else if (language == "cpp") - CppReferenceExtractor.EmitTypePositionReferences(preparedLine, originalLine, references, seen, fileId, context, lineNumber, ResolveContainerForCall); - else if (language == "go") - { - GoReferenceExtractor.EmitConcurrencyReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - GoReferenceExtractor.EmitTypePositionReferences(preparedLine, originalLine, references, seen, fileId, context, lineNumber, ResolveContainerForCall, goImportBlockLines?[i] == true); - } - else if (language == "dart") - DartReferenceExtractor.EmitTypePositionReferences(preparedLine, references, seen, fileId, context, lineNumber, ResolveContainerForCall); - else if (language == "vb") - VisualBasicReferenceExtractor.EmitTypePositionReferences(preparedLine, references, seen, fileId, context, lineNumber, ResolveContainerForCall); - else if (language == "fortran") - FortranReferenceExtractor.EmitTypePositionReferences(preparedLine, originalLine, references, seen, fileId, context, lineNumber, ResolveContainerForCall, container); - else if (language == "pascal") - PascalReferenceExtractor.EmitTypePositionReferences(preparedLine, references, seen, fileId, context, lineNumber, ResolveContainerForCall, container); - else if (language == "objc") - ObjectiveCReferenceExtractor.EmitTypePositionReferences(preparedLine, references, seen, fileId, context, lineNumber, ResolveContainerForCall, container); - else if (language == "haskell") - HaskellReferenceExtractor.EmitTypePositionReferences(preparedLine, references, seen, fileId, context, lineNumber, container); - else if (language == "elixir") - ElixirReferenceExtractor.EmitTypePositionReferences(preparedLine, references, seen, fileId, context, lineNumber, container); - else if (language == "lua") - LuaReferenceExtractor.EmitTypePositionReferences(luaReferenceLines?[i] ?? originalLine, references, seen, fileId, context, lineNumber, container); - else if (language == "css") - { - CssReferenceExtractor.EmitCss( - preparedLine, - originalLine, - context, - lineNumber, - references, - seen, - fileId, - definitionNames, - container); - } - else if (language == "sass") - { - CssReferenceExtractor.EmitSass(preparedLine, originalLineForLanguage, references, seen, fileId, context, lineNumber, container); - continue; - } - else if (language == "stylus") - { - CssReferenceExtractor.EmitStylus(preparedLine, originalLineForLanguage, references, seen, fileId, context, lineNumber, allDefinitionNames, stylusVariableDefinitionNames, container); - continue; - } - else if (language == "xml" && xamlReferenceEnabled) - { - var xamlLine = XamlReferenceExtractor.StripXmlComments(originalLine, ref xamlInXmlComment); - XamlReferenceExtractor.Emit(xamlLine, context, lineNumber, references, seen, fileId, container, xamlBindingPropertyElementState!, xamlBindingMarkupExtensionState!); - continue; - } - else if (language == "xml") + var typeContext = new CoreTypeReferenceContext( + lineContext, + lookups, + containerCandidates, + symbols, + structuralLines, + csharpQualifiedConstantPatternMemberLookup, + csharpQualifiedTypePatternLookup, + csharpUsingAliases, + csharpUsingStatics, + csharpLocalNamesByFunction, + pendingCSharpWhereConstraint, + kotlinConstructorTypeNames, + typeScriptNamespaceAliases, + typeScriptTypeAliases, + swiftTypeAliases, + ResolveSwiftPropertyContainerForCall, + goImportBlockLines, + luaReferenceLines, + originalLineForLanguage, + allDefinitionNames, + stylusVariableDefinitionNames, + xamlReferenceEnabled, + xamlBindingPropertyElementState, + xamlBindingMarkupExtensionState); + if (EmitCoreTypeReferences( + typeContext, + ref pendingCSharpMultiLineTypePattern, + ref xamlInXmlComment)) { continue; } diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreTypeReferences.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreTypeReferences.cs new file mode 100644 index 000000000..fa287ce43 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreTypeReferences.cs @@ -0,0 +1,378 @@ +using System.Text.RegularExpressions; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private readonly record struct CoreTypeReferenceContext( + CoreReferenceLineContext Line, + CoreExtractionLookups Lookups, + IReadOnlyList ContainerCandidates, + IReadOnlyList Symbols, + string[] StructuralLines, + IReadOnlyDictionary< + string, + List<(string ContainerName, string? QualifiedContainerName, bool AllowShortNameFallback)>> + CSharpQualifiedConstantPatternMemberLookup, + IReadOnlyDictionary< + string, + List<(string ContainerName, string? QualifiedContainerName, bool AllowShortNameFallback)>> + CSharpQualifiedTypePatternLookup, + IReadOnlyList CSharpUsingAliases, + IReadOnlyList CSharpUsingStatics, + Dictionary>? CSharpLocalNamesByFunction, + CSharpWhereConstraintState? PendingCSharpWhereConstraint, + HashSet? KotlinConstructorTypeNames, + IReadOnlyList TypeScriptNamespaceAliases, + IReadOnlyList? TypeScriptTypeAliases, + IReadOnlyList? SwiftTypeAliases, + Func ResolveSwiftPropertyContainerForCall, + bool[]? GoImportBlockLines, + string[]? LuaReferenceLines, + string OriginalLineForLanguage, + IReadOnlySet? AllDefinitionNames, + HashSet? StylusVariableDefinitionNames, + bool XamlReferenceEnabled, + XamlReferenceExtractor.BindingPropertyElementState? XamlBindingPropertyElementState, + XamlReferenceExtractor.BindingMarkupExtensionState? XamlBindingMarkupExtensionState); + + private static bool EmitCoreTypeReferences( + CoreTypeReferenceContext type, + ref CSharpMultiLineTypePatternState pendingCSharpMultiLineTypePattern, + ref bool xamlInXmlComment) + { + var line = type.Line; + if (line.Language == "csharp") + { + CSharpReferenceExtractor.AdvanceMultiLineTypePatternState( + line.PreparedLine, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + type.CSharpQualifiedConstantPatternMemberLookup, + type.CSharpUsingAliases, + type.CSharpUsingStatics, + type.Lookups.HasActiveSameFileCSharpTypeCandidate, + line.References, + line.Seen, + line.FileId, + ref pendingCSharpMultiLineTypePattern); + } + + // Event subscription/unsubscription (C#) / イベント購読・解除 (C#) + if (line.Language is "csharp") + { + foreach (Match match in EventSubscriptionRegex.Matches(line.PreparedLine)) + { + var eventContainer = line.ResolveContainerForCall(match.Groups["name"].Index); + AddReference(line.References, line.Seen, line.FileId, match, "subscribe", line.Context, line.LineNumber, eventContainer); + } + } + + // Constructor chain-call rewrites: C# `: this(...)` / `: base(...)`, Java `this(...)` / `super(...)`, + // and Kotlin `constructor(...) : this(...)` / `: super(...)`. + // コンストラクタ連鎖呼び出しの書き換え + if (line.Language is "csharp") + { + CSharpReferenceExtractor.EmitCtorChainReferences( + line.PreparedLine, type.Lookups.GetEnclosingTypeCandidates, type.ContainerCandidates, + type.StructuralLines, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.Container); + } + else if (line.Language is "java") + { + JavaReferenceExtractor.EmitCtorChainReferences( + line.PreparedLine, type.Lookups.GetEnclosingTypeCandidates, type.Symbols, type.StructuralLines, + line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.Container); + } + else if (line.Language is "kotlin") + { + KotlinReferenceExtractor.EmitCtorDelegationReferences( + line.PreparedLine, type.Lookups.GetEnclosingTypeCandidates, type.Symbols, type.StructuralLines, + line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.Container); + } + + // Compile-time type/member line.References that CallRegex cannot see because the + // argument has no trailing `(` of its own. See issue #253. + // 末尾の `(` を持たず CallRegex では取れないコンパイル時の型/メンバ参照。issue #253 参照。 + if (line.Language is "csharp") + { + var csharpGenericParameterNames = CollectCSharpGenericParameterNamesForDeclaration(line.PreparedLine); + foreach (Match match in CSharpTypeKeywordIntroRegex.Matches(line.PreparedLine)) + { + int parenIndex = match.Index + match.Length - 1; // position of '(' / '(' の位置 + ExtractCSharpTypeKeywordSegments( + line.References, line.Seen, line.FileId, line.PreparedLine, parenIndex + 1, + line.Context, line.LineNumber, line.Container, line.Language, csharpGenericParameterNames); + } + ExtractCSharpReflectionNameLiteralReferences( + line.References, line.Seen, line.FileId, line.PreparedLine, line.OriginalLine, line.Context, line.LineNumber, line.Container); + } + else if (line.Language is "java") + { + JavaReferenceExtractor.EmitDotClassTypeLiteralReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + } + else if (line.Language is "kotlin") + { + KotlinReferenceExtractor.EmitClassLiteralReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container); + KotlinReferenceExtractor.EmitBacktickConstructorReferences( + line.PreparedLine, + type.KotlinConstructorTypeNames!, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + } + + // Type-position line.References without an introducing keyword-call: base lists, + // declaration types, generic constraints, throws clauses, type tests, and + // XML-doc crefs. These are dependency edges for `line.References` / `impact`, but + // not invocation edges for default `callers` / `callees`. See issue #256. + // キーワード呼び出しの外にある型位置参照(継承リスト、宣言型、generic 制約、 + // throws、型テスト、XML doc cref)。`line.References` / `impact` では依存として扱うが、 + // 既定の `callers` / `callees` では呼び出しエッジではない。issue #256 参照。 + if (line.Language is "csharp" or "java" or "kotlin") + { + EmitCatchTypeReferences( + line.Language, + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + } + + if (line.Language == "csharp") + { + EmitCSharpLambdaCaptureReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.Container, + type.CSharpLocalNamesByFunction); + + CSharpReferenceExtractor.EmitTypePositionReferences( + line.PreparedLine, + line.OriginalLine, + type.CSharpQualifiedConstantPatternMemberLookup, + type.CSharpQualifiedTypePatternLookup, + type.CSharpUsingAliases, + type.CSharpUsingStatics, + type.Lookups.HasActiveSameFileCSharpTypeCandidate, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + line.Container, + type.PendingCSharpWhereConstraint!, + ref pendingCSharpMultiLineTypePattern); + + if (CSharpReferenceExtractor.HasTrailingIsAsTypePatternIntro(line.PreparedLine, line.OriginalLine)) + { + CSharpReferenceExtractor.StartWaitingForMultiLineTypePatternHead(ref pendingCSharpMultiLineTypePattern); + } + + if (CSharpReferenceExtractor.HasTrailingCaseTypePatternIntro(line.PreparedLine, line.OriginalLine)) + { + CSharpReferenceExtractor.StartWaitingForMultiLineTypePatternHead(ref pendingCSharpMultiLineTypePattern); + } + + TrackCSharpLocalDeclarations(line.PreparedLine, line.Container, type.CSharpLocalNamesByFunction); + } + else if (line.Language == "java") + { + JavaReferenceExtractor.EmitModuleDirectiveReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + + JavaReferenceExtractor.EmitTypePositionReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + line.Container); + } + else if (line.Language == "typescript") + { + TypeScriptReferenceExtractor.EmitTypePositionReferences( + line.PreparedLines, + line.Lines, + line.LineIndex, + line.PreparedLine, + line.Lines[line.LineIndex], + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + type.TypeScriptNamespaceAliases); + + TypeScriptReferenceExtractor.EmitDeclarationTypeReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + + TypeScriptReferenceExtractor.EmitAliasTargetReferences( + line.PreparedLine, + type.TypeScriptTypeAliases!, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + } + else if (line.Language == "kotlin") + { + KotlinReferenceExtractor.EmitTypePositionReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + } + else if (line.Language == "swift") + { + SwiftReferenceExtractor.EmitTypePositionReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + type.ResolveSwiftPropertyContainerForCall); + SwiftReferenceExtractor.EmitAliasTargetReferences( + line.PreparedLine, + type.SwiftTypeAliases!, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + } + else if (line.Language == "rust") + { + var rustEnumCandidatesForLine = type.Lookups.GetRustEnumCandidates(); + var rustEnumContainer = rustEnumCandidatesForLine != null + ? FindInnermostContainer(rustEnumCandidatesForLine, line.LineNumber) + : null; + var rustTypePositionLine = RustReferenceExtractor.MaskAttributeBodies(line.PreparedLine); + RustReferenceExtractor.EmitTypePositionReferences( + rustTypePositionLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + line.Container, + rustEnumContainer); + } + else if (line.Language == "c") + CReferenceExtractor.EmitTypePositionReferences(line.PreparedLine, line.OriginalLine, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.ResolveContainerForCall); + else if (line.Language == "cpp") + CppReferenceExtractor.EmitTypePositionReferences(line.PreparedLine, line.OriginalLine, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.ResolveContainerForCall); + else if (line.Language == "go") + { + GoReferenceExtractor.EmitConcurrencyReferences( + line.PreparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); + GoReferenceExtractor.EmitTypePositionReferences(line.PreparedLine, line.OriginalLine, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.ResolveContainerForCall, type.GoImportBlockLines?[line.LineIndex] == true); + } + else if (line.Language == "dart") + DartReferenceExtractor.EmitTypePositionReferences(line.PreparedLine, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.ResolveContainerForCall); + else if (line.Language == "vb") + VisualBasicReferenceExtractor.EmitTypePositionReferences(line.PreparedLine, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.ResolveContainerForCall); + else if (line.Language == "fortran") + FortranReferenceExtractor.EmitTypePositionReferences(line.PreparedLine, line.OriginalLine, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.ResolveContainerForCall, line.Container); + else if (line.Language == "pascal") + PascalReferenceExtractor.EmitTypePositionReferences(line.PreparedLine, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.ResolveContainerForCall, line.Container); + else if (line.Language == "objc") + ObjectiveCReferenceExtractor.EmitTypePositionReferences(line.PreparedLine, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.ResolveContainerForCall, line.Container); + else if (line.Language == "haskell") + HaskellReferenceExtractor.EmitTypePositionReferences(line.PreparedLine, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.Container); + else if (line.Language == "elixir") + ElixirReferenceExtractor.EmitTypePositionReferences(line.PreparedLine, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.Container); + else if (line.Language == "lua") + LuaReferenceExtractor.EmitTypePositionReferences(type.LuaReferenceLines?[line.LineIndex] ?? line.OriginalLine, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.Container); + else if (line.Language == "css") + { + CssReferenceExtractor.EmitCss( + line.PreparedLine, + line.OriginalLine, + line.Context, + line.LineNumber, + line.References, + line.Seen, + line.FileId, + line.DefinitionNames, + line.Container); + } + else if (line.Language == "sass") + { + CssReferenceExtractor.EmitSass(line.PreparedLine, type.OriginalLineForLanguage, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.Container); + return true; + } + else if (line.Language == "stylus") + { + CssReferenceExtractor.EmitStylus(line.PreparedLine, type.OriginalLineForLanguage, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, type.AllDefinitionNames, type.StylusVariableDefinitionNames, line.Container); + return true; + } + else if (line.Language == "xml" && type.XamlReferenceEnabled) + { + var xamlLine = XamlReferenceExtractor.StripXmlComments(line.OriginalLine, ref xamlInXmlComment); + XamlReferenceExtractor.Emit(xamlLine, line.Context, line.LineNumber, line.References, line.Seen, line.FileId, line.Container, type.XamlBindingPropertyElementState!, type.XamlBindingMarkupExtensionState!); + return true; + } + else if (line.Language == "xml") + { + return true; + } + return false; + } +} From 1a946200685f0f4dea5fd03fb1f912fe983ccfe8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 21:25:15 +0900 Subject: [PATCH 070/101] Split core documentation and ambiguous language paths --- .../ReferenceExtractor.AmbiguousM.cs | 78 ++++++ ...ferenceExtractor.CoreDocumentationLines.cs | 167 +++++++++++++ .../ReferenceExtractor.CoreExtraction.cs | 228 +++--------------- 3 files changed, 272 insertions(+), 201 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.AmbiguousM.cs create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CoreDocumentationLines.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.AmbiguousM.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.AmbiguousM.cs new file mode 100644 index 000000000..4fde1932c --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.AmbiguousM.cs @@ -0,0 +1,78 @@ +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static List ExtractAmbiguousMReferences(ReferenceExtractionContext request) + { + if (string.IsNullOrEmpty(request.Content) + || (request.HasOversizeLine ?? ChunkSplitter.HasOversizeLine(request.Content)) + || (request.ConflictMarkerLine ?? FileIndexer.GetConflictMarkerLine(request.Content)) > 0) + { + return []; + } + + var normalizedContent = request.ContentIsNormalized + ? request.Content + : FileIndexer.NormalizeContentForPrepass(request.Content); + var originalLines = SplitContentLines(normalizedContent); + var matlabContent = AmbiguousMContentMasker.MaskComments( + normalizedContent, + maskMatlabComments: true, + maskObjectiveCComments: true); + var objectiveCContent = AmbiguousMContentMasker.MaskComments( + normalizedContent, + maskMatlabComments: true, + maskObjectiveCComments: true, + preserveObjectiveCModuloExpressions: true); + var matlabReferences = ExtractCore(request with + { + Language = "matlab", + Content = matlabContent, + RequestedLanguage = "ambiguous_m", + ContentIsNormalized = true, + HasOversizeLine = false, + ConflictMarkerLine = 0, + }); + var objectiveCReferences = ExtractCore(request with + { + Language = "objc", + Content = objectiveCContent, + RequestedLanguage = "ambiguous_m", + ContentIsNormalized = true, + HasOversizeLine = false, + ConflictMarkerLine = 0, + }); + var merged = CreateReferenceList( + request.MaxReferenceCount, + Math.Min(matlabReferences.Count + objectiveCReferences.Count, ReferenceListInitialCapacityMax)); + var seen = new ReferenceDedupeSet(merged.Capacity); + + AddUnique(matlabReferences); + AddUnique(objectiveCReferences); + return merged; + + void AddUnique(IReadOnlyList candidates) + { + for (var index = 0; index < candidates.Count && !ReferenceLimitReached(merged); index++) + { + var candidate = candidates[index]; + if (candidate.Line > 0 && candidate.Line <= originalLines.Length) + candidate.Context = originalLines[candidate.Line - 1].Trim(); + var key = CreateReferenceDedupeKey( + candidate.FileId, + "ambiguous_m", + candidate.Line, + candidate.Column, + candidate.ReferenceKind, + candidate.SymbolName, + candidate.ContainerKind, + candidate.ContainerName); + if (seen.Add(key)) + TryAddReference(merged, candidate); + } + } + } + +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreDocumentationLines.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreDocumentationLines.cs new file mode 100644 index 000000000..b85d66d74 --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreDocumentationLines.cs @@ -0,0 +1,167 @@ +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private readonly record struct CoreDocumentationLineContext( + long FileId, + string Language, + string[] Lines, + string[] PreparedLines, + string[] StructuralLines, + int LineIndex, + int LineNumber, + string OriginalLine, + string PreparedLine, + List References, + ReferenceDedupeSet Seen, + IReadOnlyList ContainerCandidates, + InnermostContainerResolver ContainerResolver, + CoreExtractionLookups Lookups, + bool[]? CSharpLinesInsideMultilineStringContent, + bool[]? CSharpLinesInsideBlockComment, + List<(int start, int end)>? CSharpAttributeRangesOnLine, + List<(int start, int end)>?[]? CSharpAttributeRanges, + Func GetPhpLineContainer); + + private static void EmitCoreDocumentationReferences( + CoreDocumentationLineContext line, + ref bool csharpInDelimitedDocComment, + ref bool jvmInDelimitedDocComment, + ref bool phpInDocblock, + ref SymbolRecord? phpDocblockContainer, + ref HashSet? phpDocblockPropertyNames) + { + if (line.Language == "csharp" + && line.CSharpLinesInsideMultilineStringContent != null + && !(line.CSharpLinesInsideMultilineStringContent?[line.LineIndex] ?? false) + && TryGetCSharpXmlDocCommentSpan( + line.OriginalLine, + csharpInDelimitedDocComment, + line.CSharpLinesInsideBlockComment?[line.LineIndex] ?? false, + out var csharpDocCommentStartIndex, + out var csharpDocCommentEndExclusive, + out var nextCsharpDelimitedDocComment)) + { + var csharpDocCommentText = line.OriginalLine[csharpDocCommentStartIndex..csharpDocCommentEndExclusive]; + if (csharpDocCommentText.IndexOf("cref=\"", StringComparison.OrdinalIgnoreCase) >= 0) + { + var innermostContainer = line.ContainerResolver.Find(line.LineNumber); + var sameLineDeclarationStartColumn = GetCSharpSameLineDocumentedDeclarationStartColumn( + line.OriginalLine, + csharpDocCommentEndExclusive, + nextCsharpDelimitedDocComment); + var docContainer = FindDocumentedContainer( + line.ContainerCandidates, + line.StructuralLines[line.LineIndex], + line.PreparedLine, + line.CSharpAttributeRangesOnLine, + line.LineNumber, + sameLineDeclarationStartColumn); + if (docContainer != null + && (docContainer.StartLine == line.LineNumber + || CanAttachCSharpXmlDocCommentToNextDeclaration( + innermostContainer, + line.Lookups.GetCSharpXmlDocAttachmentScopeCandidates(), + line.CSharpAttributeRanges, + line.PreparedLines, + line.LineNumber, + docContainer))) + { + CSharpReferenceExtractor.EmitDocCrefReferences( + csharpDocCommentText, + line.References, + line.Seen, + line.FileId, + csharpDocCommentStartIndex, + csharpDocCommentText.Trim(), + line.LineNumber, + docContainer); + } + } + csharpInDelimitedDocComment = nextCsharpDelimitedDocComment; + } + else if (line.Language is "java" or "kotlin" + && TryGetJvmDocCommentSpan( + line.OriginalLine, + jvmInDelimitedDocComment, + out var jvmDocCommentStartIndex, + out var jvmDocCommentEndExclusive, + out var jvmSameLineDeclarationStartColumn, + out var nextJvmDelimitedDocComment)) + { + if (jvmDocCommentEndExclusive > jvmDocCommentStartIndex) + { + var docContainer = FindJvmDocumentedContainer( + line.ContainerCandidates, + line.Lines, + line.StructuralLines[line.LineIndex], + line.LineNumber, + jvmSameLineDeclarationStartColumn); + if (docContainer != null) + { + var docText = line.OriginalLine[jvmDocCommentStartIndex..jvmDocCommentEndExclusive]; + EmitJvmDocLinkReferences( + line.Language, + docText, + line.References, + line.Seen, + line.FileId, + jvmDocCommentStartIndex, + docText.Trim(), + line.LineNumber, + docContainer); + } + } + + jvmInDelimitedDocComment = nextJvmDelimitedDocComment; + } + + if (line.Language == "r") + { + var roxygenContext = line.OriginalLine.Trim(); + if (roxygenContext.Length > 0) + { + RReferenceExtractor.EmitRoxygenImportFromReferences( + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + roxygenContext, + line.LineNumber, + container: null); + RReferenceExtractor.EmitRoxygenImportReferences( + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + roxygenContext, + line.LineNumber, + container: null); + RReferenceExtractor.EmitRoxygenMethodReferences( + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + roxygenContext, + line.LineNumber, + container: null); + } + } + + if (line.Language == "php") + { + EmitPhpLinePreambleReferences( + line.OriginalLine, + line.References, + line.Seen, + line.FileId, + line.LineNumber, + line.GetPhpLineContainer, + ref phpInDocblock, + ref phpDocblockContainer, + ref phpDocblockPropertyNames); + } + } +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs index 651261d1b..bd7c5a028 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs @@ -318,136 +318,33 @@ internal static List ExtractCore(ReferenceExtractionContext req return phpLineContainer; } - if (language == "csharp" - && csharpLinesInsideMultilineStringContent != null - && !(csharpLinesInsideMultilineStringContent?[i] ?? false) - && TryGetCSharpXmlDocCommentSpan( - originalLine, - csharpInDelimitedDocComment, - csharpLinesInsideBlockComment?[i] ?? false, - out var csharpDocCommentStartIndex, - out var csharpDocCommentEndExclusive, - out var nextCsharpDelimitedDocComment)) - { - var csharpDocCommentText = originalLine[csharpDocCommentStartIndex..csharpDocCommentEndExclusive]; - if (csharpDocCommentText.IndexOf("cref=\"", StringComparison.OrdinalIgnoreCase) >= 0) - { - var innermostContainer = containerResolver.Find(lineNumber); - var sameLineDeclarationStartColumn = GetCSharpSameLineDocumentedDeclarationStartColumn( - originalLine, - csharpDocCommentEndExclusive, - nextCsharpDelimitedDocComment); - var docContainer = FindDocumentedContainer( - containerCandidates, - structuralLines[i], - preparedLine, - csharpAttrRangesOnLine, - lineNumber, - sameLineDeclarationStartColumn); - if (docContainer != null - && (docContainer.StartLine == lineNumber - || CanAttachCSharpXmlDocCommentToNextDeclaration( - innermostContainer, - lookups.GetCSharpXmlDocAttachmentScopeCandidates(), - csharpAttrRanges, - preparedLines, - lineNumber, - docContainer))) - { - CSharpReferenceExtractor.EmitDocCrefReferences( - csharpDocCommentText, - references, - seen, - fileId, - csharpDocCommentStartIndex, - csharpDocCommentText.Trim(), - lineNumber, - docContainer); - } - } - csharpInDelimitedDocComment = nextCsharpDelimitedDocComment; - } - else if (language is "java" or "kotlin" - && TryGetJvmDocCommentSpan( - originalLine, - jvmInDelimitedDocComment, - out var jvmDocCommentStartIndex, - out var jvmDocCommentEndExclusive, - out var jvmSameLineDeclarationStartColumn, - out var nextJvmDelimitedDocComment)) - { - if (jvmDocCommentEndExclusive > jvmDocCommentStartIndex) - { - var docContainer = FindJvmDocumentedContainer( - containerCandidates, - lines, - structuralLines[i], - lineNumber, - jvmSameLineDeclarationStartColumn); - if (docContainer != null) - { - var docText = originalLine[jvmDocCommentStartIndex..jvmDocCommentEndExclusive]; - EmitJvmDocLinkReferences( - language, - docText, - references, - seen, - fileId, - jvmDocCommentStartIndex, - docText.Trim(), - lineNumber, - docContainer); - } - } - - jvmInDelimitedDocComment = nextJvmDelimitedDocComment; - } - - if (language == "r") - { - var roxygenContext = originalLine.Trim(); - if (roxygenContext.Length > 0) - { - RReferenceExtractor.EmitRoxygenImportFromReferences( - originalLine, - references, - seen, - fileId, - roxygenContext, - lineNumber, - container: null); - RReferenceExtractor.EmitRoxygenImportReferences( - originalLine, - references, - seen, - fileId, - roxygenContext, - lineNumber, - container: null); - RReferenceExtractor.EmitRoxygenMethodReferences( - originalLine, - references, - seen, - fileId, - roxygenContext, - lineNumber, - container: null); - } - } - - if (language == "php") - { - EmitPhpLinePreambleReferences( - originalLine, - references, - seen, - fileId, - lineNumber, - GetPhpLineContainer, - ref phpInDocblock, - ref phpDocblockContainer, - ref phpDocblockPropertyNames); - } + var documentationLine = new CoreDocumentationLineContext( + fileId, + language, + lines, + preparedLines, + structuralLines, + i, + lineNumber, + originalLine, + preparedLine, + references, + seen, + containerCandidates, + containerResolver, + lookups, + csharpLinesInsideMultilineStringContent, + csharpLinesInsideBlockComment, + csharpAttrRangesOnLine, + csharpAttrRanges, + GetPhpLineContainer); + EmitCoreDocumentationReferences( + documentationLine, + ref csharpInDelimitedDocComment, + ref jvmInDelimitedDocComment, + ref phpInDocblock, + ref phpDocblockContainer, + ref phpDocblockPropertyNames); var context = originalLine.Trim(); if (language is "cmake" or "justfile" or "makefile" or "msbuild" @@ -952,75 +849,4 @@ internal static List ExtractCore(ReferenceExtractionContext req return references; } - private static List ExtractAmbiguousMReferences(ReferenceExtractionContext request) - { - if (string.IsNullOrEmpty(request.Content) - || (request.HasOversizeLine ?? ChunkSplitter.HasOversizeLine(request.Content)) - || (request.ConflictMarkerLine ?? FileIndexer.GetConflictMarkerLine(request.Content)) > 0) - { - return []; - } - - var normalizedContent = request.ContentIsNormalized - ? request.Content - : FileIndexer.NormalizeContentForPrepass(request.Content); - var originalLines = SplitContentLines(normalizedContent); - var matlabContent = AmbiguousMContentMasker.MaskComments( - normalizedContent, - maskMatlabComments: true, - maskObjectiveCComments: true); - var objectiveCContent = AmbiguousMContentMasker.MaskComments( - normalizedContent, - maskMatlabComments: true, - maskObjectiveCComments: true, - preserveObjectiveCModuloExpressions: true); - var matlabReferences = ExtractCore(request with - { - Language = "matlab", - Content = matlabContent, - RequestedLanguage = "ambiguous_m", - ContentIsNormalized = true, - HasOversizeLine = false, - ConflictMarkerLine = 0, - }); - var objectiveCReferences = ExtractCore(request with - { - Language = "objc", - Content = objectiveCContent, - RequestedLanguage = "ambiguous_m", - ContentIsNormalized = true, - HasOversizeLine = false, - ConflictMarkerLine = 0, - }); - var merged = CreateReferenceList( - request.MaxReferenceCount, - Math.Min(matlabReferences.Count + objectiveCReferences.Count, ReferenceListInitialCapacityMax)); - var seen = new ReferenceDedupeSet(merged.Capacity); - - AddUnique(matlabReferences); - AddUnique(objectiveCReferences); - return merged; - - void AddUnique(IReadOnlyList candidates) - { - for (var index = 0; index < candidates.Count && !ReferenceLimitReached(merged); index++) - { - var candidate = candidates[index]; - if (candidate.Line > 0 && candidate.Line <= originalLines.Length) - candidate.Context = originalLines[candidate.Line - 1].Trim(); - var key = CreateReferenceDedupeKey( - candidate.FileId, - "ambiguous_m", - candidate.Line, - candidate.Column, - candidate.ReferenceKind, - candidate.SymbolName, - candidate.ContainerKind, - candidate.ContainerName); - if (seen.Add(key)) - TryAddReference(merged, candidate); - } - } - } - } From 2dbbd8dced340977a8f415f5f08131e278e360b9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 21:33:14 +0900 Subject: [PATCH 071/101] Unify index file byte tracking --- .../IndexCommandRunner.FileByteTracking.cs | 95 +++++++++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 88 +++-------------- .../Cli/IndexCommandRunner.Update.cs | 61 ++---------- 3 files changed, 118 insertions(+), 126 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FileByteTracking.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FileByteTracking.cs b/src/CodeIndex/Cli/IndexCommandRunner.FileByteTracking.cs new file mode 100644 index 000000000..89e4298b5 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FileByteTracking.cs @@ -0,0 +1,95 @@ +using CodeIndex.Database; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class ReadableFileByteTracker( + int fileCount, + Func getFilePath, + string projectRoot, + List? indexRunDiagnostics) + { + private readonly long[] knownSizes = new long[fileCount]; + private readonly bool[] sizeKnown = new bool[fileCount]; + private int knownCount; + private long knownBytes; + private bool estimateComplete = true; + + internal long KnownBytes => knownBytes; + internal bool EstimateComplete => estimateComplete; + + internal void Remember(int fileIndex, long size) + { + long? priorSize = null; + if (sizeKnown[fileIndex]) + { + priorSize = knownSizes[fileIndex]; + } + else + { + sizeKnown[fileIndex] = true; + knownCount++; + } + + if (estimateComplete + && !FtsBulkLoadTriggerGuard.TryUpdateKnownByteTotal( + knownBytes, + priorSize, + size, + out knownBytes)) + { + estimateComplete = false; + } + + knownSizes[fileIndex] = size; + } + + internal FileByteReadSummary MeasureRemaining() + { + var total = knownBytes; + long skipped = estimateComplete ? 0 : 1; + var totalComplete = estimateComplete; + if (knownCount == fileCount) + return new FileByteReadSummary(total, skipped); + + for (var fileIndex = 0; fileIndex < fileCount; fileIndex++) + { + if (sizeKnown[fileIndex]) + continue; + + var path = getFilePath(fileIndex); + try + { + var info = new FileInfo(path); + if (info.Exists + && totalComplete + && !FtsBulkLoadTriggerGuard.TryUpdateKnownByteTotal( + total, + previousBytes: null, + info.Length, + out total)) + { + totalComplete = false; + skipped++; + } + } + catch (Exception ex) when ( + ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + skipped++; + RecordIndexRunDiagnostic( + indexRunDiagnostics, + "file_size_bytes_skipped", + FormatDiagnosticPath(projectRoot, path), + ex); + } + } + + return new FileByteReadSummary(total, skipped); + } + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index c3d0a1c5b..49a91a8c0 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -169,69 +169,11 @@ void ThrowIfFullScanCancelled(int filesProcessed, int? filesTotal) indexedTarget.GeneratedExtractionSuppressed)); } } - var knownReadableFileSizes = new long[files.Count]; - var knownReadableFileSizeKnown = new bool[files.Count]; - var knownReadableFileCount = 0; - long knownReadableBytesRead = 0; - var knownReadableByteEstimateComplete = true; - void RememberReadableFileSize(int fileIndex, long size) - { - long? priorSize = null; - if (knownReadableFileSizeKnown[fileIndex]) - { - priorSize = knownReadableFileSizes[fileIndex]; - } - else - { - knownReadableFileSizeKnown[fileIndex] = true; - knownReadableFileCount++; - } - if (knownReadableByteEstimateComplete - && !FtsBulkLoadTriggerGuard.TryUpdateKnownByteTotal( - knownReadableBytesRead, - priorSize, - size, - out knownReadableBytesRead)) - { - knownReadableByteEstimateComplete = false; - } - knownReadableFileSizes[fileIndex] = size; - } - FileByteReadSummary MeasureRemainingReadableFileBytes() - { - long total = knownReadableBytesRead; - long skipped = knownReadableByteEstimateComplete ? 0 : 1; - var totalComplete = knownReadableByteEstimateComplete; - for (var fileIndex = 0; fileIndex < files.Count; fileIndex++) - { - if (knownReadableFileSizeKnown[fileIndex]) - continue; - - var path = files[fileIndex]; - try - { - var info = new FileInfo(path); - if (info.Exists - && totalComplete - && !FtsBulkLoadTriggerGuard.TryUpdateKnownByteTotal( - total, - previousBytes: null, - info.Length, - out total)) - { - totalComplete = false; - skipped++; - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) - { - skipped++; - RecordIndexRunDiagnostic(indexRunDiagnostics, "file_size_bytes_skipped", FormatDiagnosticPath(projectRoot, path), ex); - } - } - - return new FileByteReadSummary(total, skipped); - } + var readableFileBytes = new ReadableFileByteTracker( + files.Count, + fileIndex => files[fileIndex], + projectRoot, + indexRunDiagnostics); var errorList = discovery.ErrorList; var fileErrorList = errorList .Take(PartialIndexFileErrorLimit) @@ -993,7 +935,7 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis var language = target.Language; skipped++; processed++; - RememberReadableFileSize(fileIndex, existingFile.Size); + readableFileBytes.Remember(fileIndex, existingFile.Size); if (!string.IsNullOrWhiteSpace(language)) { skippedSymbolExtractorLanguages ??= new HashSet(StringComparer.Ordinal); @@ -1193,7 +1135,7 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis var persistedSizeExcessBytes = 0L; var byteEstimateComplete = !scanHadErrors && staleFilePurgePlan.ByteEstimateComplete - && knownReadableByteEstimateComplete; + && readableFileBytes.EstimateComplete; void AddDirtyFileBytes(int fileIndex) { @@ -1207,7 +1149,7 @@ void AddDirtyFileBytes(int fileIndex) return; } - RememberReadableFileSize(fileIndex, info.Length); + readableFileBytes.Remember(fileIndex, info.Length); var persistedSize = reusableIndexedFileStats!.GetPersistedSize(fileTargets[fileIndex].IndexPath); if (!FtsBulkLoadTriggerGuard.TryAccumulateDirtyFileBytes( dirtyBytes, @@ -1237,9 +1179,9 @@ void AddDirtyFileBytes(int fileIndex) AddDirtyFileBytes(fileIndex); } - byteEstimateComplete &= knownReadableByteEstimateComplete; - var totalBytes = knownReadableBytesRead; - if (!knownReadableByteEstimateComplete + byteEstimateComplete &= readableFileBytes.EstimateComplete; + var totalBytes = readableFileBytes.KnownBytes; + if (!readableFileBytes.EstimateComplete || totalBytes > long.MaxValue - staleFilePurgePlan.DeletedBytes) byteEstimateComplete = false; else @@ -2128,7 +2070,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) } var record = item.Record!; - RememberReadableFileSize(item.FileIndex, record.Size); + readableFileBytes.Remember(item.FileIndex, record.Size); if (item.Warning != null && !options.Json && !options.Quiet) { PauseIndexSpinnerForConsoleWrite(); @@ -2842,11 +2784,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) if (options.MemoryTrace) memorySamples.Add(CaptureMemorySample("finalize", stopwatch)); var memoryTimelineForStamp = BuildMemoryTimeline(memorySamples); - var bytesRead = knownReadableFileCount == files.Count - ? new FileByteReadSummary( - knownReadableBytesRead, - knownReadableByteEstimateComplete ? 0 : 1) - : MeasureRemainingReadableFileBytes(); + var bytesRead = readableFileBytes.MeasureRemaining(); StampLastIndexRunMetadata( writer, options.Rebuild ? "rebuild" : "incremental", diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index a5d70b393..a20535308 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -1450,50 +1450,11 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) var updateTargetIndex = 0; foreach (var targetPath in targetPaths) updateTargets[updateTargetIndex++] = UpdateFileTarget.Create(projectRoot, targetPath); - var knownReadableFileSizes = new long[updateTargets.Length]; - var knownReadableFileSizeKnown = new bool[updateTargets.Length]; - var knownReadableFileCount = 0; - long knownReadableBytesRead = 0; - void RememberReadableFileSize(int targetIndex, long size) - { - if (knownReadableFileSizeKnown[targetIndex]) - { - var priorSize = knownReadableFileSizes[targetIndex]; - knownReadableBytesRead += size - priorSize; - } - else - { - knownReadableFileSizeKnown[targetIndex] = true; - knownReadableFileCount++; - knownReadableBytesRead += size; - } - knownReadableFileSizes[targetIndex] = size; - } - FileByteReadSummary MeasureRemainingUpdateReadableFileBytes() - { - long total = knownReadableBytesRead; - long skippedSizeCount = 0; - for (var targetIndex = 0; targetIndex < updateTargets.Length; targetIndex++) - { - if (knownReadableFileSizeKnown[targetIndex]) - continue; - - var path = updateTargets[targetIndex].FilePath; - try - { - var info = new FileInfo(path); - if (info.Exists) - total += info.Length; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) - { - skippedSizeCount++; - RecordIndexRunDiagnostic(indexRunDiagnostics, "file_size_bytes_skipped", FormatDiagnosticPath(projectRoot, path), ex); - } - } - - return new FileByteReadSummary(total, skippedSizeCount); - } + var readableFileBytes = new ReadableFileByteTracker( + updateTargets.Length, + targetIndex => updateTargets[targetIndex].FilePath, + projectRoot, + indexRunDiagnostics); WriteIndexJsonLiveness(options, $"updating {ConsoleUi.Counted(targetPaths.Count, "file")}..."); string? currentUpdatePath = null; @@ -1824,7 +1785,7 @@ FileByteReadSummary MeasureRemainingUpdateReadableFileBytes() if (statMatchedFile != null) { skipped++; - RememberReadableFileSize(targetIndex, statMatchedFile.Value.Size); + readableFileBytes.Remember(targetIndex, statMatchedFile.Value.Size); if (options.Verbose && !options.Json && !options.Quiet) { PauseUpdateSpinnerForConsoleWrite(); @@ -1864,7 +1825,7 @@ FileByteReadSummary MeasureRemainingUpdateReadableFileBytes() skipped++; continue; } - RememberReadableFileSize(targetIndex, record.Size); + readableFileBytes.Remember(targetIndex, record.Size); var content = loaded.Content; var rawBytes = loaded.RawBytes; var warning = loaded.Warning; @@ -2173,7 +2134,7 @@ FileByteReadSummary MeasureRemainingUpdateReadableFileBytes() throw new CSharpWorkspaceChangedException( "The C# file changed while recording its binary skip state."); } - RememberReadableFileSize(targetIndex, skippedRecord.Size); + readableFileBytes.Remember(targetIndex, skippedRecord.Size); var stalePurged = PurgeStaleUpdateCleanupPaths( skippedRecord.Path, skippedRecord.Checksum, @@ -2277,7 +2238,7 @@ or IndexInterruptedException throw new CSharpWorkspaceChangedException( "The C# file changed while recording its oversized skip state."); } - RememberReadableFileSize(targetIndex, skippedRecord.Size); + readableFileBytes.Remember(targetIndex, skippedRecord.Size); var stalePurged = PurgeStaleUpdateCleanupPaths( skippedRecord.Path, skippedRecord.Checksum, @@ -2696,9 +2657,7 @@ or IndexInterruptedException if (options.MemoryTrace) memorySamples.Add(CaptureMemorySample("finalize", stopwatch)); var memoryTimelineForStamp = BuildMemoryTimeline(memorySamples); - var bytesRead = knownReadableFileCount == updateTargets.Length - ? new FileByteReadSummary(knownReadableBytesRead, 0) - : MeasureRemainingUpdateReadableFileBytes(); + var bytesRead = readableFileBytes.MeasureRemaining(); StampLastIndexRunMetadata( writer, "update", From f1b84ed3669e91a900e6728a4e11b67ec58905ff Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 21:36:09 +0900 Subject: [PATCH 072/101] Unify index progress reporting --- .../Cli/IndexCommandRunner.FullScan.cs | 111 +++++--------- .../IndexCommandRunner.ProgressReporter.cs | 63 ++++++++ .../Cli/IndexCommandRunner.Update.cs | 140 +++++++----------- 3 files changed, 151 insertions(+), 163 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.ProgressReporter.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 49a91a8c0..e244ef522 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -295,16 +295,21 @@ void ThrowIfFullScanCancelled(int filesProcessed, int? filesTotal) ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(projectRoot); var purgedRefs = 0; - CancellationTokenSource? indexCts = null; int processed = 0, skipped = 0, warnings = warningList.Count, errors = errorList.Count; var ftsMutated = purged > 0; var symbolsDroppedByKindFilter = 0; var mutualRecursionRefreshNeeded = !options.SymbolsOnly && (!writer.ReferenceIdentityContractMatchesCurrent() || purged > 0); - var interactiveIndexSpinner = !options.Json && !options.Quiet && ConsoleUi.ShouldUseInteractiveConsole(); var redirectedIndexingMessagePrinted = false; var indexProgressVisible = false; + var indexProgress = new IndexProgressReporter( + options, + "Indexing...", + spinnerFrames, + ConsoleUi.TryWriteErrorLine, + canResume: () => processed < files.Count && !indexProgressVisible, + clearProgressLineBeforeWrite: true); HashSet? reusedHotspotFamilyLanguages = null; HashSet? skippedSymbolExtractorLanguages = null; var indexedSymbolExtractorLanguages = new HashSet(languageCounts.Count, StringComparer.Ordinal); @@ -395,48 +400,6 @@ void RequireTypeScriptAugmentationRefresh() && !startedWithNoIndexedFiles && FullScanJavaScriptTypeScriptConfigChanged()); - void StartIndexSpinnerIfNeeded() - { - if (!interactiveIndexSpinner || indexCts != null) - return; - - indexCts = ConsoleUi.StartSpinner("Indexing...", spinnerFrames); - } - - void PauseIndexSpinnerForConsoleWrite() - { - if (indexCts == null) - return; - - ConsoleUi.StopSpinner(indexCts); - indexCts = null; - } - - void ResumeIndexSpinnerAfterConsoleWrite() - { - if (!interactiveIndexSpinner || processed >= files.Count || indexProgressVisible) - return; - - StartIndexSpinnerIfNeeded(); - } - - void WriteIndexVerboseStatus(string message) - { - if (!options.Verbose || options.Quiet) - return; - - if (options.Json) - { - ConsoleUi.TryWriteErrorLine(message); - return; - } - - PauseIndexSpinnerForConsoleWrite(); - ConsoleUi.ClearProgressLine(); - CommandOutputWriter.WriteLine(message); - ResumeIndexSpinnerAfterConsoleWrite(); - } - void EnsureIndexingActivityVisible() { if (options.Json || options.Quiet) @@ -445,9 +408,9 @@ void EnsureIndexingActivityVisible() if (indexProgressVisible) return; - if (interactiveIndexSpinner) + if (indexProgress.Interactive) { - StartIndexSpinnerIfNeeded(); + indexProgress.Start(); return; } @@ -1607,7 +1570,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) { if (!options.Json && !options.Quiet) { - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); indexProgressVisible = true; ConsoleUi.PrintProgress(0, files.Count); } @@ -2018,9 +1981,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) ReportJsonIndexProgressIfNeeded(); if (!options.Json && !options.Quiet) { - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); ConsoleUi.PrintProgress(processed, files.Count); - ResumeIndexSpinnerAfterConsoleWrite(); + indexProgress.Resume(); } continue; } @@ -2035,9 +1998,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) warningList.Add(new CliJsonMessage(currentJsonIndexFile, item.Warning ?? "File skipped")); if (!options.Json && !options.Quiet && item.Warning != null) { - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); ConsoleUi.PrintWarning(item.Warning); - ResumeIndexSpinnerAfterConsoleWrite(); + indexProgress.Resume(); } if (writer.HasFileAtPath(currentJsonIndexFile)) @@ -2062,9 +2025,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) ReportJsonIndexProgressIfNeeded(); if (!options.Json && !options.Quiet) { - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); ConsoleUi.PrintProgress(processed, files.Count); - ResumeIndexSpinnerAfterConsoleWrite(); + indexProgress.Resume(); } continue; } @@ -2073,9 +2036,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) readableFileBytes.Remember(item.FileIndex, record.Size); if (item.Warning != null && !options.Json && !options.Quiet) { - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); ConsoleUi.PrintWarning(item.Warning); - ResumeIndexSpinnerAfterConsoleWrite(); + indexProgress.Resume(); } var generatedSuppressionIssue = item.GeneratedSuppressionChecked @@ -2136,16 +2099,16 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) } if (options.Verbose && !options.Json && !options.Quiet) { - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); ConsoleUi.ClearProgressLine(); CommandOutputWriter.WriteLine($" [SKIP] {record.Path}"); - ResumeIndexSpinnerAfterConsoleWrite(); + indexProgress.Resume(); } if (!options.Json && !options.Quiet) { - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); ConsoleUi.PrintProgress(processed, files.Count); - ResumeIndexSpinnerAfterConsoleWrite(); + indexProgress.Resume(); } ReportJsonIndexProgressIfNeeded(); currentJsonIndexFile = null; @@ -2202,7 +2165,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) generatedSuppressionIssue); InsertIssuesForIndexedFile(fileId, generatedIssues); if (options.Verbose) - WriteIndexVerboseStatus($" [OK ] {record.Path} ({chunks.Count} chunks, generated-code extraction skipped)"); + indexProgress.WriteVerbose($" [OK ] {record.Path} ({chunks.Count} chunks, generated-code extraction skipped)"); currentJsonIndexFile = FormatIndexPhasePath(record.Path, "committing"); WriteProjectRootOnce(); txn.Commit(); @@ -2214,9 +2177,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) processed++; if (!options.Json && !options.Quiet) { - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); ConsoleUi.PrintProgress(processed, files.Count); - ResumeIndexSpinnerAfterConsoleWrite(); + indexProgress.Resume(); } ReportJsonIndexProgressIfNeeded(); currentJsonIndexFile = null; @@ -2255,16 +2218,16 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) writer.InsertReferencesInAtomicFileScope([], cancellationToken); InsertIssuesForIndexedFile(fileId, capIssues); if (options.Verbose) - WriteIndexVerboseStatus($" [SKIP] {record.Path} ({issue.Message})"); + indexProgress.WriteVerbose($" [SKIP] {record.Path} ({issue.Message})"); txn.Commit(); ftsMutated |= fileFtsMutated; CountFreshInsertedRows(); processed++; if (!options.Json && !options.Quiet) { - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); ConsoleUi.PrintProgress(processed, files.Count); - ResumeIndexSpinnerAfterConsoleWrite(); + indexProgress.Resume(); } ReportJsonIndexProgressIfNeeded(); currentJsonIndexFile = null; @@ -2286,16 +2249,16 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) writer.InsertReferencesInAtomicFileScope([], cancellationToken); writer.InsertIssues(fileId, capIssues); if (options.Verbose) - WriteIndexVerboseStatus($" [SKIP] {record.Path} ({issue.Message})"); + indexProgress.WriteVerbose($" [SKIP] {record.Path} ({issue.Message})"); txn.Commit(); ftsMutated |= fileFtsMutated; CountFreshInsertedRows(); processed++; if (!options.Json && !options.Quiet) { - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); ConsoleUi.PrintProgress(processed, files.Count); - ResumeIndexSpinnerAfterConsoleWrite(); + indexProgress.Resume(); } ReportJsonIndexProgressIfNeeded(); currentJsonIndexFile = null; @@ -2386,7 +2349,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) indexedSymbolExtractorLanguages.Add(record.Lang); CountFreshInsertedRows(chunks.Count, symbols.Count, references.Count); - WriteIndexVerboseStatus($" [OK ] {record.Path} ({chunks.Count} chunks, {symbols.Count} symbols, {references.Count} refs)"); + indexProgress.WriteVerbose($" [OK ] {record.Path} ({chunks.Count} chunks, {symbols.Count} symbols, {references.Count} refs)"); } catch (IndexExtractionStalledException) { @@ -2402,10 +2365,10 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) fileErrorList.Add(BuildIndexFileError(item.RelativePath, indexFilePhase, ex)); if (!options.Json) { - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); ConsoleUi.ClearProgressLine(); ConsoleUi.TryWriteErrorLine(FormatPerFileErrorLine("ERR ", item.FilePath, ex, errorMessage)); - ResumeIndexSpinnerAfterConsoleWrite(); + indexProgress.Resume(); } } finally @@ -2422,9 +2385,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) ReportJsonIndexProgressIfNeeded(); if (!options.Json && !options.Quiet) { - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); ConsoleUi.PrintProgress(processed, files.Count); - ResumeIndexSpinnerAfterConsoleWrite(); + indexProgress.Resume(); } } Task.WaitAll(workers, cancellationToken); @@ -2437,7 +2400,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) } } - PauseIndexSpinnerForConsoleWrite(); + indexProgress.Pause(); if (options.MemoryTrace) memorySamples.Add(CaptureMemorySample("extraction", stopwatch)); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.ProgressReporter.cs b/src/CodeIndex/Cli/IndexCommandRunner.ProgressReporter.cs new file mode 100644 index 000000000..42fae4ba8 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.ProgressReporter.cs @@ -0,0 +1,63 @@ +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class IndexProgressReporter( + IndexCommandOptions options, + string spinnerMessage, + string[] spinnerFrames, + Action writeJsonVerbose, + Func? canResume = null, + bool clearProgressLineBeforeWrite = false) + { + private CancellationTokenSource? spinner; + + internal bool Interactive { get; } = + !options.Json + && !options.Quiet + && ConsoleUi.ShouldUseInteractiveConsole(); + + internal void Start() + { + if (!Interactive || spinner != null) + return; + + spinner = ConsoleUi.StartSpinner(spinnerMessage, spinnerFrames); + } + + internal void Pause() + { + if (spinner == null) + return; + + ConsoleUi.StopSpinner(spinner); + spinner = null; + } + + internal void Resume() + { + if (!Interactive || canResume?.Invoke() == false) + return; + + Start(); + } + + internal void WriteVerbose(string message) + { + if (!options.Verbose || options.Quiet) + return; + + if (options.Json) + { + writeJsonVerbose(message); + return; + } + + Pause(); + if (clearProgressLineBeforeWrite) + ConsoleUi.ClearProgressLine(); + CommandOutputWriter.WriteLine(message); + Resume(); + } + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index a20535308..90009ba8f 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -168,9 +168,12 @@ private static int RunUpdateMode( if (!options.Json && !options.Quiet) CommandOutputWriter.WriteLine($"Updating {ConsoleUi.Counted(targetPaths.Count, "file")}..."); - CancellationTokenSource? updateCts = null; - var interactiveUpdateSpinner = !options.Json && !options.Quiet && ConsoleUi.ShouldUseInteractiveConsole(); int updated = 0, removed = 0, skipped = 0, warnings = 0, errors = 0; + var updateProgress = new IndexProgressReporter( + options, + "Updating...", + spinnerFrames, + CommandErrorWriter.WriteStderr); var errorList = new List(); var fileErrorList = new List(); var warningList = new List(); @@ -318,54 +321,13 @@ void RecordScanErrors( if (!options.Json) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); ConsoleUi.PrintWarning($"{scanError.Path}: {scanError.Message}"); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } } } - void StartUpdateSpinnerIfNeeded() - { - if (!interactiveUpdateSpinner || updateCts != null) - return; - - updateCts = ConsoleUi.StartSpinner("Updating...", spinnerFrames); - } - - void PauseUpdateSpinnerForConsoleWrite() - { - if (updateCts == null) - return; - - ConsoleUi.StopSpinner(updateCts); - updateCts = null; - } - - void ResumeUpdateSpinnerAfterConsoleWrite() - { - if (!interactiveUpdateSpinner) - return; - - StartUpdateSpinnerIfNeeded(); - } - - void WriteUpdateVerboseStatus(string message) - { - if (!options.Verbose || options.Quiet) - return; - - if (options.Json) - { - CommandErrorWriter.WriteStderr(message); - return; - } - - PauseUpdateSpinnerForConsoleWrite(); - CommandOutputWriter.WriteLine(message); - ResumeUpdateSpinnerAfterConsoleWrite(); - } - void RecordUpdateFileFailure( string relativePath, string phase, @@ -381,10 +343,10 @@ void RecordUpdateFileFailure( fileErrorList.Add(BuildIndexFileError(relativePath, phase, exception)); if (!options.Json) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); CommandErrorWriter.WriteStderr( FormatPerFileErrorLine("ERR ", relativePath, exception, errorMessage)); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } } @@ -393,7 +355,7 @@ void ThrowIfUpdateCancelled() if (!cancellationToken.IsCancellationRequested) return; - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); throw new IndexInterruptedException(updated + removed, targetPaths.Count); } @@ -1423,7 +1385,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) ftsMutated = true; mutualRecursionRefreshNeeded = true; csharpMetadataTargetsNeedRefresh |= scopedCleanupHadCSharp; - WriteUpdateVerboseStatus( + updateProgress.WriteVerbose( $" [DEL ] purged {plannedPurged:N0} planned missing indexed path(s)"); } @@ -1444,7 +1406,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) purgeTxn.Commit(); } - StartUpdateSpinnerIfNeeded(); + updateProgress.Start(); var updateTargets = new UpdateFileTarget[targetPaths.Count]; var updateTargetIndex = 0; @@ -1476,7 +1438,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) { var target = updateTargets[targetIndex]; ThrowIfUpdateCancelled(); - StartUpdateSpinnerIfNeeded(); + updateProgress.Start(); var relPath = target.RelativePath; currentUpdatePath = relPath; currentUpdatePhase = "preparing"; @@ -1528,12 +1490,12 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) removed++; ftsMutated = true; mutualRecursionRefreshNeeded = true; - WriteUpdateVerboseStatus($" [DEL ] {relPath}"); + updateProgress.WriteVerbose($" [DEL ] {relPath}"); } else { skipped++; - WriteUpdateVerboseStatus($" [SKIP] {relPath} (not in DB)"); + updateProgress.WriteVerbose($" [SKIP] {relPath} (not in DB)"); } continue; } @@ -1547,9 +1509,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) skipped++; if (options.Verbose && !options.Json && !options.Quiet) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); CommandOutputWriter.WriteLine($" [SKIP] {relPath} ({DescribePathFilter(pathFilter.FilterKind)})"); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } continue; } @@ -1566,9 +1528,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) mutualRecursionRefreshNeeded = true; if (options.Verbose && !options.Json && !options.Quiet) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); CommandOutputWriter.WriteLine($" [DEL ] {relPath} ({DescribePathFilter(pathFilter.FilterKind)})"); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } } else @@ -1576,9 +1538,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) skipped++; if (options.Verbose && !options.Json) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); CommandOutputWriter.WriteLine($" [SKIP] {relPath} ({DescribePathFilter(pathFilter.FilterKind)})"); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } } continue; @@ -1616,9 +1578,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) warningList.Add(new CliJsonMessage(relPath, message)); if (!options.Json && !options.Quiet) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); ConsoleUi.PrintWarning(message); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } using var deleteTxn = writer.BeginTransaction(cancellationToken, "update delete missing during probe"); @@ -1657,12 +1619,12 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) } if (!options.Json) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); if (options.Verbose) CommandErrorWriter.WriteStderr($" [ERR ] {relPath}: Could not probe file for indexability/language."); else CommandErrorWriter.WriteStderr($" [ERR ] {relPath}: Could not probe file for indexability/language."); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } continue; } @@ -1687,9 +1649,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) mutualRecursionRefreshNeeded = true; if (options.Verbose && !options.Json && !options.Quiet) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); CommandOutputWriter.WriteLine($" [DEL ] {relPath} (unsupported renamed target)"); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } } else @@ -1697,9 +1659,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) skipped++; if (options.Verbose && !options.Json && !options.Quiet) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); CommandOutputWriter.WriteLine($" [SKIP] {relPath} (unsupported type)"); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } } continue; @@ -1717,9 +1679,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) mutualRecursionRefreshNeeded = true; if (options.Verbose && !options.Json && !options.Quiet) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); CommandOutputWriter.WriteLine($" [DEL ] {relPath} (no longer indexable)"); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } } else @@ -1727,9 +1689,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) skipped++; if (options.Verbose && !options.Json) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); CommandOutputWriter.WriteLine($" [SKIP] {relPath} (unsupported type)"); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } } continue; @@ -1744,9 +1706,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) warningList.Add(new CliJsonMessage(relPath, message)); if (!options.Json && !options.Quiet) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); ConsoleUi.PrintWarning($"{relPath}: {message}"); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } using var deleteTxn = writer.BeginTransaction(); @@ -1788,9 +1750,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) readableFileBytes.Remember(targetIndex, statMatchedFile.Value.Size); if (options.Verbose && !options.Json && !options.Quiet) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); CommandOutputWriter.WriteLine($" [SKIP] {relPath} (unchanged)"); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } continue; } @@ -1835,9 +1797,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) if (warning != null && !options.Json && !options.Quiet) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); ConsoleUi.PrintWarning(warning); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } var existingId = writer.GetReusableUnchangedFileId( @@ -1876,11 +1838,11 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) skipped++; if (options.Verbose && !options.Json && !options.Quiet) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); CommandOutputWriter.WriteLine(purged > 0 ? $" [SKIP] {relPath} (unchanged; purged {purged:N0} stale renamed path(s))" : $" [SKIP] {relPath} (unchanged)"); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } continue; } @@ -1930,7 +1892,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) RecordDynamicGraphFileRefresh(record.Lang); updated++; ftsMutated = true; - WriteUpdateVerboseStatus($" [OK ] {relPath} ({chunks.Count} chunks, generated-code extraction skipped)"); + updateProgress.WriteVerbose($" [OK ] {relPath} ({chunks.Count} chunks, generated-code extraction skipped)"); continue; } currentUpdatePath = FormatIndexPhasePath(relPath, "symbols"); @@ -1978,7 +1940,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) RecordDynamicGraphFileRefresh(record.Lang); updated++; ftsMutated = true; - WriteUpdateVerboseStatus($" [SKIP] {relPath} ({issue.Message})"); + updateProgress.WriteVerbose($" [SKIP] {relPath} ({issue.Message})"); continue; } SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(absPath, record.Lang)); @@ -1999,7 +1961,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) RecordDynamicGraphFileRefresh(record.Lang); updated++; ftsMutated = true; - WriteUpdateVerboseStatus($" [SKIP] {relPath} ({issue.Message})"); + updateProgress.WriteVerbose($" [SKIP] {relPath} ({issue.Message})"); continue; } writer.InsertChunks(chunks, cancellationToken); @@ -2060,7 +2022,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) mutualRecursionRefreshNeeded = true; UpdateFileCommittedForTesting?.Invoke(updated + removed, targetPaths.Count); ThrowIfUpdateCancelled(); - WriteUpdateVerboseStatus($" [OK ] {relPath} ({chunks.Count} chunks, {symbols.Count} symbols, {references.Count} refs)"); + updateProgress.WriteVerbose($" [OK ] {relPath} ({chunks.Count} chunks, {symbols.Count} symbols, {references.Count} refs)"); } catch (IndexExtractionStalledException) { @@ -2105,9 +2067,9 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) warningList.Add(new CliJsonMessage(relPath, sanitizedMessage)); if (!options.Json && !options.Quiet) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); ConsoleUi.PrintWarning(sanitizedMessage); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } DemoteReadinessOnce(); @@ -2320,9 +2282,9 @@ or IndexInterruptedException warningList.Add(new CliJsonMessage(relPath, message)); if (!options.Json && !options.Quiet) { - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); ConsoleUi.PrintWarning(message); - ResumeUpdateSpinnerAfterConsoleWrite(); + updateProgress.Resume(); } if (writer.HasFileAtPath(dbPath)) @@ -2399,7 +2361,7 @@ or IndexInterruptedException removed += purgedMissing; ftsMutated = true; mutualRecursionRefreshNeeded = true; - WriteUpdateVerboseStatus( + updateProgress.WriteVerbose( $" [DEL ] purged {purgedMissing:N0} missing indexed path(s) after --changed-between"); } } @@ -2412,7 +2374,7 @@ or IndexInterruptedException if (options.MemoryTrace) memorySamples.Add(CaptureMemorySample("reference_graph", stopwatch)); ThrowIfUpdateCancelled(); - PauseUpdateSpinnerForConsoleWrite(); + updateProgress.Pause(); if (purgedRefs > 0 && !options.Json && !options.Quiet) CommandOutputWriter.WriteLine($" Purged {purgedRefs:N0} stale references (unsupported language)"); From 64288f442828b46cfb17e9e8a419b6447e90f53a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 21:39:52 +0900 Subject: [PATCH 073/101] Separate full scan result rendering --- .../Cli/IndexCommandRunner.FullScan.Output.cs | 241 ++++++++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 226 ++++------------ 2 files changed, 289 insertions(+), 178 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.Output.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Output.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Output.cs new file mode 100644 index 000000000..f3ab450c6 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Output.cs @@ -0,0 +1,241 @@ +using System.Diagnostics; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Hooks; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class FullScanFinalOutputContext + { + internal required DbWriter Writer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required Stopwatch Stopwatch { get; init; } + internal required CliJsonSerializerContext JsonContext { get; init; } + internal required string ProjectRoot { get; init; } + internal required string ResolvedDbPath { get; init; } + internal string? InitialCwd { get; init; } + internal required List MemorySamples { get; init; } + internal PostExtractionHookRunner? PostExtractionHooks { get; init; } + internal required List WarningList { get; init; } + internal required List ErrorList { get; init; } + internal required List FileErrorList { get; init; } + internal int Warnings { get; set; } + internal int Errors { get; init; } + internal int SymbolsDroppedByKindFilter { get; init; } + internal bool StartedWithNoIndexedFiles { get; init; } + internal bool ScanHadErrors { get; init; } + internal long FreshCountFiles { get; init; } + internal long FreshCountChunks { get; init; } + internal long FreshCountSymbols { get; init; } + internal long FreshCountReferences { get; init; } + internal bool HasSqlFilesAfter { get; init; } + internal bool GraphTableAvailableAfter { get; init; } + internal bool IssuesTableAvailableAfter { get; init; } + internal bool CSharpSymbolNameReadyAfter { get; init; } + internal bool CSharpMetadataTargetReadyAfter { get; init; } + internal bool FoldReadyAfter { get; init; } + internal string? FoldReadyReasonAfter { get; init; } + internal long ExtractedFiles { get; init; } + internal long PersistedFiles { get; init; } + internal long ExtractedChunks { get; init; } + internal long PersistedChunks { get; init; } + internal long ExtractedSymbols { get; init; } + internal long PersistedSymbols { get; init; } + internal long ExtractedReferences { get; init; } + internal long PersistedReferences { get; init; } + internal int FilesCount { get; init; } + internal int Skipped { get; init; } + internal int Purged { get; init; } + internal required FileIndexer.ScanFilesResult ScanResult { get; init; } + internal required IReadOnlyDictionary LanguageCounts { get; init; } + internal bool HeadChangeDetected { get; init; } + internal string? PriorIndexedHeadCommit { get; init; } + internal string? CurrentHeadCommit { get; init; } + internal string? HeadChangeNotice { get; init; } + internal bool ShowNextSteps { get; init; } + } + + private static int WriteFullScanFinalOutput(FullScanFinalOutputContext output) + { + if (output.Options.MemoryTrace) + output.MemorySamples.Add(CaptureMemorySample("commit", output.Stopwatch)); + output.Stopwatch.Stop(); + var memoryTimeline = BuildMemoryTimeline(output.MemorySamples); + WarnIfMemoryThresholdExceeded(memoryTimeline); + // Detect cwd drift between option-parsing and finalize. See RunUpdateMode for the + // rationale; the warning is informational because we already absolutized paths. + // Issue #1577. + var finalCwd = TryCaptureCurrentDirectory(); + var cwdDriftNotice = BuildCwdDriftNotice(output.InitialCwd, finalCwd); + var cwdDriftDetected = cwdDriftNotice != null; + if (cwdDriftDetected) + { + output.WarningList.Add(new CliJsonMessage("", cwdDriftNotice!)); + output.Warnings++; + } + output.Warnings += AddPostExtractionHookWarnings(output.PostExtractionHooks, output.WarningList); + var (totalFiles, totalChunks, totalSymbols, totalReferences) = + output.StartedWithNoIndexedFiles && !output.ScanHadErrors && output.Errors == 0 + ? (output.FreshCountFiles, output.FreshCountChunks, output.FreshCountSymbols, output.FreshCountReferences) + : output.Writer.GetCounts(); + var signalReader = new DbReader(output.Writer.Connection); + var referenceExtractionCapHitsAfter = signalReader.GetReferenceExtractionCapHits(); + var referenceGraphCompleteAfter = signalReader.IsReferenceGraphComplete( + referenceExtractionCapHitsAfter); + var sqlGraphContractSignalAfter = signalReader.GetSqlGraphContractSignal(lang: null); + if (!output.HasSqlFilesAfter) + { + sqlGraphContractSignalAfter = new SqlGraphContractSignal( + Ready: true, + Relevant: false, + DegradedReason: null); + } + else if (!sqlGraphContractSignalAfter.Relevant) + { + // A failed first SQL target leaves no persisted row for DbReader to classify. + // Preserve the discovered-language contract in this immediate index response. + // 最初の SQL target failure で row が無くても index response は degraded を返す。 + sqlGraphContractSignalAfter = new SqlGraphContractSignal( + Ready: false, + Relevant: true, + DegradedReason: DegradationReasonCodes.BuildSqlGraphContractDegradedReason()); + } + var hotspotFamilySignalAfter = signalReader.GetHotspotFamilySignal(lang: null); + var sqlGraphContractReadyAfter = sqlGraphContractSignalAfter.Ready; + var sqlGraphContractDegradedReasonAfter = sqlGraphContractSignalAfter.DegradedReason; + var hotspotFamilyReadyAfter = hotspotFamilySignalAfter.Ready; + var hotspotFamilyDegradedReasonAfter = hotspotFamilySignalAfter.DegradedReason; + + var foldOnlyRemediation = BuildFoldOnlyReadinessRemediation( + output.GraphTableAvailableAfter, + output.IssuesTableAvailableAfter, + sqlGraphContractReadyAfter, + hotspotFamilyReadyAfter, + output.CSharpSymbolNameReadyAfter, + output.CSharpMetadataTargetReadyAfter, + output.FoldReadyAfter, + output.FoldReadyReasonAfter, + output.ProjectRoot, + output.ResolvedDbPath); + + if (output.Options.Json) + { + CommandOutputWriter.WriteLine(JsonSerializer.Serialize(new IndexFullScanJsonResult + { + Status = output.Errors > 0 ? "partial" : "success", + Mode = output.Options.Rebuild ? "rebuild" : "incremental", + Summary = new IndexFullScanSummaryJsonResult + { + FilesTotal = totalFiles, + ChunksTotal = totalChunks, + SymbolsTotal = totalSymbols, + ReferencesTotal = totalReferences, + FilesExtracted = output.ExtractedFiles, + FilesPersisted = output.PersistedFiles, + ChunksExtracted = output.ExtractedChunks, + ChunksPersisted = output.PersistedChunks, + SymbolsExtracted = output.ExtractedSymbols, + SymbolsPersisted = output.PersistedSymbols, + ReferencesExtracted = output.ExtractedReferences, + ReferencesPersisted = output.PersistedReferences, + FilesScanned = output.FilesCount, + FilesSkipped = output.Skipped, + FilesPurged = output.Purged, + DanglingSymlinksSkipped = output.ScanResult.DanglingSymlinks.Count, + Warnings = output.Warnings, + Errors = output.Errors, + SymbolsDroppedByKindFilter = output.SymbolsDroppedByKindFilter, + }, + SymbolKindFilter = output.Options.SymbolKindFilter.ToJsonResult(), + GraphTableAvailable = output.GraphTableAvailableAfter, + GraphDataCurrent = output.Errors == 0 && output.GraphTableAvailableAfter && referenceGraphCompleteAfter, + IndexComplete = output.Errors == 0, + ReferenceExtractionLimits = ReferenceExtractor.GetSafetyLimits(), + ReferenceGraphComplete = referenceGraphCompleteAfter, + ReferenceExtractionCapHits = referenceExtractionCapHitsAfter, + ErrorCode = output.Errors > 0 ? CommandErrorCodes.IndexPartial : null, + IssuesTableAvailable = output.IssuesTableAvailableAfter, + SqlGraphContractReady = sqlGraphContractReadyAfter, + SqlGraphContractDegradedReason = sqlGraphContractDegradedReasonAfter, + HotspotFamilyReady = hotspotFamilyReadyAfter, + HotspotFamilyDegradedReason = hotspotFamilyDegradedReasonAfter, + CSharpSymbolNameReady = output.CSharpSymbolNameReadyAfter, + CSharpMetadataTargetReady = output.CSharpMetadataTargetReadyAfter, + // #86 codex review: expose fold-readiness so AI clients can decide whether + // `--exact` will use the Unicode fold path or fall back to ASCII NOCASE. + // #86 codex: AI クライアントが --exact の経路を判断できるよう fold_ready を返す。 + FoldReady = output.FoldReadyAfter, + FoldReadyReason = output.FoldReadyAfter ? null : output.FoldReadyReasonAfter, + DegradedReason = foldOnlyRemediation?.DegradedReason, + RecommendedAction = foldOnlyRemediation?.RecommendedAction, + AlternativeAction = foldOnlyRemediation?.AlternativeAction, + HeadChanged = output.HeadChangeDetected, + PriorIndexedHeadCommit = output.PriorIndexedHeadCommit, + CurrentHeadCommit = output.CurrentHeadCommit, + HeadChangeNotice = output.HeadChangeNotice, + CwdDriftDetected = cwdDriftDetected, + CwdAtStart = output.InitialCwd, + CwdAtFinalize = finalCwd, + CwdDriftNotice = cwdDriftNotice, + Errors = output.ErrorList.Count > 0 ? output.ErrorList : null, + FileErrors = output.FileErrorList.Count > 0 ? output.FileErrorList : null, + Warnings = output.WarningList.Count > 0 ? output.WarningList : null, + MemoryTimeline = memoryTimeline, + ElapsedMs = output.Stopwatch.ElapsedMilliseconds, + }, output.JsonContext.IndexFullScanJsonResult)); + } + else if (!output.Options.Quiet) + { + CommandOutputWriter.WriteLine(); + CommandOutputWriter.WriteLine(); + CommandOutputWriter.WriteLine("Done."); + CommandOutputWriter.WriteLine(); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Files", ConsoleUi.FormatNumber(totalFiles), indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Chunks", ConsoleUi.FormatNumber(totalChunks), indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Symbols", ConsoleUi.FormatNumber(totalSymbols), indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Refs", ConsoleUi.FormatNumber(totalReferences), indent: " ")); + if (output.Skipped > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Skipped", $"{ConsoleUi.FormatNumber(output.Skipped)} (unchanged)", indent: " ")); + if (output.ScanResult.DanglingSymlinks.Count > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Dangling symlinks", $"{ConsoleUi.FormatNumber(output.ScanResult.DanglingSymlinks.Count)} output.Skipped", indent: " ")); + if (output.Options.Verbose && output.ScanResult.UnknownExtensionFiles.Count > 0) + { + CommandOutputWriter.WriteLine($" Unknown extension files: {ConsoleUi.FormatNumber(output.ScanResult.UnknownExtensionFiles.Count)}"); + foreach (var relPath in output.ScanResult.UnknownExtensionFiles.Take(5)) + CommandOutputWriter.WriteLine($" {relPath}"); + if (output.ScanResult.UnknownExtensionFiles.Count > 5) + CommandOutputWriter.WriteLine($" ... {ConsoleUi.FormatNumber(output.ScanResult.UnknownExtensionFiles.Count - 5)} more"); + } + if (output.Warnings > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Warnings", ConsoleUi.FormatNumber(output.Warnings), indent: " ")); + if (output.Errors > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Errors", ConsoleUi.FormatNumber(output.Errors), indent: " ")); + if (output.SymbolsDroppedByKindFilter > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Filtered symbols", ConsoleUi.FormatNumber(output.SymbolsDroppedByKindFilter), indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Graph", output.GraphTableAvailableAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Issues", output.IssuesTableAvailableAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("SQL graph", sqlGraphContractReadyAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Hotspots", hotspotFamilyReadyAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("C# names", output.CSharpSymbolNameReadyAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("C# meta", output.CSharpMetadataTargetReadyAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Fold", output.FoldReadyAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Elapsed", ConsoleUi.FormatDuration(output.Stopwatch.Elapsed, output.Options.DurationFormat), indent: " ")); + CommandOutputWriter.WriteLine(); + if (output.Errors > 0) + ConsoleUi.PrintWarning($"Some files failed to index. Fix the reported files or permissions, then rerun `cdidx index \"{output.ProjectRoot}\"` to restore a fully ready index."); + if (!output.GraphTableAvailableAfter || !output.IssuesTableAvailableAfter || !sqlGraphContractReadyAfter || !hotspotFamilyReadyAfter || !output.CSharpSymbolNameReadyAfter || !output.CSharpMetadataTargetReadyAfter || !output.FoldReadyAfter) + ConsoleUi.PrintWarning(GetIndexReadinessWarning(output.GraphTableAvailableAfter, output.IssuesTableAvailableAfter, sqlGraphContractReadyAfter, hotspotFamilyReadyAfter, output.CSharpSymbolNameReadyAfter, output.CSharpMetadataTargetReadyAfter, output.FoldReadyAfter, output.FoldReadyReasonAfter, output.ProjectRoot, output.ResolvedDbPath)); + if (cwdDriftDetected) + ConsoleUi.PrintWarning(cwdDriftNotice!); + if (output.Errors == 0 && output.ShowNextSteps) + ConsoleUi.PrintIndexCompleteSummary(output.ProjectRoot, output.ResolvedDbPath, incremental: !output.Options.Rebuild, output.FilesCount, output.LanguageCounts); + } + + if (!output.Options.Json && !output.Options.Quiet && output.Stopwatch.Elapsed >= TimeSpan.FromSeconds(5)) + ConsoleUi.EmitCompletionNotification( + output.Options.NotifyMode, + $"cdidx index complete ({ConsoleUi.Counted(output.FilesCount, "file", format: "N0")})"); + + return output.Errors > 0 && !output.Options.AllowPartial + ? CommandExitCodes.PartialResult + : CommandExitCodes.Success; + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index e244ef522..01ddace1a 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -2767,184 +2767,54 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) hotspotAggregateRefresh.Complete(cancellationToken); writer.ClearBatchInProgress(); fullScanTxn.Commit(); - if (options.MemoryTrace) - memorySamples.Add(CaptureMemorySample("commit", stopwatch)); - stopwatch.Stop(); - var memoryTimeline = BuildMemoryTimeline(memorySamples); - WarnIfMemoryThresholdExceeded(memoryTimeline); - // Detect cwd drift between option-parsing and finalize. See RunUpdateMode for the - // rationale; the warning is informational because we already absolutized paths. - // Issue #1577. - var finalCwd = TryCaptureCurrentDirectory(); - var cwdDriftNotice = BuildCwdDriftNotice(initialCwd, finalCwd); - var cwdDriftDetected = cwdDriftNotice != null; - if (cwdDriftDetected) - { - warningList.Add(new CliJsonMessage("", cwdDriftNotice!)); - warnings++; - } - warnings += AddPostExtractionHookWarnings(postExtractionHooks, warningList); - var (totalFiles, totalChunks, totalSymbols, totalReferences) = - startedWithNoIndexedFiles && !scanHadErrors && errors == 0 - ? (freshCountFiles, freshCountChunks, freshCountSymbols, freshCountReferences) - : writer.GetCounts(); - var signalReader = new DbReader(writer.Connection); - var referenceExtractionCapHitsAfter = signalReader.GetReferenceExtractionCapHits(); - var referenceGraphCompleteAfter = signalReader.IsReferenceGraphComplete( - referenceExtractionCapHitsAfter); - var sqlGraphContractSignalAfter = signalReader.GetSqlGraphContractSignal(lang: null); - if (!hasSqlFilesAfter) - { - sqlGraphContractSignalAfter = new SqlGraphContractSignal( - Ready: true, - Relevant: false, - DegradedReason: null); - } - else if (!sqlGraphContractSignalAfter.Relevant) + return WriteFullScanFinalOutput(new FullScanFinalOutputContext { - // A failed first SQL target leaves no persisted row for DbReader to classify. - // Preserve the discovered-language contract in this immediate index response. - // 最初の SQL target failure で row が無くても index response は degraded を返す。 - sqlGraphContractSignalAfter = new SqlGraphContractSignal( - Ready: false, - Relevant: true, - DegradedReason: DegradationReasonCodes.BuildSqlGraphContractDegradedReason()); - } - var hotspotFamilySignalAfter = signalReader.GetHotspotFamilySignal(lang: null); - var sqlGraphContractReadyAfter = sqlGraphContractSignalAfter.Ready; - var sqlGraphContractDegradedReasonAfter = sqlGraphContractSignalAfter.DegradedReason; - var hotspotFamilyReadyAfter = hotspotFamilySignalAfter.Ready; - var hotspotFamilyDegradedReasonAfter = hotspotFamilySignalAfter.DegradedReason; - - var foldOnlyRemediation = BuildFoldOnlyReadinessRemediation( - graphTableAvailableAfter, - issuesTableAvailableAfter, - sqlGraphContractReadyAfter, - hotspotFamilyReadyAfter, - csharpSymbolNameReadyAfter, - csharpMetadataTargetReadyAfter, - foldReadyAfter, - foldReadyReasonAfter, - projectRoot, - resolvedDbPath); - - if (options.Json) - { - CommandOutputWriter.WriteLine(JsonSerializer.Serialize(new IndexFullScanJsonResult - { - Status = errors > 0 ? "partial" : "success", - Mode = options.Rebuild ? "rebuild" : "incremental", - Summary = new IndexFullScanSummaryJsonResult - { - FilesTotal = totalFiles, - ChunksTotal = totalChunks, - SymbolsTotal = totalSymbols, - ReferencesTotal = totalReferences, - FilesExtracted = extractedFiles, - FilesPersisted = persistedFiles, - ChunksExtracted = extractedChunks, - ChunksPersisted = persistedChunks, - SymbolsExtracted = extractedSymbols, - SymbolsPersisted = persistedSymbols, - ReferencesExtracted = extractedReferences, - ReferencesPersisted = persistedReferences, - FilesScanned = files.Count, - FilesSkipped = skipped, - FilesPurged = purged, - DanglingSymlinksSkipped = scanResult.DanglingSymlinks.Count, - Warnings = warnings, - Errors = errors, - SymbolsDroppedByKindFilter = symbolsDroppedByKindFilter, - }, - SymbolKindFilter = options.SymbolKindFilter.ToJsonResult(), - GraphTableAvailable = graphTableAvailableAfter, - GraphDataCurrent = errors == 0 && graphTableAvailableAfter && referenceGraphCompleteAfter, - IndexComplete = errors == 0, - ReferenceExtractionLimits = ReferenceExtractor.GetSafetyLimits(), - ReferenceGraphComplete = referenceGraphCompleteAfter, - ReferenceExtractionCapHits = referenceExtractionCapHitsAfter, - ErrorCode = errors > 0 ? CommandErrorCodes.IndexPartial : null, - IssuesTableAvailable = issuesTableAvailableAfter, - SqlGraphContractReady = sqlGraphContractReadyAfter, - SqlGraphContractDegradedReason = sqlGraphContractDegradedReasonAfter, - HotspotFamilyReady = hotspotFamilyReadyAfter, - HotspotFamilyDegradedReason = hotspotFamilyDegradedReasonAfter, - CSharpSymbolNameReady = csharpSymbolNameReadyAfter, - CSharpMetadataTargetReady = csharpMetadataTargetReadyAfter, - // #86 codex review: expose fold-readiness so AI clients can decide whether - // `--exact` will use the Unicode fold path or fall back to ASCII NOCASE. - // #86 codex: AI クライアントが --exact の経路を判断できるよう fold_ready を返す。 - FoldReady = foldReadyAfter, - FoldReadyReason = foldReadyAfter ? null : foldReadyReasonAfter, - DegradedReason = foldOnlyRemediation?.DegradedReason, - RecommendedAction = foldOnlyRemediation?.RecommendedAction, - AlternativeAction = foldOnlyRemediation?.AlternativeAction, - HeadChanged = headChangeDetected, - PriorIndexedHeadCommit = priorIndexedHeadCommit, - CurrentHeadCommit = currentHeadCommit, - HeadChangeNotice = headChangeNotice, - CwdDriftDetected = cwdDriftDetected, - CwdAtStart = initialCwd, - CwdAtFinalize = finalCwd, - CwdDriftNotice = cwdDriftNotice, - Errors = errorList.Count > 0 ? errorList : null, - FileErrors = fileErrorList.Count > 0 ? fileErrorList : null, - Warnings = warningList.Count > 0 ? warningList : null, - MemoryTimeline = memoryTimeline, - ElapsedMs = stopwatch.ElapsedMilliseconds, - }, jsonContext.IndexFullScanJsonResult)); - } - else if (!options.Quiet) - { - CommandOutputWriter.WriteLine(); - CommandOutputWriter.WriteLine(); - CommandOutputWriter.WriteLine("Done."); - CommandOutputWriter.WriteLine(); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Files", ConsoleUi.FormatNumber(totalFiles), indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Chunks", ConsoleUi.FormatNumber(totalChunks), indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Symbols", ConsoleUi.FormatNumber(totalSymbols), indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Refs", ConsoleUi.FormatNumber(totalReferences), indent: " ")); - if (skipped > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Skipped", $"{ConsoleUi.FormatNumber(skipped)} (unchanged)", indent: " ")); - if (scanResult.DanglingSymlinks.Count > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Dangling symlinks", $"{ConsoleUi.FormatNumber(scanResult.DanglingSymlinks.Count)} skipped", indent: " ")); - if (options.Verbose && scanResult.UnknownExtensionFiles.Count > 0) - { - CommandOutputWriter.WriteLine($" Unknown extension files: {ConsoleUi.FormatNumber(scanResult.UnknownExtensionFiles.Count)}"); - foreach (var relPath in scanResult.UnknownExtensionFiles.Take(5)) - CommandOutputWriter.WriteLine($" {relPath}"); - if (scanResult.UnknownExtensionFiles.Count > 5) - CommandOutputWriter.WriteLine($" ... {ConsoleUi.FormatNumber(scanResult.UnknownExtensionFiles.Count - 5)} more"); - } - if (warnings > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Warnings", ConsoleUi.FormatNumber(warnings), indent: " ")); - if (errors > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Errors", ConsoleUi.FormatNumber(errors), indent: " ")); - if (symbolsDroppedByKindFilter > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Filtered symbols", ConsoleUi.FormatNumber(symbolsDroppedByKindFilter), indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Graph", graphTableAvailableAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Issues", issuesTableAvailableAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("SQL graph", sqlGraphContractReadyAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Hotspots", hotspotFamilyReadyAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("C# names", csharpSymbolNameReadyAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("C# meta", csharpMetadataTargetReadyAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Fold", foldReadyAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Elapsed", ConsoleUi.FormatDuration(stopwatch.Elapsed, options.DurationFormat), indent: " ")); - CommandOutputWriter.WriteLine(); - if (errors > 0) - ConsoleUi.PrintWarning($"Some files failed to index. Fix the reported files or permissions, then rerun `cdidx index \"{projectRoot}\"` to restore a fully ready index."); - if (!graphTableAvailableAfter || !issuesTableAvailableAfter || !sqlGraphContractReadyAfter || !hotspotFamilyReadyAfter || !csharpSymbolNameReadyAfter || !csharpMetadataTargetReadyAfter || !foldReadyAfter) - ConsoleUi.PrintWarning(GetIndexReadinessWarning(graphTableAvailableAfter, issuesTableAvailableAfter, sqlGraphContractReadyAfter, hotspotFamilyReadyAfter, csharpSymbolNameReadyAfter, csharpMetadataTargetReadyAfter, foldReadyAfter, foldReadyReasonAfter, projectRoot, resolvedDbPath)); - if (cwdDriftDetected) - ConsoleUi.PrintWarning(cwdDriftNotice!); - if (errors == 0 && showNextSteps) - ConsoleUi.PrintIndexCompleteSummary(projectRoot, resolvedDbPath, incremental: !options.Rebuild, files.Count, languageCounts); - } - - if (!options.Json && !options.Quiet && stopwatch.Elapsed >= TimeSpan.FromSeconds(5)) - ConsoleUi.EmitCompletionNotification( - options.NotifyMode, - $"cdidx index complete ({ConsoleUi.Counted(files.Count, "file", format: "N0")})"); - - return errors > 0 && !options.AllowPartial - ? CommandExitCodes.PartialResult - : CommandExitCodes.Success; + Writer = writer, + Options = options, + Stopwatch = stopwatch, + JsonContext = jsonContext, + ProjectRoot = projectRoot, + ResolvedDbPath = resolvedDbPath, + InitialCwd = initialCwd, + MemorySamples = memorySamples, + PostExtractionHooks = postExtractionHooks, + WarningList = warningList, + ErrorList = errorList, + FileErrorList = fileErrorList, + Warnings = warnings, + Errors = errors, + SymbolsDroppedByKindFilter = symbolsDroppedByKindFilter, + StartedWithNoIndexedFiles = startedWithNoIndexedFiles, + ScanHadErrors = scanHadErrors, + FreshCountFiles = freshCountFiles, + FreshCountChunks = freshCountChunks, + FreshCountSymbols = freshCountSymbols, + FreshCountReferences = freshCountReferences, + HasSqlFilesAfter = hasSqlFilesAfter, + GraphTableAvailableAfter = graphTableAvailableAfter, + IssuesTableAvailableAfter = issuesTableAvailableAfter, + CSharpSymbolNameReadyAfter = csharpSymbolNameReadyAfter, + CSharpMetadataTargetReadyAfter = csharpMetadataTargetReadyAfter, + FoldReadyAfter = foldReadyAfter, + FoldReadyReasonAfter = foldReadyReasonAfter, + ExtractedFiles = extractedFiles, + PersistedFiles = persistedFiles, + ExtractedChunks = extractedChunks, + PersistedChunks = persistedChunks, + ExtractedSymbols = extractedSymbols, + PersistedSymbols = persistedSymbols, + ExtractedReferences = extractedReferences, + PersistedReferences = persistedReferences, + FilesCount = files.Count, + Skipped = skipped, + Purged = purged, + ScanResult = scanResult, + LanguageCounts = languageCounts, + HeadChangeDetected = headChangeDetected, + PriorIndexedHeadCommit = priorIndexedHeadCommit, + CurrentHeadCommit = currentHeadCommit, + HeadChangeNotice = headChangeNotice, + ShowNextSteps = showNextSteps, + }); } - - } From 0d0b9771bbcb34c6e1edcbd95eba7a01e6812925 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 21:42:17 +0900 Subject: [PATCH 074/101] Separate update result rendering --- .../Cli/IndexCommandRunner.Update.Output.cs | 186 ++++++++++++++++++ .../Cli/IndexCommandRunner.Update.cs | 169 +++------------- 2 files changed, 213 insertions(+), 142 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.Update.Output.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.Output.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.Output.cs new file mode 100644 index 000000000..9e14ad583 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.Output.cs @@ -0,0 +1,186 @@ +using System.Diagnostics; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Hooks; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class UpdateFinalOutputContext + { + internal required DbWriter Writer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required Stopwatch Stopwatch { get; init; } + internal required CliJsonSerializerContext JsonContext { get; init; } + internal required string ProjectRoot { get; init; } + internal required string ResolvedDbPath { get; init; } + internal string? InitialCwd { get; init; } + internal required List MemorySamples { get; init; } + internal PostExtractionHookRunner? PostExtractionHooks { get; init; } + internal required List WarningList { get; init; } + internal required List ErrorList { get; init; } + internal required List FileErrorList { get; init; } + internal int Warnings { get; set; } + internal int Errors { get; init; } + internal int SymbolsDroppedByKindFilter { get; init; } + internal bool GraphTableAvailableAfter { get; init; } + internal bool IssuesTableAvailableAfter { get; init; } + internal bool CSharpSymbolNameReadyAfter { get; init; } + internal bool CSharpMetadataTargetReadyAfter { get; init; } + internal bool FoldReadyAfter { get; init; } + internal string? FoldReadyReasonAfter { get; init; } + internal int Updated { get; init; } + internal int Removed { get; init; } + internal int Skipped { get; init; } + internal bool FtsMergeRan { get; init; } + } + + private static int WriteUpdateFinalOutput(UpdateFinalOutputContext output) + { + output.Stopwatch.Stop(); + var memoryTimeline = BuildMemoryTimeline(output.MemorySamples); + WarnIfMemoryThresholdExceeded(memoryTimeline); + // Detect cwd drift between option-parsing and finalize. Paths used in this run are + // already absolute, but a drifted cwd is a strong signal that an embedded host or + // signal handler mutated process state -- surface it so the operator can correct + // their hosting code. Issue #1577. + var finalCwd = TryCaptureCurrentDirectory(); + var cwdDriftNotice = BuildCwdDriftNotice(output.InitialCwd, finalCwd); + var cwdDriftDetected = cwdDriftNotice != null; + if (cwdDriftDetected) + { + output.WarningList.Add(new CliJsonMessage("", cwdDriftNotice!)); + output.Warnings++; + } + output.Warnings += AddPostExtractionHookWarnings(output.PostExtractionHooks, output.WarningList); + var (totalFiles, totalChunks, totalSymbols, totalReferences) = output.Writer.GetCounts(); + var signalReader = new DbReader(output.Writer.Connection); + var referenceExtractionCapHitsAfter = signalReader.GetReferenceExtractionCapHits(); + var referenceGraphCompleteAfter = signalReader.IsReferenceGraphComplete( + referenceExtractionCapHitsAfter); + var sqlGraphContractSignalAfter = signalReader.GetSqlGraphContractSignal(lang: null); + var hdlGraphContractSignalAfter = signalReader.GetHdlGraphContractSignal(lang: null); + var hotspotFamilySignalAfter = signalReader.GetHotspotFamilySignal(lang: null); + var sqlGraphContractReadyAfter = sqlGraphContractSignalAfter.Ready; + var sqlGraphContractDegradedReasonAfter = sqlGraphContractSignalAfter.DegradedReason; + var hotspotFamilyReadyAfter = hotspotFamilySignalAfter.Ready; + var hotspotFamilyDegradedReasonAfter = hotspotFamilySignalAfter.DegradedReason; + + var foldOnlyRemediation = BuildFoldOnlyReadinessRemediation( + output.GraphTableAvailableAfter, + output.IssuesTableAvailableAfter, + sqlGraphContractReadyAfter, + hotspotFamilyReadyAfter, + output.CSharpSymbolNameReadyAfter, + output.CSharpMetadataTargetReadyAfter, + output.FoldReadyAfter, + output.FoldReadyReasonAfter, + output.ProjectRoot, + output.ResolvedDbPath); + + if (output.Options.Json) + { + CommandOutputWriter.WriteLine(JsonSerializer.Serialize(new IndexUpdateJsonResult + { + Status = output.Errors > 0 ? "partial" : "success", + Mode = "update", + Summary = new IndexUpdateSummaryJsonResult + { + FilesTotal = totalFiles, + ChunksTotal = totalChunks, + SymbolsTotal = totalSymbols, + ReferencesTotal = totalReferences, + Updated = output.Updated, + Removed = output.Removed, + Skipped = output.Skipped, + Warnings = output.Warnings, + Errors = output.Errors, + SymbolsDroppedByKindFilter = output.SymbolsDroppedByKindFilter, + FtsOptimizeRan = false, + FtsMergeRan = output.FtsMergeRan, + }, + SymbolKindFilter = output.Options.SymbolKindFilter.ToJsonResult(), + GraphTableAvailable = output.GraphTableAvailableAfter, + GraphDataCurrent = output.Errors == 0 + && output.GraphTableAvailableAfter + && referenceGraphCompleteAfter + && hdlGraphContractSignalAfter.Ready, + IndexComplete = output.Errors == 0, + ReferenceExtractionLimits = ReferenceExtractor.GetSafetyLimits(), + ReferenceGraphComplete = referenceGraphCompleteAfter, + ReferenceExtractionCapHits = referenceExtractionCapHitsAfter, + ErrorCode = output.Errors > 0 ? CommandErrorCodes.IndexPartial : null, + IssuesTableAvailable = output.IssuesTableAvailableAfter, + SqlGraphContractReady = sqlGraphContractReadyAfter, + SqlGraphContractDegradedReason = sqlGraphContractDegradedReasonAfter, + HdlGraphContractReady = hdlGraphContractSignalAfter.Ready, + HdlGraphContractDegradedReason = hdlGraphContractSignalAfter.DegradedReason, + HotspotFamilyReady = hotspotFamilyReadyAfter, + HotspotFamilyDegradedReason = hotspotFamilyDegradedReasonAfter, + CSharpSymbolNameReady = output.CSharpSymbolNameReadyAfter, + CSharpMetadataTargetReady = output.CSharpMetadataTargetReadyAfter, + // #86 codex review: expose fold-readiness so AI clients can decide whether + // `--exact` will use the Unicode fold path or fall back to ASCII NOCASE. + // #86 codex: AI クライアントが --exact の経路を判断できるよう fold_ready を返す。 + FoldReady = output.FoldReadyAfter, + FoldReadyReason = output.FoldReadyAfter ? null : output.FoldReadyReasonAfter, + DegradedReason = foldOnlyRemediation?.DegradedReason, + RecommendedAction = foldOnlyRemediation?.RecommendedAction, + AlternativeAction = foldOnlyRemediation?.AlternativeAction, + CwdDriftDetected = cwdDriftDetected, + CwdAtStart = output.InitialCwd, + CwdAtFinalize = finalCwd, + CwdDriftNotice = cwdDriftNotice, + Errors = output.ErrorList.Count > 0 ? output.ErrorList : null, + FileErrors = output.FileErrorList.Count > 0 ? output.FileErrorList : null, + Warnings = output.WarningList.Count > 0 ? output.WarningList : null, + MemoryTimeline = memoryTimeline, + ElapsedMs = output.Stopwatch.ElapsedMilliseconds, + }, output.JsonContext.IndexUpdateJsonResult)); + } + else + { + CommandOutputWriter.WriteLine(); + CommandOutputWriter.WriteLine(); + CommandOutputWriter.WriteLine("Done."); + CommandOutputWriter.WriteLine(); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Files", $"{ConsoleUi.FormatNumber(totalFiles)} (total in DB)", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Chunks", ConsoleUi.FormatNumber(totalChunks), indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Symbols", ConsoleUi.FormatNumber(totalSymbols), indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Refs", ConsoleUi.FormatNumber(totalReferences), indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Updated", ConsoleUi.FormatNumber(output.Updated), indent: " ")); + if (output.Removed > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Removed", ConsoleUi.FormatNumber(output.Removed), indent: " ")); + if (output.Skipped > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Skipped", ConsoleUi.FormatNumber(output.Skipped), indent: " ")); + if (output.Warnings > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Warnings", ConsoleUi.FormatNumber(output.Warnings), indent: " ")); + if (output.Errors > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Errors", ConsoleUi.FormatNumber(output.Errors), indent: " ")); + if (output.SymbolsDroppedByKindFilter > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Filtered symbols", ConsoleUi.FormatNumber(output.SymbolsDroppedByKindFilter), indent: " ")); + if (output.FtsMergeRan) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("FTS merge", "completed", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Graph", output.GraphTableAvailableAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Issues", output.IssuesTableAvailableAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("SQL graph", sqlGraphContractReadyAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Hotspots", hotspotFamilyReadyAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("C# names", output.CSharpSymbolNameReadyAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("C# meta", output.CSharpMetadataTargetReadyAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Fold", output.FoldReadyAfter ? "ready" : "degraded", indent: " ")); + CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Elapsed", ConsoleUi.FormatDuration(output.Stopwatch.Elapsed, output.Options.DurationFormat), indent: " ")); + CommandOutputWriter.WriteLine(); + if (output.Errors > 0) + ConsoleUi.PrintWarning($"Some files failed to update. Fix the reported files or permissions, then rerun `cdidx index \"{output.ProjectRoot}\"` to restore a fully ready index."); + if (!output.GraphTableAvailableAfter || !output.IssuesTableAvailableAfter || !sqlGraphContractReadyAfter || !hotspotFamilyReadyAfter || !output.CSharpSymbolNameReadyAfter || !output.CSharpMetadataTargetReadyAfter || !output.FoldReadyAfter) + ConsoleUi.PrintWarning(GetIndexReadinessWarning(output.GraphTableAvailableAfter, output.IssuesTableAvailableAfter, sqlGraphContractReadyAfter, hotspotFamilyReadyAfter, output.CSharpSymbolNameReadyAfter, output.CSharpMetadataTargetReadyAfter, output.FoldReadyAfter, output.FoldReadyReasonAfter, output.ProjectRoot, output.ResolvedDbPath)); + if (cwdDriftDetected) + ConsoleUi.PrintWarning(cwdDriftNotice!); + } + + if (!output.Options.Json && !output.Options.Quiet && output.Stopwatch.Elapsed >= TimeSpan.FromSeconds(5)) + ConsoleUi.EmitCompletionNotification( + output.Options.NotifyMode, + $"cdidx index update complete ({ConsoleUi.Counted(output.Updated + output.Removed + output.Skipped, "file", format: "N0")})"); + + return output.Errors > 0 && !output.Options.AllowPartial + ? CommandExitCodes.PartialResult + : CommandExitCodes.Success; + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index 90009ba8f..6b5092948 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -2636,149 +2636,34 @@ or IndexInterruptedException indexRunDiagnostics, writer.GetReferenceExtractionCapHits(issuesTableAvailableAfter)); } - stopwatch.Stop(); - var memoryTimeline = BuildMemoryTimeline(memorySamples); - WarnIfMemoryThresholdExceeded(memoryTimeline); - // Detect cwd drift between option-parsing and finalize. Paths used in this run are - // already absolute, but a drifted cwd is a strong signal that an embedded host or - // signal handler mutated process state -- surface it so the operator can correct - // their hosting code. Issue #1577. - var finalCwd = TryCaptureCurrentDirectory(); - var cwdDriftNotice = BuildCwdDriftNotice(initialCwd, finalCwd); - var cwdDriftDetected = cwdDriftNotice != null; - if (cwdDriftDetected) + return WriteUpdateFinalOutput(new UpdateFinalOutputContext { - warningList.Add(new CliJsonMessage("", cwdDriftNotice!)); - warnings++; - } - warnings += AddPostExtractionHookWarnings(postExtractionHooks.ValueIfCreated, warningList); - var (totalFiles, totalChunks, totalSymbols, totalReferences) = writer.GetCounts(); - var signalReader = new DbReader(writer.Connection); - var referenceExtractionCapHitsAfter = signalReader.GetReferenceExtractionCapHits(); - var referenceGraphCompleteAfter = signalReader.IsReferenceGraphComplete( - referenceExtractionCapHitsAfter); - var sqlGraphContractSignalAfter = signalReader.GetSqlGraphContractSignal(lang: null); - var hdlGraphContractSignalAfter = signalReader.GetHdlGraphContractSignal(lang: null); - var hotspotFamilySignalAfter = signalReader.GetHotspotFamilySignal(lang: null); - var sqlGraphContractReadyAfter = sqlGraphContractSignalAfter.Ready; - var sqlGraphContractDegradedReasonAfter = sqlGraphContractSignalAfter.DegradedReason; - var hotspotFamilyReadyAfter = hotspotFamilySignalAfter.Ready; - var hotspotFamilyDegradedReasonAfter = hotspotFamilySignalAfter.DegradedReason; - - var foldOnlyRemediation = BuildFoldOnlyReadinessRemediation( - graphTableAvailableAfter, - issuesTableAvailableAfter, - sqlGraphContractReadyAfter, - hotspotFamilyReadyAfter, - csharpSymbolNameReadyAfter, - csharpMetadataTargetReadyAfter, - foldReadyAfter, - foldReadyReasonAfter, - projectRoot, - resolvedDbPath); - - if (options.Json) - { - CommandOutputWriter.WriteLine(JsonSerializer.Serialize(new IndexUpdateJsonResult - { - Status = errors > 0 ? "partial" : "success", - Mode = "update", - Summary = new IndexUpdateSummaryJsonResult - { - FilesTotal = totalFiles, - ChunksTotal = totalChunks, - SymbolsTotal = totalSymbols, - ReferencesTotal = totalReferences, - Updated = updated, - Removed = removed, - Skipped = skipped, - Warnings = warnings, - Errors = errors, - SymbolsDroppedByKindFilter = symbolsDroppedByKindFilter, - FtsOptimizeRan = false, - FtsMergeRan = ftsMergeRan, - }, - SymbolKindFilter = options.SymbolKindFilter.ToJsonResult(), - GraphTableAvailable = graphTableAvailableAfter, - GraphDataCurrent = errors == 0 - && graphTableAvailableAfter - && referenceGraphCompleteAfter - && hdlGraphContractSignalAfter.Ready, - IndexComplete = errors == 0, - ReferenceExtractionLimits = ReferenceExtractor.GetSafetyLimits(), - ReferenceGraphComplete = referenceGraphCompleteAfter, - ReferenceExtractionCapHits = referenceExtractionCapHitsAfter, - ErrorCode = errors > 0 ? CommandErrorCodes.IndexPartial : null, - IssuesTableAvailable = issuesTableAvailableAfter, - SqlGraphContractReady = sqlGraphContractReadyAfter, - SqlGraphContractDegradedReason = sqlGraphContractDegradedReasonAfter, - HdlGraphContractReady = hdlGraphContractSignalAfter.Ready, - HdlGraphContractDegradedReason = hdlGraphContractSignalAfter.DegradedReason, - HotspotFamilyReady = hotspotFamilyReadyAfter, - HotspotFamilyDegradedReason = hotspotFamilyDegradedReasonAfter, - CSharpSymbolNameReady = csharpSymbolNameReadyAfter, - CSharpMetadataTargetReady = csharpMetadataTargetReadyAfter, - // #86 codex review: expose fold-readiness so AI clients can decide whether - // `--exact` will use the Unicode fold path or fall back to ASCII NOCASE. - // #86 codex: AI クライアントが --exact の経路を判断できるよう fold_ready を返す。 - FoldReady = foldReadyAfter, - FoldReadyReason = foldReadyAfter ? null : foldReadyReasonAfter, - DegradedReason = foldOnlyRemediation?.DegradedReason, - RecommendedAction = foldOnlyRemediation?.RecommendedAction, - AlternativeAction = foldOnlyRemediation?.AlternativeAction, - CwdDriftDetected = cwdDriftDetected, - CwdAtStart = initialCwd, - CwdAtFinalize = finalCwd, - CwdDriftNotice = cwdDriftNotice, - Errors = errorList.Count > 0 ? errorList : null, - FileErrors = fileErrorList.Count > 0 ? fileErrorList : null, - Warnings = warningList.Count > 0 ? warningList : null, - MemoryTimeline = memoryTimeline, - ElapsedMs = stopwatch.ElapsedMilliseconds, - }, jsonContext.IndexUpdateJsonResult)); - } - else - { - CommandOutputWriter.WriteLine(); - CommandOutputWriter.WriteLine(); - CommandOutputWriter.WriteLine("Done."); - CommandOutputWriter.WriteLine(); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Files", $"{ConsoleUi.FormatNumber(totalFiles)} (total in DB)", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Chunks", ConsoleUi.FormatNumber(totalChunks), indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Symbols", ConsoleUi.FormatNumber(totalSymbols), indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Refs", ConsoleUi.FormatNumber(totalReferences), indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Updated", ConsoleUi.FormatNumber(updated), indent: " ")); - if (removed > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Removed", ConsoleUi.FormatNumber(removed), indent: " ")); - if (skipped > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Skipped", ConsoleUi.FormatNumber(skipped), indent: " ")); - if (warnings > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Warnings", ConsoleUi.FormatNumber(warnings), indent: " ")); - if (errors > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Errors", ConsoleUi.FormatNumber(errors), indent: " ")); - if (symbolsDroppedByKindFilter > 0) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Filtered symbols", ConsoleUi.FormatNumber(symbolsDroppedByKindFilter), indent: " ")); - if (ftsMergeRan) CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("FTS merge", "completed", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Graph", graphTableAvailableAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Issues", issuesTableAvailableAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("SQL graph", sqlGraphContractReadyAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Hotspots", hotspotFamilyReadyAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("C# names", csharpSymbolNameReadyAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("C# meta", csharpMetadataTargetReadyAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Fold", foldReadyAfter ? "ready" : "degraded", indent: " ")); - CommandOutputWriter.WriteLine(ConsoleUi.FormatSummaryLine("Elapsed", ConsoleUi.FormatDuration(stopwatch.Elapsed, options.DurationFormat), indent: " ")); - CommandOutputWriter.WriteLine(); - if (errors > 0) - ConsoleUi.PrintWarning($"Some files failed to update. Fix the reported files or permissions, then rerun `cdidx index \"{projectRoot}\"` to restore a fully ready index."); - if (!graphTableAvailableAfter || !issuesTableAvailableAfter || !sqlGraphContractReadyAfter || !hotspotFamilyReadyAfter || !csharpSymbolNameReadyAfter || !csharpMetadataTargetReadyAfter || !foldReadyAfter) - ConsoleUi.PrintWarning(GetIndexReadinessWarning(graphTableAvailableAfter, issuesTableAvailableAfter, sqlGraphContractReadyAfter, hotspotFamilyReadyAfter, csharpSymbolNameReadyAfter, csharpMetadataTargetReadyAfter, foldReadyAfter, foldReadyReasonAfter, projectRoot, resolvedDbPath)); - if (cwdDriftDetected) - ConsoleUi.PrintWarning(cwdDriftNotice!); - } - - if (!options.Json && !options.Quiet && stopwatch.Elapsed >= TimeSpan.FromSeconds(5)) - ConsoleUi.EmitCompletionNotification( - options.NotifyMode, - $"cdidx index update complete ({ConsoleUi.Counted(updated + removed + skipped, "file", format: "N0")})"); - - return errors > 0 && !options.AllowPartial - ? CommandExitCodes.PartialResult - : CommandExitCodes.Success; + Writer = writer, + Options = options, + Stopwatch = stopwatch, + JsonContext = jsonContext, + ProjectRoot = projectRoot, + ResolvedDbPath = resolvedDbPath, + InitialCwd = initialCwd, + MemorySamples = memorySamples, + PostExtractionHooks = postExtractionHooks.ValueIfCreated, + WarningList = warningList, + ErrorList = errorList, + FileErrorList = fileErrorList, + Warnings = warnings, + Errors = errors, + SymbolsDroppedByKindFilter = symbolsDroppedByKindFilter, + GraphTableAvailableAfter = graphTableAvailableAfter, + IssuesTableAvailableAfter = issuesTableAvailableAfter, + CSharpSymbolNameReadyAfter = csharpSymbolNameReadyAfter, + CSharpMetadataTargetReadyAfter = csharpMetadataTargetReadyAfter, + FoldReadyAfter = foldReadyAfter, + FoldReadyReasonAfter = foldReadyReasonAfter, + Updated = updated, + Removed = removed, + Skipped = skipped, + FtsMergeRan = ftsMergeRan, + }); } private sealed class CSharpWorkspaceChangedException(string message) : Exception(message); From 6274f8eff1116d774383ae36f7f28a8152100b87 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 21:53:55 +0900 Subject: [PATCH 075/101] Separate index snapshot failure reporting --- ...xCommandRunner.CSharpWorkspaceSnapshots.cs | 30 ++++ ...xCommandRunner.FullScan.SnapshotFailure.cs | 145 +++++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 170 ++++-------------- ...dexCommandRunner.Update.SnapshotFailure.cs | 121 +++++++++++++ .../Cli/IndexCommandRunner.Update.cs | 131 +++----------- 5 files changed, 355 insertions(+), 242 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.CSharpWorkspaceSnapshots.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.SnapshotFailure.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.Update.SnapshotFailure.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.CSharpWorkspaceSnapshots.cs b/src/CodeIndex/Cli/IndexCommandRunner.CSharpWorkspaceSnapshots.cs new file mode 100644 index 000000000..6212d5b16 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.CSharpWorkspaceSnapshots.cs @@ -0,0 +1,30 @@ +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private static string FormatCSharpWorkspaceSnapshotPath( + string projectRoot, + string? path) + { + if (string.IsNullOrWhiteSpace(path) || path == "") + return ""; + if (!Path.IsPathRooted(path)) + return FileIndexer.NormalizePathSeparators(path); + + try + { + return FileIndexer.NormalizePathSeparators( + FileIndexer.GetRelativePathFromDirectory(projectRoot, path)); + } + catch (Exception ex) when ( + ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + return ""; + } + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.SnapshotFailure.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.SnapshotFailure.cs new file mode 100644 index 000000000..4ffdebb19 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.SnapshotFailure.cs @@ -0,0 +1,145 @@ +using System.Diagnostics; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class FullScanSnapshotFailureContext + { + internal required DbWriter Writer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required Stopwatch Stopwatch { get; init; } + internal required CliJsonSerializerContext JsonContext { get; init; } + internal required string ProjectRoot { get; init; } + internal int PriorReadiness { get; init; } + internal bool CSharpSymbolNameContractMatchesCurrent { get; init; } + internal bool PriorMetadataTargetCsharpMatchesCurrent { get; init; } + internal string? PriorFoldVersion { get; init; } + internal string? PriorFoldFingerprint { get; init; } + internal required List MemorySamples { get; init; } + internal required IReadOnlyDictionary LanguageCounts { get; init; } + internal int FilesCount { get; init; } + internal int Skipped { get; init; } + internal int DanglingSymlinkCount { get; init; } + internal int Warnings { get; init; } + internal int Errors { get; set; } + internal int SymbolsDroppedByKindFilter { get; init; } + internal required List ErrorList { get; init; } + internal required List FileErrorList { get; init; } + internal required List WarningList { get; init; } + } + + private static int WriteFullScanSnapshotFailure( + string changedPath, + FullScanSnapshotFailureContext failure) + { + var formattedPath = FormatCSharpWorkspaceSnapshotPath(failure.ProjectRoot, changedPath); + var exception = new IOException( + "Directory entries or scan configuration changed after source discovery; rerun indexing from a stable workspace snapshot."); + failure.Errors++; + failure.ErrorList.Add(new CliJsonMessage(formattedPath, FormatIndexFileException(exception))); + if (failure.FileErrorList.Count < PartialIndexFileErrorLimit) + failure.FileErrorList.Add(BuildIndexFileError(formattedPath, "csharp_workspace_validation", exception)); + + failure.Stopwatch.Stop(); + var (totalFiles, totalChunks, totalSymbols, totalReferences) = failure.Writer.GetCounts(); + var graphTableAvailable = (failure.PriorReadiness & DbContext.GraphReadyFlag) != 0; + var issuesTableAvailable = (failure.PriorReadiness & DbContext.IssuesReadyFlag) != 0; + var referenceExtractionCapHits = failure.Writer.GetReferenceExtractionCapHits(issuesTableAvailable); + // The connection is writable, but failure diagnostics must not trigger the + // DbReader constructor's interrupted-FTS recovery before the write barrier. + using var signalReader = new DbReader(failure.Writer.Connection, isReadOnly: true); + var discoveredCSharpFiles = failure.LanguageCounts.ContainsKey("csharp"); + var discoveredSqlFiles = failure.LanguageCounts.ContainsKey("sql"); + var persistedCSharpFiles = failure.Writer.HasAnyFilesWithLanguage("csharp"); + var persistedSqlFiles = failure.Writer.HasAnyFilesWithLanguage("sql"); + var hasCSharpFiles = discoveredCSharpFiles || persistedCSharpFiles; + var hasSqlFiles = discoveredSqlFiles || persistedSqlFiles; + var sqlGraphContractSignal = signalReader.GetSqlGraphContractSignal(lang: null); + if (!hasSqlFiles) + { + sqlGraphContractSignal = new SqlGraphContractSignal( + Ready: true, + Relevant: false, + DegradedReason: null); + } + else if (!sqlGraphContractSignal.Relevant) + { + // The write barrier can fail after discovery but before the first target is + // persisted. Keep positive language evidence in the immediate response. + // write 前 barrier failure でも発見済み language の degraded signal を保持する。 + sqlGraphContractSignal = new SqlGraphContractSignal( + Ready: false, + Relevant: true, + DegradedReason: DegradationReasonCodes.BuildSqlGraphContractDegradedReason()); + } + var hotspotFamilySignal = signalReader.GetHotspotFamilySignal(lang: null); + var csharpSymbolNameReady = !hasCSharpFiles + || (persistedCSharpFiles && failure.CSharpSymbolNameContractMatchesCurrent); + var csharpMetadataTargetReady = !hasCSharpFiles + || (persistedCSharpFiles && failure.PriorMetadataTargetCsharpMatchesCurrent); + var foldReady = (failure.PriorReadiness & DbContext.FoldReadyFlag) != 0; + var memoryTimeline = BuildMemoryTimeline(failure.MemorySamples); + + if (failure.Options.Json) + { + CommandOutputWriter.WriteLine(JsonSerializer.Serialize(new IndexFullScanJsonResult + { + Status = "partial", + Mode = failure.Options.Rebuild ? "rebuild" : "incremental", + Summary = new IndexFullScanSummaryJsonResult + { + FilesTotal = totalFiles, + ChunksTotal = totalChunks, + SymbolsTotal = totalSymbols, + ReferencesTotal = totalReferences, + FilesScanned = failure.FilesCount, + FilesSkipped = failure.Skipped, + FilesPurged = 0, + DanglingSymlinksSkipped = failure.DanglingSymlinkCount, + Warnings = failure.Warnings, + Errors = failure.Errors, + SymbolsDroppedByKindFilter = failure.SymbolsDroppedByKindFilter, + }, + SymbolKindFilter = failure.Options.SymbolKindFilter.ToJsonResult(), + GraphTableAvailable = graphTableAvailable, + GraphDataCurrent = false, + IndexComplete = false, + ReferenceExtractionLimits = ReferenceExtractor.GetSafetyLimits(), + ReferenceGraphComplete = signalReader.IsReferenceGraphComplete( + referenceExtractionCapHits), + ReferenceExtractionCapHits = referenceExtractionCapHits, + ErrorCode = CommandErrorCodes.IndexPartial, + IssuesTableAvailable = issuesTableAvailable, + SqlGraphContractReady = sqlGraphContractSignal.Ready, + SqlGraphContractDegradedReason = sqlGraphContractSignal.DegradedReason, + HotspotFamilyReady = hotspotFamilySignal.Ready, + HotspotFamilyDegradedReason = hotspotFamilySignal.DegradedReason, + CSharpSymbolNameReady = csharpSymbolNameReady, + CSharpMetadataTargetReady = csharpMetadataTargetReady, + FoldReady = foldReady, + FoldReadyReason = foldReady ? null : GetFoldReadyReason( + backfillReady: false, + failure.PriorFoldVersion == NameFold.Version.ToString(System.Globalization.CultureInfo.InvariantCulture), + failure.PriorFoldFingerprint == NameFold.Fingerprint()), + Errors = failure.ErrorList, + FileErrors = failure.FileErrorList, + Warnings = failure.WarningList.Count > 0 ? failure.WarningList : null, + MemoryTimeline = memoryTimeline, + ElapsedMs = failure.Stopwatch.ElapsedMilliseconds, + }, failure.JsonContext.IndexFullScanJsonResult)); + } + else if (!failure.Options.Quiet) + { + ConsoleUi.TryWriteErrorLine( + $"Indexing stopped before index-data mutation because the scan snapshot changed: {formattedPath}"); + } + + return failure.Options.AllowPartial + ? CommandExitCodes.Success + : CommandExitCodes.PartialResult; + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 01ddace1a..865a16daf 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -618,26 +618,6 @@ bool IsExistingCSharpSymbolPathNowNonCSharp(string indexPath) Dictionary? csharpWorkspaceFileSnapshots = null; - string FormatCSharpWorkspaceSnapshotPath(string? path) - { - if (string.IsNullOrWhiteSpace(path) || path == "") - return ""; - if (!Path.IsPathRooted(path)) - return FileIndexer.NormalizePathSeparators(path); - try - { - return FileIndexer.NormalizePathSeparators( - FileIndexer.GetRelativePathFromDirectory(projectRoot, path)); - } - catch (Exception ex) when (ex is IOException - or UnauthorizedAccessException - or NotSupportedException - or ArgumentException) - { - return ""; - } - } - CSharpStaticInterfaceWorkspaceSymbols BuildStableCSharpWorkspace( Func buildWorkspace) { @@ -657,7 +637,7 @@ CSharpStaticInterfaceWorkspaceSymbols BuildStableCSharpWorkspace( SourceContractEvidenceComplete: false, IncompleteSourcePaths: [ - FormatCSharpWorkspaceSnapshotPath(failedFilePath) + FormatCSharpWorkspaceSnapshotPath(projectRoot, failedFilePath) ]); } @@ -677,7 +657,7 @@ CSharpStaticInterfaceWorkspaceSymbols BuildStableCSharpWorkspace( { HasStaticInterfaceContracts = true, SourceContractEvidenceComplete = false, - IncompleteSourcePaths = [FormatCSharpWorkspaceSnapshotPath(incompletePath)], + IncompleteSourcePaths = [FormatCSharpWorkspaceSnapshotPath(projectRoot, incompletePath)], }; } @@ -800,7 +780,7 @@ CSharpStaticInterfaceWorkspaceSymbols BuildStableCSharpWorkspace( void DeferCSharpMutationsForLoadedSnapshotDrift(string path) { - path = FormatCSharpWorkspaceSnapshotPath(path); + path = FormatCSharpWorkspaceSnapshotPath(projectRoot, path); deferCSharpMutationsForIncompleteScan = true; preservePriorPositiveCSharpSourceNoOp = false; csharpSourceEvidenceForStamp = false; @@ -1256,115 +1236,6 @@ void AddDirtyFileBytes(int fileIndex) } } - int ReturnBeforeWriteSnapshotFailure(string changedPath) - { - var formattedPath = FormatCSharpWorkspaceSnapshotPath(changedPath); - var exception = new IOException( - "Directory entries or scan configuration changed after source discovery; rerun indexing from a stable workspace snapshot."); - errors++; - errorList.Add(new CliJsonMessage(formattedPath, FormatIndexFileException(exception))); - if (fileErrorList.Count < PartialIndexFileErrorLimit) - fileErrorList.Add(BuildIndexFileError(formattedPath, "csharp_workspace_validation", exception)); - - stopwatch.Stop(); - var (totalFiles, totalChunks, totalSymbols, totalReferences) = writer.GetCounts(); - var graphTableAvailable = (priorReadiness & DbContext.GraphReadyFlag) != 0; - var issuesTableAvailable = (priorReadiness & DbContext.IssuesReadyFlag) != 0; - var referenceExtractionCapHits = writer.GetReferenceExtractionCapHits(issuesTableAvailable); - // The connection is writable, but failure diagnostics must not trigger the - // DbReader constructor's interrupted-FTS recovery before the write barrier. - using var signalReader = new DbReader(writer.Connection, isReadOnly: true); - var discoveredCSharpFiles = languageCounts.ContainsKey("csharp"); - var discoveredSqlFiles = languageCounts.ContainsKey("sql"); - var persistedCSharpFiles = writer.HasAnyFilesWithLanguage("csharp"); - var persistedSqlFiles = writer.HasAnyFilesWithLanguage("sql"); - var hasCSharpFiles = discoveredCSharpFiles || persistedCSharpFiles; - var hasSqlFiles = discoveredSqlFiles || persistedSqlFiles; - var sqlGraphContractSignal = signalReader.GetSqlGraphContractSignal(lang: null); - if (!hasSqlFiles) - { - sqlGraphContractSignal = new SqlGraphContractSignal( - Ready: true, - Relevant: false, - DegradedReason: null); - } - else if (!sqlGraphContractSignal.Relevant) - { - // The write barrier can fail after discovery but before the first target is - // persisted. Keep positive language evidence in the immediate response. - // write 前 barrier failure でも発見済み language の degraded signal を保持する。 - sqlGraphContractSignal = new SqlGraphContractSignal( - Ready: false, - Relevant: true, - DegradedReason: DegradationReasonCodes.BuildSqlGraphContractDegradedReason()); - } - var hotspotFamilySignal = signalReader.GetHotspotFamilySignal(lang: null); - var csharpSymbolNameReady = !hasCSharpFiles - || (persistedCSharpFiles && csharpSymbolNameContractMatchesCurrent); - var csharpMetadataTargetReady = !hasCSharpFiles - || (persistedCSharpFiles && priorMetadataTargetCsharpMatchesCurrent); - var foldReady = (priorReadiness & DbContext.FoldReadyFlag) != 0; - var memoryTimeline = BuildMemoryTimeline(memorySamples); - - if (options.Json) - { - CommandOutputWriter.WriteLine(JsonSerializer.Serialize(new IndexFullScanJsonResult - { - Status = "partial", - Mode = options.Rebuild ? "rebuild" : "incremental", - Summary = new IndexFullScanSummaryJsonResult - { - FilesTotal = totalFiles, - ChunksTotal = totalChunks, - SymbolsTotal = totalSymbols, - ReferencesTotal = totalReferences, - FilesScanned = files.Count, - FilesSkipped = skipped, - FilesPurged = 0, - DanglingSymlinksSkipped = scanResult.DanglingSymlinks.Count, - Warnings = warnings, - Errors = errors, - SymbolsDroppedByKindFilter = symbolsDroppedByKindFilter, - }, - SymbolKindFilter = options.SymbolKindFilter.ToJsonResult(), - GraphTableAvailable = graphTableAvailable, - GraphDataCurrent = false, - IndexComplete = false, - ReferenceExtractionLimits = ReferenceExtractor.GetSafetyLimits(), - ReferenceGraphComplete = signalReader.IsReferenceGraphComplete( - referenceExtractionCapHits), - ReferenceExtractionCapHits = referenceExtractionCapHits, - ErrorCode = CommandErrorCodes.IndexPartial, - IssuesTableAvailable = issuesTableAvailable, - SqlGraphContractReady = sqlGraphContractSignal.Ready, - SqlGraphContractDegradedReason = sqlGraphContractSignal.DegradedReason, - HotspotFamilyReady = hotspotFamilySignal.Ready, - HotspotFamilyDegradedReason = hotspotFamilySignal.DegradedReason, - CSharpSymbolNameReady = csharpSymbolNameReady, - CSharpMetadataTargetReady = csharpMetadataTargetReady, - FoldReady = foldReady, - FoldReadyReason = foldReady ? null : GetFoldReadyReason( - backfillReady: false, - priorFoldVersion == NameFold.Version.ToString(System.Globalization.CultureInfo.InvariantCulture), - priorFoldFingerprint == NameFold.Fingerprint()), - Errors = errorList, - FileErrors = fileErrorList, - Warnings = warningList.Count > 0 ? warningList : null, - MemoryTimeline = memoryTimeline, - ElapsedMs = stopwatch.ElapsedMilliseconds, - }, jsonContext.IndexFullScanJsonResult)); - } - else if (!options.Quiet) - { - ConsoleUi.TryWriteErrorLine( - $"Indexing stopped before index-data mutation because the scan snapshot changed: {formattedPath}"); - } - - return options.AllowPartial - ? CommandExitCodes.Success - : CommandExitCodes.PartialResult; - } - if (discovery.InputSnapshot != null) { FullScanInputSnapshotBarrierForTesting?.Invoke("before_write"); @@ -1373,7 +1244,32 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) out var changedScanInputPath, cancellationToken)) { - return ReturnBeforeWriteSnapshotFailure(changedScanInputPath); + return WriteFullScanSnapshotFailure( + changedScanInputPath, + new FullScanSnapshotFailureContext + { + Writer = writer, + Options = options, + Stopwatch = stopwatch, + JsonContext = jsonContext, + ProjectRoot = projectRoot, + PriorReadiness = priorReadiness, + CSharpSymbolNameContractMatchesCurrent = csharpSymbolNameContractMatchesCurrent, + PriorMetadataTargetCsharpMatchesCurrent = priorMetadataTargetCsharpMatchesCurrent, + PriorFoldVersion = priorFoldVersion, + PriorFoldFingerprint = priorFoldFingerprint, + MemorySamples = memorySamples, + LanguageCounts = languageCounts, + FilesCount = files.Count, + Skipped = skipped, + DanglingSymlinkCount = scanResult.DanglingSymlinks.Count, + Warnings = warnings, + Errors = errors, + SymbolsDroppedByKindFilter = symbolsDroppedByKindFilter, + ErrorList = errorList, + FileErrorList = fileErrorList, + WarningList = warningList, + }); } } @@ -1388,7 +1284,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) cancellationToken); if (!stableFiles) { - var driftPath = FormatCSharpWorkspaceSnapshotPath(changedFilePath); + var driftPath = FormatCSharpWorkspaceSnapshotPath(projectRoot, changedFilePath); var incompleteWorkspace = new CSharpStaticInterfaceWorkspaceSymbols( [], HasStaticInterfaceContracts: true, @@ -1637,7 +1533,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) displayRelativePath, "csharp_workspace_validation", new CSharpWorkspaceSnapshotDriftException( - FormatCSharpWorkspaceSnapshotPath(changedPath))), + FormatCSharpWorkspaceSnapshotPath(projectRoot, changedPath))), extractionCancellationToken); continue; } @@ -1825,7 +1721,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) displayRelativePath, "csharp_workspace_validation", new CSharpWorkspaceSnapshotDriftException( - FormatCSharpWorkspaceSnapshotPath(changedPath))), + FormatCSharpWorkspaceSnapshotPath(projectRoot, changedPath))), extractionCancellationToken); continue; } @@ -1859,7 +1755,7 @@ int ReturnBeforeWriteSnapshotFailure(string changedPath) displayRelativePath, "csharp_workspace_validation", new CSharpWorkspaceSnapshotDriftException( - FormatCSharpWorkspaceSnapshotPath(changedPath))), + FormatCSharpWorkspaceSnapshotPath(projectRoot, changedPath))), extractionCancellationToken); continue; } diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.SnapshotFailure.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.SnapshotFailure.cs new file mode 100644 index 000000000..4a9588f09 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.SnapshotFailure.cs @@ -0,0 +1,121 @@ +using System.Diagnostics; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class UpdateSnapshotFailureContext + { + internal required DbWriter Writer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required Stopwatch Stopwatch { get; init; } + internal required CliJsonSerializerContext JsonContext { get; init; } + internal required string ProjectRoot { get; init; } + internal int PriorReadiness { get; init; } + internal bool CSharpSymbolNameContractMatchesCurrent { get; init; } + internal bool PriorMetadataTargetCsharpMatchesCurrent { get; init; } + internal string? PriorFoldVersion { get; init; } + internal string? PriorFoldFingerprint { get; init; } + internal required string CurrentFoldVersion { get; init; } + internal required string CurrentFoldFingerprint { get; init; } + internal required List MemorySamples { get; init; } + internal int Skipped { get; init; } + internal int Warnings { get; init; } + internal int SymbolsDroppedByKindFilter { get; init; } + internal required List ErrorList { get; init; } + internal required List FileErrorList { get; init; } + internal required List WarningList { get; init; } + internal required Action RecordCSharpWorkspaceDrift { get; init; } + internal required Func GetErrorCount { get; init; } + } + + private static int WriteUpdateSnapshotFailure( + string changedPath, + UpdateSnapshotFailureContext failure) + { + var formattedPath = FormatCSharpWorkspaceSnapshotPath(failure.ProjectRoot, changedPath); + failure.RecordCSharpWorkspaceDrift( + formattedPath, + "Directory entries or scan configuration changed after expanded C# discovery.", + "csharp_workspace_validation"); + var errors = failure.GetErrorCount(); + + failure.Stopwatch.Stop(); + var (totalFiles, totalChunks, totalSymbols, totalReferences) = failure.Writer.GetCounts(); + var graphTableAvailable = (failure.PriorReadiness & DbContext.GraphReadyFlag) != 0; + var issuesTableAvailable = (failure.PriorReadiness & DbContext.IssuesReadyFlag) != 0; + var referenceExtractionCapHits = failure.Writer.GetReferenceExtractionCapHits(issuesTableAvailable); + // Keep early failure rendering observational: a default DbReader would recover + // interrupted FTS state on this writable connection before the write barrier. + using var signalReader = new DbReader(failure.Writer.Connection, isReadOnly: true); + var sqlGraphContractSignal = signalReader.GetSqlGraphContractSignal(lang: null); + var hotspotFamilySignal = signalReader.GetHotspotFamilySignal(lang: null); + var hasCSharpFiles = failure.Writer.HasAnyFilesWithLanguage("csharp"); + var csharpSymbolNameReady = !hasCSharpFiles || failure.CSharpSymbolNameContractMatchesCurrent; + var csharpMetadataTargetReady = !hasCSharpFiles || failure.PriorMetadataTargetCsharpMatchesCurrent; + var foldReady = (failure.PriorReadiness & DbContext.FoldReadyFlag) != 0; + var memoryTimeline = BuildMemoryTimeline(failure.MemorySamples); + + if (failure.Options.Json) + { + CommandOutputWriter.WriteLine(JsonSerializer.Serialize(new IndexUpdateJsonResult + { + Status = "partial", + Mode = "update", + Summary = new IndexUpdateSummaryJsonResult + { + FilesTotal = totalFiles, + ChunksTotal = totalChunks, + SymbolsTotal = totalSymbols, + ReferencesTotal = totalReferences, + Updated = 0, + Removed = 0, + Skipped = failure.Skipped, + Warnings = failure.Warnings, + Errors = errors, + SymbolsDroppedByKindFilter = failure.SymbolsDroppedByKindFilter, + FtsOptimizeRan = false, + FtsMergeRan = false, + }, + SymbolKindFilter = failure.Options.SymbolKindFilter.ToJsonResult(), + GraphTableAvailable = graphTableAvailable, + GraphDataCurrent = false, + IndexComplete = false, + ReferenceExtractionLimits = ReferenceExtractor.GetSafetyLimits(), + ReferenceGraphComplete = signalReader.IsReferenceGraphComplete( + referenceExtractionCapHits), + ReferenceExtractionCapHits = referenceExtractionCapHits, + ErrorCode = CommandErrorCodes.IndexPartial, + IssuesTableAvailable = issuesTableAvailable, + SqlGraphContractReady = sqlGraphContractSignal.Ready, + SqlGraphContractDegradedReason = sqlGraphContractSignal.DegradedReason, + HotspotFamilyReady = hotspotFamilySignal.Ready, + HotspotFamilyDegradedReason = hotspotFamilySignal.DegradedReason, + CSharpSymbolNameReady = csharpSymbolNameReady, + CSharpMetadataTargetReady = csharpMetadataTargetReady, + FoldReady = foldReady, + FoldReadyReason = foldReady ? null : GetFoldReadyReason( + backfillReady: false, + failure.PriorFoldVersion == failure.CurrentFoldVersion, + failure.PriorFoldFingerprint == failure.CurrentFoldFingerprint), + Errors = failure.ErrorList, + FileErrors = failure.FileErrorList, + Warnings = failure.WarningList.Count > 0 ? failure.WarningList : null, + MemoryTimeline = memoryTimeline, + ElapsedMs = failure.Stopwatch.ElapsedMilliseconds, + }, failure.JsonContext.IndexUpdateJsonResult)); + } + else if (!failure.Options.Quiet) + { + ConsoleUi.TryWriteErrorLine( + $"Update stopped before index-data mutation because the scan snapshot changed: {formattedPath}"); + } + + return failure.Options.AllowPartial + ? CommandExitCodes.Success + : CommandExitCodes.PartialResult; + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index 6b5092948..9ac75d9b1 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -489,26 +489,6 @@ bool TryValidateCurrentCSharpTargetSet( cancellationToken); } - string FormatCSharpWorkspaceSnapshotPath(string? path) - { - if (string.IsNullOrWhiteSpace(path) || path == "") - return ""; - if (!Path.IsPathRooted(path)) - return FileIndexer.NormalizePathSeparators(path); - try - { - return FileIndexer.NormalizePathSeparators( - FileIndexer.GetRelativePathFromDirectory(projectRoot, path)); - } - catch (Exception ex) when (ex is IOException - or UnauthorizedAccessException - or NotSupportedException - or ArgumentException) - { - return ""; - } - } - var csharpWorkspaceDriftDetected = false; void RecordCSharpWorkspaceDrift( @@ -1163,95 +1143,36 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) } } - int ReturnBeforeWriteSnapshotFailure(string changedPath) + if (csharpWorkspaceInputSnapshot != null) { - var formattedPath = FormatCSharpWorkspaceSnapshotPath(changedPath); - RecordCSharpWorkspaceDrift( - formattedPath, - "Directory entries or scan configuration changed after expanded C# discovery.", - "csharp_workspace_validation"); - - stopwatch.Stop(); - var (totalFiles, totalChunks, totalSymbols, totalReferences) = writer.GetCounts(); - var graphTableAvailable = (priorReadiness & DbContext.GraphReadyFlag) != 0; - var issuesTableAvailable = (priorReadiness & DbContext.IssuesReadyFlag) != 0; - var referenceExtractionCapHits = writer.GetReferenceExtractionCapHits(issuesTableAvailable); - // Keep early failure rendering observational: a default DbReader would recover - // interrupted FTS state on this writable connection before the write barrier. - using var signalReader = new DbReader(writer.Connection, isReadOnly: true); - var sqlGraphContractSignal = signalReader.GetSqlGraphContractSignal(lang: null); - var hotspotFamilySignal = signalReader.GetHotspotFamilySignal(lang: null); - var hasCSharpFiles = writer.HasAnyFilesWithLanguage("csharp"); - var csharpSymbolNameReady = !hasCSharpFiles || csharpSymbolNameContractMatchesCurrent; - var csharpMetadataTargetReady = !hasCSharpFiles || priorMetadataTargetCsharpMatchesCurrent; - var foldReady = (priorReadiness & DbContext.FoldReadyFlag) != 0; - var memoryTimeline = BuildMemoryTimeline(memorySamples); - - if (options.Json) - { - CommandOutputWriter.WriteLine(JsonSerializer.Serialize(new IndexUpdateJsonResult - { - Status = "partial", - Mode = "update", - Summary = new IndexUpdateSummaryJsonResult + UpdateScanInputSnapshotBarrierForTesting?.Invoke("before_write"); + if (!TryValidateCSharpWorkspaceInputSnapshot(out var changedInputPath)) + return WriteUpdateSnapshotFailure( + changedInputPath ?? projectRoot, + new UpdateSnapshotFailureContext { - FilesTotal = totalFiles, - ChunksTotal = totalChunks, - SymbolsTotal = totalSymbols, - ReferencesTotal = totalReferences, - Updated = 0, - Removed = 0, + Writer = writer, + Options = options, + Stopwatch = stopwatch, + JsonContext = jsonContext, + ProjectRoot = projectRoot, + PriorReadiness = priorReadiness, + CSharpSymbolNameContractMatchesCurrent = csharpSymbolNameContractMatchesCurrent, + PriorMetadataTargetCsharpMatchesCurrent = priorMetadataTargetCsharpMatchesCurrent, + PriorFoldVersion = priorFoldVersion, + PriorFoldFingerprint = priorFoldFingerprint, + CurrentFoldVersion = currentFoldVersion, + CurrentFoldFingerprint = currentFoldFingerprint, + MemorySamples = memorySamples, Skipped = skipped, Warnings = warnings, - Errors = errors, SymbolsDroppedByKindFilter = symbolsDroppedByKindFilter, - FtsOptimizeRan = false, - FtsMergeRan = false, - }, - SymbolKindFilter = options.SymbolKindFilter.ToJsonResult(), - GraphTableAvailable = graphTableAvailable, - GraphDataCurrent = false, - IndexComplete = false, - ReferenceExtractionLimits = ReferenceExtractor.GetSafetyLimits(), - ReferenceGraphComplete = signalReader.IsReferenceGraphComplete( - referenceExtractionCapHits), - ReferenceExtractionCapHits = referenceExtractionCapHits, - ErrorCode = CommandErrorCodes.IndexPartial, - IssuesTableAvailable = issuesTableAvailable, - SqlGraphContractReady = sqlGraphContractSignal.Ready, - SqlGraphContractDegradedReason = sqlGraphContractSignal.DegradedReason, - HotspotFamilyReady = hotspotFamilySignal.Ready, - HotspotFamilyDegradedReason = hotspotFamilySignal.DegradedReason, - CSharpSymbolNameReady = csharpSymbolNameReady, - CSharpMetadataTargetReady = csharpMetadataTargetReady, - FoldReady = foldReady, - FoldReadyReason = foldReady ? null : GetFoldReadyReason( - backfillReady: false, - priorFoldVersion == currentFoldVersion, - priorFoldFingerprint == currentFoldFingerprint), - Errors = errorList, - FileErrors = fileErrorList, - Warnings = warningList.Count > 0 ? warningList : null, - MemoryTimeline = memoryTimeline, - ElapsedMs = stopwatch.ElapsedMilliseconds, - }, jsonContext.IndexUpdateJsonResult)); - } - else if (!options.Quiet) - { - ConsoleUi.TryWriteErrorLine( - $"Update stopped before index-data mutation because the scan snapshot changed: {formattedPath}"); - } - - return options.AllowPartial - ? CommandExitCodes.Success - : CommandExitCodes.PartialResult; - } - - if (csharpWorkspaceInputSnapshot != null) - { - UpdateScanInputSnapshotBarrierForTesting?.Invoke("before_write"); - if (!TryValidateCSharpWorkspaceInputSnapshot(out var changedInputPath)) - return ReturnBeforeWriteSnapshotFailure(changedInputPath ?? projectRoot); + ErrorList = errorList, + FileErrorList = fileErrorList, + WarningList = warningList, + RecordCSharpWorkspaceDrift = RecordCSharpWorkspaceDrift, + GetErrorCount = () => errors, + }); } string? changedCSharpTargetPath = null; @@ -2406,7 +2327,7 @@ or IndexInterruptedException RecordCSharpWorkspaceDrift( !string.IsNullOrEmpty(finalChangedCSharpPath) ? finalChangedCSharpPath - : FormatCSharpWorkspaceSnapshotPath(finalChangedCSharpDirectoryPath), + : FormatCSharpWorkspaceSnapshotPath(projectRoot, finalChangedCSharpDirectoryPath), "The C# workspace changed before final source-evidence validation."); csharpSourceEvidenceForStamp = null; csharpSourceEvidenceCompleteForStamp = false; From 49caa094f3a1729742dea2ba44e4b8f34aec8006 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 22:00:03 +0900 Subject: [PATCH 076/101] Separate full scan FTS bulk load planning --- .../IndexCommandRunner.FileByteTracking.cs | 91 +++++++++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 77 +++------------- 2 files changed, 102 insertions(+), 66 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FileByteTracking.cs b/src/CodeIndex/Cli/IndexCommandRunner.FileByteTracking.cs index 89e4298b5..6f3288834 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FileByteTracking.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FileByteTracking.cs @@ -92,4 +92,95 @@ or NotSupportedException return new FileByteReadSummary(total, skipped); } } + + private static bool ShouldUseFullScanFtsBulkLoad( + bool rebuild, + bool startedWithNoIndexedFiles, + int extractionWorkItemCount, + FilePurgePlan staleFilePurgePlan, + bool scanHadErrors, + ReadableFileByteTracker readableFileBytes, + ReusableIndexedFileStatsSnapshot reusableIndexedFileStats, + IReadOnlyList fileTargets, + IReadOnlyList? extractionFileIndexes, + Action throwIfCancelled) + { + if (rebuild || startedWithNoIndexedFiles) + return true; + if (extractionWorkItemCount == 0 && staleFilePurgePlan.Count == 0) + return false; + + var dirtyBytes = staleFilePurgePlan.DeletedBytes; + var persistedSizeExcessBytes = 0L; + var byteEstimateComplete = !scanHadErrors + && staleFilePurgePlan.ByteEstimateComplete + && readableFileBytes.EstimateComplete; + + void AddDirtyFileBytes(int fileIndex) + { + throwIfCancelled(); + try + { + var target = fileTargets[fileIndex]; + var info = new FileInfo(target.FilePath); + if (!info.Exists || info.Length < 0) + { + byteEstimateComplete = false; + return; + } + + readableFileBytes.Remember(fileIndex, info.Length); + var persistedSize = reusableIndexedFileStats.GetPersistedSize(target.IndexPath); + if (!FtsBulkLoadTriggerGuard.TryAccumulateDirtyFileBytes( + dirtyBytes, + persistedSizeExcessBytes, + info.Length, + persistedSize, + out dirtyBytes, + out persistedSizeExcessBytes)) + { + byteEstimateComplete = false; + } + } + catch (Exception ex) when ( + ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + byteEstimateComplete = false; + } + } + + if (extractionFileIndexes != null) + { + foreach (var fileIndex in extractionFileIndexes) + AddDirtyFileBytes(fileIndex); + } + else + { + for (var fileIndex = 0; fileIndex < fileTargets.Count; fileIndex++) + AddDirtyFileBytes(fileIndex); + } + + byteEstimateComplete &= readableFileBytes.EstimateComplete; + var totalBytes = readableFileBytes.KnownBytes; + if (!readableFileBytes.EstimateComplete + || totalBytes > long.MaxValue - staleFilePurgePlan.DeletedBytes) + { + byteEstimateComplete = false; + } + else + { + totalBytes += staleFilePurgePlan.DeletedBytes; + } + + if (totalBytes > long.MaxValue - persistedSizeExcessBytes) + byteEstimateComplete = false; + else + totalBytes += persistedSizeExcessBytes; + + return byteEstimateComplete + && FtsBulkLoadTriggerGuard.ShouldUseForDirtyBytes(dirtyBytes, totalBytes); + } } diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 865a16daf..553c394ee 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -1071,72 +1071,17 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis } } - var useFtsBulkLoad = options.Rebuild || startedWithNoIndexedFiles; - if (!useFtsBulkLoad && (extractionWorkItemCount > 0 || staleFilePurgePlan.Count > 0)) - { - var dirtyBytes = staleFilePurgePlan.DeletedBytes; - var persistedSizeExcessBytes = 0L; - var byteEstimateComplete = !scanHadErrors - && staleFilePurgePlan.ByteEstimateComplete - && readableFileBytes.EstimateComplete; - - void AddDirtyFileBytes(int fileIndex) - { - ThrowIfFullScanCancelled(processed, files.Count); - try - { - var info = new FileInfo(fileTargets[fileIndex].FilePath); - if (!info.Exists || info.Length < 0) - { - byteEstimateComplete = false; - return; - } - - readableFileBytes.Remember(fileIndex, info.Length); - var persistedSize = reusableIndexedFileStats!.GetPersistedSize(fileTargets[fileIndex].IndexPath); - if (!FtsBulkLoadTriggerGuard.TryAccumulateDirtyFileBytes( - dirtyBytes, - persistedSizeExcessBytes, - info.Length, - persistedSize, - out dirtyBytes, - out persistedSizeExcessBytes)) - { - byteEstimateComplete = false; - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) - { - byteEstimateComplete = false; - } - } - - if (extractionFileIndexes != null) - { - foreach (var fileIndex in extractionFileIndexes) - AddDirtyFileBytes(fileIndex); - } - else - { - for (var fileIndex = 0; fileIndex < fileTargets.Length; fileIndex++) - AddDirtyFileBytes(fileIndex); - } - - byteEstimateComplete &= readableFileBytes.EstimateComplete; - var totalBytes = readableFileBytes.KnownBytes; - if (!readableFileBytes.EstimateComplete - || totalBytes > long.MaxValue - staleFilePurgePlan.DeletedBytes) - byteEstimateComplete = false; - else - totalBytes += staleFilePurgePlan.DeletedBytes; - if (totalBytes > long.MaxValue - persistedSizeExcessBytes) - byteEstimateComplete = false; - else - totalBytes += persistedSizeExcessBytes; - - useFtsBulkLoad = byteEstimateComplete - && FtsBulkLoadTriggerGuard.ShouldUseForDirtyBytes(dirtyBytes, totalBytes); - } + var useFtsBulkLoad = ShouldUseFullScanFtsBulkLoad( + options.Rebuild, + startedWithNoIndexedFiles, + extractionWorkItemCount, + staleFilePurgePlan, + scanHadErrors, + readableFileBytes, + reusableIndexedFileStats!, + fileTargets, + extractionFileIndexes, + () => ThrowIfFullScanCancelled(processed, files.Count)); if (preservePriorPositiveCSharpSourceNoOp && (extractionWorkItemCount > 0 || staleFilePurgePlan.Count > 0)) From 58d81ad72a0a415bc49fd4a4e05e1c77914ed1b0 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 22:08:58 +0900 Subject: [PATCH 077/101] Separate update C# target coordination --- ...IndexCommandRunner.Update.CSharpTargets.cs | 190 ++++++++++++++++ .../Cli/IndexCommandRunner.Update.cs | 209 +++--------------- 2 files changed, 221 insertions(+), 178 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpTargets.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpTargets.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpTargets.cs new file mode 100644 index 000000000..6054bead0 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpTargets.cs @@ -0,0 +1,190 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private static List BuildUpdateCSharpPrepassTargets( + FileIndexer indexer, + string projectRoot, + IReadOnlyCollection targetPaths, + IReadOnlyDictionary? scannedLanguages, + out HashSet? existingCSharpPathsNowUnsupportedOrNonCSharp) + { + var targets = new List(targetPaths.Count); + HashSet? transitionedPaths = null; + + void RememberExistingCSharpTransition(string indexPath) + => (transitionedPaths ??= new HashSet(StringComparer.Ordinal)).Add(indexPath); + + foreach (var targetPath in targetPaths) + { + var updateTarget = UpdateFileTarget.Create(projectRoot, targetPath); + var absPath = updateTarget.FilePath; + if (!File.Exists(LongPath.EnsureWindowsPrefix(absPath))) + { + RememberExistingCSharpTransition(updateTarget.IndexPath); + continue; + } + + string? language; + if (scannedLanguages != null) + { + // A clean expanded scan is the authoritative membership snapshot. A + // caller-selected path that is absent from it was filtered, ignored, or + // otherwise non-indexable and must not be reintroduced by extension-only + // detection. The normal update loop still retains the target so it can + // remove any persisted row. + // clean expanded scan に存在しない caller target は filtered / ignored / + // non-indexable であり、拡張子判定だけで workspace に戻してはならない。 + // 実 update target には残し、既存 row の削除処理を行う。 + if (!scannedLanguages.TryGetValue(absPath, out var scannedLanguage) + || scannedLanguage != "csharp") + { + RememberExistingCSharpTransition(updateTarget.IndexPath); + continue; + } + + language = scannedLanguage; + } + else + { + var detection = FileIndexer.TryDetectLanguage(absPath); + if (detection.Status != FileIndexer.FileProbeStatus.Supported + || detection.Language != "csharp") + { + RememberExistingCSharpTransition(updateTarget.IndexPath); + continue; + } + + language = detection.Language; + } + + var target = new CSharpStaticInterfacePrepass.FileTarget( + updateTarget.FilePath, + updateTarget.RelativePath, + updateTarget.DisplayRelativePath, + updateTarget.IndexPath, + language); + targets.Add(target with + { + GeneratedExtractionSuppressed = + indexer.HasGeneratedCodeExtractionSuppressionPatterns + && indexer.IsGeneratedCodeExtractionSuppressed(target.IndexPath) + }); + } + + existingCSharpPathsNowUnsupportedOrNonCSharp = transitionedPaths; + return targets; + } + + private static bool TryValidateCurrentCSharpTargetSet( + string projectRoot, + IEnumerable currentTargetPaths, + IReadOnlyDictionary? scannedLanguages, + IReadOnlyDictionary snapshots, + out string? failedPath, + CancellationToken cancellationToken) + { + var currentCSharpTargets = new List( + snapshots.Count); + foreach (var targetPath in currentTargetPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + var target = UpdateFileTarget.Create(projectRoot, targetPath); + var ioPath = LongPath.EnsureWindowsPrefix(target.FilePath); + var expectedCSharp = snapshots.ContainsKey(target.IndexPath); + if (!File.Exists(ioPath)) + { + if (expectedCSharp) + { + failedPath = target.RelativePath; + return false; + } + + continue; + } + + var isCSharp = scannedLanguages != null + ? scannedLanguages.TryGetValue(target.FilePath, out var scannedLanguage) + && scannedLanguage == "csharp" + : FileIndexer.TryDetectLanguage(target.FilePath) is + { Status: FileIndexer.FileProbeStatus.Supported, Language: "csharp" }; + if (!isCSharp) + { + if (expectedCSharp) + { + failedPath = target.RelativePath; + return false; + } + + continue; + } + + if (!expectedCSharp) + { + failedPath = target.RelativePath; + return false; + } + + currentCSharpTargets.Add(new CSharpStaticInterfacePrepass.FileTarget( + target.FilePath, + target.RelativePath, + target.RelativePath, + target.IndexPath, + "csharp")); + } + + return CSharpStaticInterfacePrepass.TryValidateFileStatSnapshots( + currentCSharpTargets, + snapshots, + out failedPath, + cancellationToken); + } + + private static void DeferCSharpTargetsAfterIncompleteWorkspace( + DbWriter writer, + string projectRoot, + HashSet targetPaths, + CancellationToken cancellationToken) + { + var deferredTargetPaths = new List(); + var persistedLanguageCandidates = new List<(string TargetPath, string IndexPath)>(); + var persistedLanguageCandidatePaths = new HashSet(StringComparer.Ordinal); + foreach (var targetPath in targetPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + var target = UpdateFileTarget.Create(projectRoot, targetPath); + var detection = FileIndexer.TryDetectLanguage(target.FilePath); + if (detection.Status == FileIndexer.FileProbeStatus.Supported + && detection.Language == "csharp") + { + deferredTargetPaths.Add(targetPath); + continue; + } + + persistedLanguageCandidates.Add((targetPath, target.IndexPath)); + persistedLanguageCandidatePaths.Add(target.IndexPath); + } + + var persistedCSharpPaths = writer.ResolveCSharpFilePaths( + persistedLanguageCandidatePaths, + cancellationToken); + foreach (var candidate in persistedLanguageCandidates) + { + cancellationToken.ThrowIfCancellationRequested(); + if (persistedCSharpPaths.Contains(candidate.IndexPath)) + deferredTargetPaths.Add(candidate.TargetPath); + } + + // targetPaths itself is enumerated only once above. Remove by the bounded result + // list so a large incomplete workspace does not rescan it after the batched DB read. + // targetPaths 自体は上で一度だけ走査し、batch 結果の path だけを直接除外する。 + foreach (var deferredTargetPath in deferredTargetPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + targetPaths.Remove(deferredTargetPath); + } + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index 9ac75d9b1..5d9b3ebab 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -359,136 +359,6 @@ void ThrowIfUpdateCancelled() throw new IndexInterruptedException(updated + removed, targetPaths.Count); } - List BuildCSharpPrepassTargets( - IReadOnlyDictionary? scannedLanguages, - out HashSet? existingCSharpPathsNowUnsupportedOrNonCSharp) - { - var targets = new List(targetPaths.Count); - HashSet? transitionedPaths = null; - void RememberExistingCSharpTransition(string indexPath) - => (transitionedPaths ??= new HashSet(StringComparer.Ordinal)).Add(indexPath); - - foreach (var targetPath in targetPaths) - { - var updateTarget = UpdateFileTarget.Create(projectRoot, targetPath); - var absPath = updateTarget.FilePath; - if (!File.Exists(LongPath.EnsureWindowsPrefix(absPath))) - { - RememberExistingCSharpTransition(updateTarget.IndexPath); - continue; - } - - string? language = null; - if (scannedLanguages != null) - { - // A clean expanded scan is the authoritative membership snapshot. A - // caller-selected path that is absent from it was filtered, ignored, or - // otherwise non-indexable and must not be reintroduced by extension-only - // detection. The normal update loop still retains the target so it can - // remove any persisted row. - // clean expanded scan に存在しない caller target は filtered / ignored / - // non-indexable であり、拡張子判定だけで workspace に戻してはならない。 - // 実 update target には残し、既存 row の削除処理を行う。 - if (!scannedLanguages.TryGetValue(absPath, out var scannedLanguage) - || scannedLanguage != "csharp") - { - RememberExistingCSharpTransition(updateTarget.IndexPath); - continue; - } - - language = scannedLanguage; - } - else - { - var detection = FileIndexer.TryDetectLanguage(absPath); - if (detection.Status != FileIndexer.FileProbeStatus.Supported || detection.Language != "csharp") - { - RememberExistingCSharpTransition(updateTarget.IndexPath); - continue; - } - - language = detection.Language; - } - - var target = new CSharpStaticInterfacePrepass.FileTarget( - updateTarget.FilePath, - updateTarget.RelativePath, - updateTarget.DisplayRelativePath, - updateTarget.IndexPath, - language); - targets.Add(target with - { - GeneratedExtractionSuppressed = indexer.HasGeneratedCodeExtractionSuppressionPatterns - && indexer.IsGeneratedCodeExtractionSuppressed(target.IndexPath) - }); - } - - existingCSharpPathsNowUnsupportedOrNonCSharp = transitionedPaths; - return targets; - } - - bool TryValidateCurrentCSharpTargetSet( - IEnumerable currentTargetPaths, - IReadOnlyDictionary? scannedLanguages, - IReadOnlyDictionary snapshots, - out string? failedPath) - { - var currentCSharpTargets = new List( - snapshots.Count); - foreach (var targetPath in currentTargetPaths) - { - cancellationToken.ThrowIfCancellationRequested(); - var target = UpdateFileTarget.Create(projectRoot, targetPath); - var ioPath = LongPath.EnsureWindowsPrefix(target.FilePath); - var expectedCSharp = snapshots.ContainsKey(target.IndexPath); - if (!File.Exists(ioPath)) - { - if (expectedCSharp) - { - failedPath = target.RelativePath; - return false; - } - - continue; - } - - var isCSharp = scannedLanguages != null - ? scannedLanguages.TryGetValue(target.FilePath, out var scannedLanguage) - && scannedLanguage == "csharp" - : FileIndexer.TryDetectLanguage(target.FilePath) is - { Status: FileIndexer.FileProbeStatus.Supported, Language: "csharp" }; - if (!isCSharp) - { - if (expectedCSharp) - { - failedPath = target.RelativePath; - return false; - } - - continue; - } - - if (!expectedCSharp) - { - failedPath = target.RelativePath; - return false; - } - - currentCSharpTargets.Add(new CSharpStaticInterfacePrepass.FileTarget( - target.FilePath, - target.RelativePath, - target.RelativePath, - target.IndexPath, - "csharp")); - } - - return CSharpStaticInterfacePrepass.TryValidateFileStatSnapshots( - currentCSharpTargets, - snapshots, - out failedPath, - cancellationToken); - } - var csharpWorkspaceDriftDetected = false; void RecordCSharpWorkspaceDrift( @@ -507,47 +377,6 @@ void RecordCSharpWorkspaceDrift( ], fatalPhase); } - void DeferCSharpTargetsAfterIncompleteWorkspace() - { - var deferredTargetPaths = new List(); - var persistedLanguageCandidates = new List<(string TargetPath, string IndexPath)>(); - var persistedLanguageCandidatePaths = new HashSet(StringComparer.Ordinal); - foreach (var targetPath in targetPaths) - { - cancellationToken.ThrowIfCancellationRequested(); - var target = UpdateFileTarget.Create(projectRoot, targetPath); - var detection = FileIndexer.TryDetectLanguage(target.FilePath); - if (detection.Status == FileIndexer.FileProbeStatus.Supported - && detection.Language == "csharp") - { - deferredTargetPaths.Add(targetPath); - continue; - } - - persistedLanguageCandidates.Add((targetPath, target.IndexPath)); - persistedLanguageCandidatePaths.Add(target.IndexPath); - } - - var persistedCSharpPaths = writer.ResolveCSharpFilePaths( - persistedLanguageCandidatePaths, - cancellationToken); - foreach (var candidate in persistedLanguageCandidates) - { - cancellationToken.ThrowIfCancellationRequested(); - if (persistedCSharpPaths.Contains(candidate.IndexPath)) - deferredTargetPaths.Add(candidate.TargetPath); - } - - // targetPaths itself is enumerated only once above. Remove by the bounded result - // list so a large incomplete workspace does not rescan it after the batched DB read. - // targetPaths 自体は上で一度だけ走査し、batch 結果の path だけを直接除外する。 - foreach (var deferredTargetPath in deferredTargetPaths) - { - cancellationToken.ThrowIfCancellationRequested(); - targetPaths.Remove(deferredTargetPath); - } - } - IReadOnlyDictionary? scannedUpdateLanguages = null; ThrowIfUpdateCancelled(); WriteIndexJsonLiveness(options, "checking C# workspace contracts..."); @@ -882,7 +711,10 @@ int PurgeStaleUpdateCleanupPaths( return writer.ApplyScopedFileCleanupPlan(livePlan, cancellationToken); } - var csharpPrepassTargets = BuildCSharpPrepassTargets( + var csharpPrepassTargets = BuildUpdateCSharpPrepassTargets( + indexer, + projectRoot, + targetPaths, scannedUpdateLanguages, out var existingCSharpPathsNowUnsupportedOrNonCSharp); CSharpStaticInterfaceWorkspaceSymbols csharpWorkspace; @@ -1035,7 +867,11 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) [], HasStaticInterfaceContracts: true, SourceContractEvidenceComplete: false); - DeferCSharpTargetsAfterIncompleteWorkspace(); + DeferCSharpTargetsAfterIncompleteWorkspace( + writer, + projectRoot, + targetPaths, + cancellationToken); } else { @@ -1056,7 +892,10 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) } } - csharpPrepassTargets = BuildCSharpPrepassTargets( + csharpPrepassTargets = BuildUpdateCSharpPrepassTargets( + indexer, + projectRoot, + targetPaths, scannedUpdateLanguages, out existingCSharpPathsNowUnsupportedOrNonCSharp); var capturedBefore = CSharpStaticInterfacePrepass.TryCaptureFileStatSnapshots( @@ -1115,7 +954,11 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) HasStaticInterfaceContracts = true, SourceContractEvidenceComplete = false, }; - DeferCSharpTargetsAfterIncompleteWorkspace(); + DeferCSharpTargetsAfterIncompleteWorkspace( + writer, + projectRoot, + targetPaths, + cancellationToken); } else { @@ -1178,10 +1021,12 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) string? changedCSharpTargetPath = null; var stableCSharpWorkspaceBeforeMutation = csharpWorkspaceSnapshots == null || TryValidateCurrentCSharpTargetSet( + projectRoot, targetPaths, scannedUpdateLanguages, csharpWorkspaceSnapshots, - out changedCSharpTargetPath); + out changedCSharpTargetPath, + cancellationToken); if (!deferCSharpMutationsForIncompleteWorkspace && !stableCSharpWorkspaceBeforeMutation) { @@ -1197,7 +1042,11 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) HasStaticInterfaceContracts = true, SourceContractEvidenceComplete = false, }; - DeferCSharpTargetsAfterIncompleteWorkspace(); + DeferCSharpTargetsAfterIncompleteWorkspace( + writer, + projectRoot, + targetPaths, + cancellationToken); } // The workspace lookup was built with these immutable IDs excluded. Apply exactly @@ -1259,7 +1108,11 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) HasStaticInterfaceContracts = true, SourceContractEvidenceComplete = false, }; - DeferCSharpTargetsAfterIncompleteWorkspace(); + DeferCSharpTargetsAfterIncompleteWorkspace( + writer, + projectRoot, + targetPaths, + cancellationToken); } } From 765785044c6be46229604ccf779a6a0b4afc1cc2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 22:17:49 +0900 Subject: [PATCH 078/101] Separate full scan target preparation --- ...xCommandRunner.FullScan.CSharpWorkspace.cs | 60 ++++++++++ .../IndexCommandRunner.FullScan.Targets.cs | 52 +++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 107 +++++------------- 3 files changed, 142 insertions(+), 77 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.CSharpWorkspace.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.Targets.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.CSharpWorkspace.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.CSharpWorkspace.cs new file mode 100644 index 000000000..41f4e8958 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.CSharpWorkspace.cs @@ -0,0 +1,60 @@ +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private static CSharpStaticInterfaceWorkspaceSymbols BuildStableFullScanCSharpWorkspace( + string projectRoot, + IReadOnlyList csharpPrepassTargets, + out Dictionary? + csharpWorkspaceFileSnapshots, + Func buildWorkspace, + CancellationToken cancellationToken) + { + csharpWorkspaceFileSnapshots = null; + Dictionary fileSnapshots = []; + var capturedFiles = CSharpStaticInterfacePrepass.TryCaptureFileStatSnapshots( + csharpPrepassTargets, + out fileSnapshots, + out var failedFilePath, + cancellationToken); + if (!capturedFiles) + { + return new CSharpStaticInterfaceWorkspaceSymbols( + [], + HasStaticInterfaceContracts: true, + SourceContractEvidenceComplete: false, + IncompleteSourcePaths: + [ + FormatCSharpWorkspaceSnapshotPath(projectRoot, failedFilePath) + ]); + } + + FullScanCSharpPrepassForTesting?.Invoke(); + var workspace = buildWorkspace(); + var stableFiles = CSharpStaticInterfacePrepass.TryValidateFileStatSnapshots( + csharpPrepassTargets, + fileSnapshots, + out var changedFilePath, + cancellationToken); + if (!stableFiles || !workspace.SourceContractEvidenceComplete) + { + var incompletePath = workspace.IncompleteSourcePaths?.FirstOrDefault() + ?? changedFilePath + ?? ""; + return workspace with + { + HasStaticInterfaceContracts = true, + SourceContractEvidenceComplete = false, + IncompleteSourcePaths = + [ + FormatCSharpWorkspaceSnapshotPath(projectRoot, incompletePath) + ], + }; + } + + csharpWorkspaceFileSnapshots = fileSnapshots; + return workspace; + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Targets.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Targets.cs new file mode 100644 index 000000000..206dc36eb --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Targets.cs @@ -0,0 +1,52 @@ +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed record FullScanTargetPreparation( + FullScanFileTarget[] FileTargets, + List CSharpPrepassTargets); + + private static FullScanTargetPreparation PrepareFullScanTargets( + FileIndexer indexer, + string projectRoot, + IReadOnlyList files, + IReadOnlyDictionary fileLanguages, + bool symbolsOnly, + int csharpPrepassCapacity) + { + var fileTargets = new FullScanFileTarget[files.Count]; + var csharpPrepassTargets = new List( + symbolsOnly ? 0 : csharpPrepassCapacity); + var hasGeneratedCodeExtractionSuppressionPatterns = + indexer.HasGeneratedCodeExtractionSuppressionPatterns; + + for (var fileIndex = 0; fileIndex < files.Count; fileIndex++) + { + var filePath = files[fileIndex]; + var language = FileIndexer.GetReusableDetectedLanguage(filePath, fileLanguages); + var target = FullScanFileTarget.Create(projectRoot, filePath, language); + fileTargets[fileIndex] = hasGeneratedCodeExtractionSuppressionPatterns + ? target with + { + GeneratedExtractionSuppressed = + indexer.IsGeneratedCodeExtractionSuppressed(target.IndexPath) + } + : target; + if (symbolsOnly || language != "csharp") + continue; + + var indexedTarget = fileTargets[fileIndex]; + csharpPrepassTargets.Add(new CSharpStaticInterfacePrepass.FileTarget( + indexedTarget.FilePath, + indexedTarget.RelativePath, + indexedTarget.DisplayRelativePath, + indexedTarget.IndexPath, + indexedTarget.Language, + indexedTarget.GeneratedExtractionSuppressed)); + } + + return new FullScanTargetPreparation(fileTargets, csharpPrepassTargets); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 553c394ee..efa11e714 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -143,32 +143,17 @@ void ThrowIfFullScanCancelled(int filesProcessed, int? filesTotal) var scanResult = discovery.ScanResult; var scanHadErrors = scanResult.HadErrors; var files = discovery.Files; - var fileTargets = new FullScanFileTarget[files.Count]; var languageCounts = scanResult.LanguageCounts; var csharpPrepassCapacity = languageCounts.TryGetValue("csharp", out var csharpFileCount) ? csharpFileCount : 0; - var csharpPrepassTargets = new List( - options.SymbolsOnly ? 0 : csharpPrepassCapacity); - var hasGeneratedCodeExtractionSuppressionPatterns = indexer.HasGeneratedCodeExtractionSuppressionPatterns; - for (var i = 0; i < files.Count; i++) - { - var filePath = files[i]; - var language = FileIndexer.GetReusableDetectedLanguage(filePath, scanResult.FileLanguages); - var target = FullScanFileTarget.Create(projectRoot, filePath, language); - fileTargets[i] = hasGeneratedCodeExtractionSuppressionPatterns - ? target with { GeneratedExtractionSuppressed = indexer.IsGeneratedCodeExtractionSuppressed(target.IndexPath) } - : target; - if (!options.SymbolsOnly && language == "csharp") - { - var indexedTarget = fileTargets[i]; - csharpPrepassTargets.Add(new CSharpStaticInterfacePrepass.FileTarget( - indexedTarget.FilePath, - indexedTarget.RelativePath, - indexedTarget.DisplayRelativePath, - indexedTarget.IndexPath, - indexedTarget.Language, - indexedTarget.GeneratedExtractionSuppressed)); - } - } + var targetPreparation = PrepareFullScanTargets( + indexer, + projectRoot, + files, + scanResult.FileLanguages, + options.SymbolsOnly, + csharpPrepassCapacity); + var fileTargets = targetPreparation.FileTargets; + var csharpPrepassTargets = targetPreparation.CSharpPrepassTargets; var readableFileBytes = new ReadableFileByteTracker( files.Count, fileIndex => files[fileIndex], @@ -618,53 +603,6 @@ bool IsExistingCSharpSymbolPathNowNonCSharp(string indexPath) Dictionary? csharpWorkspaceFileSnapshots = null; - CSharpStaticInterfaceWorkspaceSymbols BuildStableCSharpWorkspace( - Func buildWorkspace) - { - csharpWorkspaceFileSnapshots = null; - Dictionary fileSnapshots = []; - string? failedFilePath = null; - var capturedFiles = CSharpStaticInterfacePrepass.TryCaptureFileStatSnapshots( - csharpPrepassTargets, - out fileSnapshots, - out failedFilePath, - cancellationToken); - if (!capturedFiles) - { - return new CSharpStaticInterfaceWorkspaceSymbols( - [], - HasStaticInterfaceContracts: true, - SourceContractEvidenceComplete: false, - IncompleteSourcePaths: - [ - FormatCSharpWorkspaceSnapshotPath(projectRoot, failedFilePath) - ]); - } - - FullScanCSharpPrepassForTesting?.Invoke(); - var workspace = buildWorkspace(); - var stableFiles = CSharpStaticInterfacePrepass.TryValidateFileStatSnapshots( - csharpPrepassTargets, - fileSnapshots, - out var changedFilePath, - cancellationToken); - if (!stableFiles || !workspace.SourceContractEvidenceComplete) - { - var incompletePath = workspace.IncompleteSourcePaths?.FirstOrDefault() - ?? changedFilePath - ?? ""; - return workspace with - { - HasStaticInterfaceContracts = true, - SourceContractEvidenceComplete = false, - IncompleteSourcePaths = [FormatCSharpWorkspaceSnapshotPath(projectRoot, incompletePath)], - }; - } - - csharpWorkspaceFileSnapshots = fileSnapshots; - return workspace; - } - priorPositiveCSharpSourceNoOpCandidate = csharpPositiveNoOpPolicyCandidate && !hasCSharpLanguageTransitions; if (priorPositiveCSharpSourceNoOpCandidate) @@ -717,7 +655,11 @@ CSharpStaticInterfaceWorkspaceSymbols BuildStableCSharpWorkspace( } else { - csharpWorkspace = BuildStableCSharpWorkspace(() => + csharpWorkspace = BuildStableFullScanCSharpWorkspace( + projectRoot, + csharpPrepassTargets, + out csharpWorkspaceFileSnapshots, + () => CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( writer, indexer, @@ -731,7 +673,8 @@ CSharpStaticInterfaceWorkspaceSymbols BuildStableCSharpWorkspace( parallelism: extractionParallelism, excludedExistingFileIds: staleFilePurgePlan.FileIds, isExistingSymbolPathExcluded: IsExistingCSharpSymbolPathNowNonCSharp, - cancellationToken: cancellationToken)); + cancellationToken: cancellationToken), + cancellationToken); forceFullCSharpRefreshFromInvalidatedNoOp = priorCSharpStaticInterfaceSourceEvidence == true || csharpWorkspace.HasStaticInterfaceContracts; @@ -961,7 +904,11 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis // If it invalidates the empty-workspace shortcut, rebuild raw C# evidence and // make every C# target dirty before any stale row can be retained or rewritten. // 最終target statでno-opが崩れた場合、write前に全C# raw prepassへ戻す。 - csharpWorkspace = BuildStableCSharpWorkspace(() => + csharpWorkspace = BuildStableFullScanCSharpWorkspace( + projectRoot, + csharpPrepassTargets, + out csharpWorkspaceFileSnapshots, + () => CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( writer, indexer, @@ -971,7 +918,8 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis parallelism: extractionParallelism, excludedExistingFileIds: staleFilePurgePlan.FileIds, isExistingSymbolPathExcluded: IsExistingCSharpSymbolPathNowNonCSharp, - cancellationToken: cancellationToken)); + cancellationToken: cancellationToken), + cancellationToken); preservePriorPositiveCSharpSourceNoOp = false; if (!csharpWorkspace.SourceContractEvidenceComplete) { @@ -1112,7 +1060,11 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis if (invalidatedCSharpFileIndexes.Count > 0) { - csharpWorkspace = BuildStableCSharpWorkspace(() => + csharpWorkspace = BuildStableFullScanCSharpWorkspace( + projectRoot, + csharpPrepassTargets, + out csharpWorkspaceFileSnapshots, + () => CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( writer, indexer, @@ -1122,7 +1074,8 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis parallelism: extractionParallelism, excludedExistingFileIds: staleFilePurgePlan.FileIds, isExistingSymbolPathExcluded: IsExistingCSharpSymbolPathNowNonCSharp, - cancellationToken: cancellationToken)); + cancellationToken: cancellationToken), + cancellationToken); preservePriorPositiveCSharpSourceNoOp = false; if (!csharpWorkspace.SourceContractEvidenceComplete) { From 9afd6d54c0d71834bf64625aa4c2eac3cb3f4032 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 22:24:00 +0900 Subject: [PATCH 079/101] Separate update C# cleanup planning --- ...IndexCommandRunner.Update.CSharpCleanup.cs | 355 ++++++++++++++++++ .../Cli/IndexCommandRunner.Update.cs | 304 +-------------- 2 files changed, 367 insertions(+), 292 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpCleanup.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpCleanup.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpCleanup.cs new file mode 100644 index 000000000..917602fb2 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpCleanup.cs @@ -0,0 +1,355 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private static FilePurgePlan PlanUpdateCSharpCleanup( + DbWriter writer, + FileIndexer indexer, + string projectRoot, + HashSet targetPaths, + IReadOnlyCollection gitTargetPaths, + IReadOnlyCollection explicitFileTargetPaths, + IndexCommandOptions options, + bool projectRootWritten, + bool? priorCSharpStaticInterfaceSourceEvidence, + Action throwIfUpdateCancelled, + CancellationToken cancellationToken) + { + var scopedCleanupPlans = new List(); + var csharpPreWorkspaceCleanupTargets = new List<( + string RetainedRelativePath, + string? Checksum, + bool IncludeDirectoryAndStem)>(); + + if (priorCSharpStaticInterfaceSourceEvidence != false) + { + PlanGitUpdateCSharpCleanup( + writer, + projectRoot, + targetPaths, + gitTargetPaths, + options.ChangedBetweenSpecified, + scopedCleanupPlans, + csharpPreWorkspaceCleanupTargets, + cancellationToken); + } + + if (options.ChangedBetweenSpecified + && priorCSharpStaticInterfaceSourceEvidence != false) + { + throwIfUpdateCancelled(); + var skipWorktreePaths = GitHelper.TryGetSkipWorktreePaths( + projectRoot, + cancellationToken); + var preservedMissingPaths = skipWorktreePaths == null + ? null + : new HashSet(skipWorktreePaths, StringComparer.Ordinal); + scopedCleanupPlans.Add( + writer.PlanStaleCSharpFiles( + projectRoot, + preservedMissingPaths, + cancellationToken)); + } + + if (priorCSharpStaticInterfaceSourceEvidence != false) + { + PlanExplicitUpdateCSharpCleanup( + writer, + indexer, + projectRoot, + explicitFileTargetPaths, + options.MaxFileSizeBytes, + projectRootWritten, + csharpPreWorkspaceCleanupTargets, + cancellationToken); + if (csharpPreWorkspaceCleanupTargets.Count > 0) + { + scopedCleanupPlans.Add( + writer.PlanStaleCSharpFilesSharingCleanupKeys( + projectRoot, + csharpPreWorkspaceCleanupTargets, + cancellationToken)); + } + } + + return FilePurgePlan.Merge(scopedCleanupPlans); + } + + private static void PlanGitUpdateCSharpCleanup( + DbWriter writer, + string projectRoot, + HashSet targetPaths, + IReadOnlyCollection gitTargetPaths, + bool changedBetweenSpecified, + List scopedCleanupPlans, + List<(string RetainedRelativePath, string? Checksum, bool IncludeDirectoryAndStem)> + csharpPreWorkspaceCleanupTargets, + CancellationToken cancellationToken) + { + if (gitTargetPaths.Count == 0) + return; + + // Git name-status supplies both sides of a rename. Resolve its missing side by the + // indexed path directly instead of hashing every live file in a wide commit/range. + // A live Git target is retained as a checksum-free alias key. Whole-path case folding + // is only a candidate prefilter; old/new filesystem identities must match before an + // exact persisted source row can be planned for deletion. Explicit --files may name + // only the retained side, so those targets keep checksum/stem discovery below. + // Git name-status は rename の両側を返すため missing 側を path で直接解決し、巨大 + // commit/range の live file を checksum のために二重読込しない。case-only rename + // 用の alias key は保持し、片側しか指定できない --files だけ checksum を読む。 + var missingGitTargetIndexPaths = new HashSet(StringComparer.Ordinal); + HashSet? liveGitTargetIndexPaths = null; + foreach (var gitTargetPath in gitTargetPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + var gitTarget = UpdateFileTarget.Create(projectRoot, gitTargetPath); + if (!File.Exists(LongPath.EnsureWindowsPrefix(gitTarget.FilePath))) + { + missingGitTargetIndexPaths.Add(gitTarget.IndexPath); + continue; + } + + (liveGitTargetIndexPaths ??= new HashSet(StringComparer.Ordinal)) + .Add(gitTarget.IndexPath); + } + + if (!changedBetweenSpecified) + { + scopedCleanupPlans.Add( + writer.PlanCSharpFilesInPaths( + missingGitTargetIndexPaths, + cancellationToken)); + } + + if (liveGitTargetIndexPaths is not { Count: > 0 }) + return; + + // File.Exists(old-cased-path) can resolve the retained file on an + // insensitive filesystem. Only a live Git path without an exact persisted + // row is the retained alias key; the old side already present in the DB + // must remain eligible for cleanup. + // case-insensitive FS では旧 casing も File.Exists=true になるため、DB に + // exact row がない live Git path だけを retained alias key とする。 + var persistedLiveGitPaths = writer.ResolveCSharpFilePaths( + liveGitTargetIndexPaths, + cancellationToken); + var persistedLiveGitPathsByAlias = new Dictionary>( + StringComparer.OrdinalIgnoreCase); + foreach (var persistedLiveGitPath in persistedLiveGitPaths) + { + if (!persistedLiveGitPathsByAlias.TryGetValue( + persistedLiveGitPath, + out var aliasSources)) + { + aliasSources = []; + persistedLiveGitPathsByAlias.Add(persistedLiveGitPath, aliasSources); + } + + aliasSources.Add(persistedLiveGitPath); + } + + // Resolve source identities lazily per case-fold bucket. Ordinary modified + // paths with exact persisted rows therefore pay no filesystem identity I/O, + // while repeated pathological fold variants still remain O(delta). + // source identity は実際に参照する fold bucket ごとに遅延解決し、通常の + // exact-row更新ではidentity I/Oを避けつつ病的variantもO(delta)に保つ。 + var persistedAliasSourcesByIdentity = new Dictionary< + string, + Dictionary>>( + StringComparer.OrdinalIgnoreCase); + + var persistedCaseAliasSources = new HashSet(StringComparer.Ordinal); + foreach (var liveGitTargetIndexPath in liveGitTargetIndexPaths) + { + if (persistedLiveGitPaths.Contains(liveGitTargetIndexPath)) + continue; + + // Case folding only narrows the candidates. On mixed-policy directory + // trees and case-sensitive filesystems two live spellings may be distinct + // files, so directly plan only exact persisted rows that resolve to the + // retained target's filesystem identity. + // case folding は候補絞り込みに限定し、mixed-policy directory tree + // でも retained target と filesystem identity が一致する exact row + // だけを直接 plan する。 + var retainedTarget = UpdateFileTarget.Create( + projectRoot, + liveGitTargetIndexPath); + var hasRetainedIdentity = FileIndexer.TryGetFileIdentity( + LongPath.EnsureWindowsPrefix(retainedTarget.FilePath), + out var retainedIdentity); + var provenAliasSource = false; + if (hasRetainedIdentity + && persistedLiveGitPathsByAlias.TryGetValue( + liveGitTargetIndexPath, + out var candidateAliasSources)) + { + if (!persistedAliasSourcesByIdentity.TryGetValue( + liveGitTargetIndexPath, + out var aliasIdentities)) + { + aliasIdentities = []; + foreach (var candidateAliasSource in candidateAliasSources) + { + var sourceTarget = UpdateFileTarget.Create( + projectRoot, + candidateAliasSource); + if (!FileIndexer.TryGetFileIdentity( + LongPath.EnsureWindowsPrefix(sourceTarget.FilePath), + out var sourceIdentity)) + { + continue; + } + + if (!aliasIdentities.TryGetValue( + sourceIdentity, + out var identitySources)) + { + identitySources = []; + aliasIdentities.Add(sourceIdentity, identitySources); + } + identitySources.Add(candidateAliasSource); + } + persistedAliasSourcesByIdentity.Add( + liveGitTargetIndexPath, + aliasIdentities); + } + + if (aliasIdentities.TryGetValue(retainedIdentity, out var aliasSources)) + { + foreach (var aliasSource in aliasSources) + { + persistedCaseAliasSources.Add(aliasSource); + provenAliasSource = true; + } + } + } + + if (!provenAliasSource) + { + csharpPreWorkspaceCleanupTargets.Add(( + liveGitTargetIndexPath, + Checksum: null, + IncludeDirectoryAndStem: false)); + } + } + + if (persistedCaseAliasSources.Count > 0) + { + scopedCleanupPlans.Add( + writer.PlanCSharpFilesInPaths( + persistedCaseAliasSources, + cancellationToken)); + } + + // Git reports both casings for a case-only rename, while File.Exists sees + // both paths as the same retained file on an insensitive filesystem. Drop + // only the exact persisted source spelling from the live update set; the + // retained spelling remains authoritative and the immutable alias cleanup + // plan above removes the old row without hashing the file. + // case-only rename では旧/新 casing の両方が存在扱いになるため、永続化済み + // source spelling だけを live update set から除き、alias cleanup に委ねる。 + foreach (var persistedCaseAliasSource in persistedCaseAliasSources) + targetPaths.Remove(persistedCaseAliasSource); + } + + private static void PlanExplicitUpdateCSharpCleanup( + DbWriter writer, + FileIndexer indexer, + string projectRoot, + IReadOnlyCollection explicitFileTargetPaths, + long? maxFileSizeBytes, + bool projectRootWritten, + List<(string RetainedRelativePath, string? Checksum, bool IncludeDirectoryAndStem)> + csharpPreWorkspaceCleanupTargets, + CancellationToken cancellationToken) + { + // A one-sided rename can name only the retained file. Reuse the persisted checksum + // for caller-selected paths whose filesystem stat is unchanged, and hash only new + // or stat-changed paths. Turn matching checksum/stem cleanup into an immutable plan + // before building the C# workspace, including when the retained path already has an + // indexed row. + // one-sided rename では retained 側しか指定されないため、caller-selected delta + // だけ実 checksum を読み、workspace 構築前に cleanup ID を確定する。 + foreach (var targetPath in explicitFileTargetPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + var cleanupTarget = UpdateFileTarget.Create(projectRoot, targetPath); + var ioPath = LongPath.EnsureWindowsPrefix(cleanupTarget.FilePath); + if (!File.Exists(ioPath)) + continue; + + string? checksum = null; + var includeDirectoryAndStem = false; + try + { + var fileInfo = new FileInfo(ioPath); + fileInfo.Refresh(); + if (!fileInfo.Exists) + continue; + + if (writer.TryGetFileChecksumByStat( + cleanupTarget.IndexPath, + fileInfo.Length, + fileInfo.LastWriteTimeUtc, + out checksum, + cancellationToken)) + { + includeDirectoryAndStem = projectRootWritten; + } + else + { + var pathFilter = indexer.EvaluatePathFilter(cleanupTarget.FilePath); + if (pathFilter.ShouldSkip || pathFilter.Errors.Any(error => error.IsFatal)) + continue; + + var indexability = + indexer.GetFileIndexabilityForIndexing(cleanupTarget.FilePath); + var detection = indexer.TryDetectLanguageForIndexing( + cleanupTarget.FilePath, + knownIndexability: indexability); + if (indexability == FileIndexer.FileProbeStatus.Supported + && detection.Status == FileIndexer.FileProbeStatus.Supported) + { + UpdateCleanupChecksumReadForTesting?.Invoke(cleanupTarget.IndexPath); + if (FileIndexer.TryComputeChecksum( + cleanupTarget.FilePath, + maxFileSizeBytes ?? FileIndexer.DefaultMaxFileSizeBytes, + out var computedChecksum, + cancellationToken)) + { + checksum = computedChecksum; + } + includeDirectoryAndStem = projectRootWritten; + } + else if (indexability != FileIndexer.FileProbeStatus.ProbeFailed + && detection.Status != FileIndexer.FileProbeStatus.ProbeFailed + && !writer.HasFileAtPath(cleanupTarget.IndexPath)) + { + // The normal update loop permits only the same-stem cleanup for a + // newly unsupported retained target. + includeDirectoryAndStem = projectRootWritten; + } + } + } + catch (Exception ex) when ( + ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + // The normal update loop reports the read/probe failure. Do not plan a + // cleanup that the actual target pass would not have reached. + continue; + } + + csharpPreWorkspaceCleanupTargets.Add(( + cleanupTarget.IndexPath, + checksum, + includeDirectoryAndStem)); + } + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index 5d9b3ebab..db2991507 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -383,298 +383,18 @@ void RecordCSharpWorkspaceDrift( var csharpWorkspaceHeartbeat = StartIndexJsonPhaseHeartbeat(options, "checking C# workspace contracts"); var priorCSharpStaticInterfaceSourceEvidence = writer.GetCSharpStaticInterfaceSourceEvidence(); - var scopedCleanupPlans = new List(); - var csharpPreWorkspaceCleanupTargets = new List<( - string RetainedRelativePath, - string? Checksum, - bool IncludeDirectoryAndStem)>(); - - void AddCSharpPreWorkspaceCleanupTarget( - string retainedRelativePath, - string? checksum, - bool includeDirectoryAndStem) - => csharpPreWorkspaceCleanupTargets.Add( - (retainedRelativePath, checksum, includeDirectoryAndStem)); - - // Git name-status supplies both sides of a rename. Resolve its missing side by the - // indexed path directly instead of hashing every live file in a wide commit/range. - // A live Git target is retained as a checksum-free alias key. Whole-path case folding - // is only a candidate prefilter; old/new filesystem identities must match before an - // exact persisted source row can be planned for deletion. Explicit --files may name - // only the retained side, so those targets keep checksum/stem discovery below. - // Git name-status は rename の両側を返すため missing 側を path で直接解決し、巨大 - // commit/range の live file を checksum のために二重読込しない。case-only rename - // 用の alias key は保持し、片側しか指定できない --files だけ checksum を読む。 - if (priorCSharpStaticInterfaceSourceEvidence != false - && gitTargetPaths.Count > 0) - { - var missingGitTargetIndexPaths = new HashSet(StringComparer.Ordinal); - HashSet? liveGitTargetIndexPaths = null; - foreach (var gitTargetPath in gitTargetPaths) - { - cancellationToken.ThrowIfCancellationRequested(); - var gitTarget = UpdateFileTarget.Create(projectRoot, gitTargetPath); - if (!File.Exists(LongPath.EnsureWindowsPrefix(gitTarget.FilePath))) - { - missingGitTargetIndexPaths.Add(gitTarget.IndexPath); - continue; - } - - (liveGitTargetIndexPaths ??= new HashSet(StringComparer.Ordinal)) - .Add(gitTarget.IndexPath); - } - - if (!options.ChangedBetweenSpecified) - { - scopedCleanupPlans.Add( - writer.PlanCSharpFilesInPaths( - missingGitTargetIndexPaths, - cancellationToken)); - } - - if (liveGitTargetIndexPaths is { Count: > 0 }) - { - // File.Exists(old-cased-path) can resolve the retained file on an - // insensitive filesystem. Only a live Git path without an exact persisted - // row is the retained alias key; the old side already present in the DB - // must remain eligible for cleanup. - // case-insensitive FS では旧 casing も File.Exists=true になるため、DB に - // exact row がない live Git path だけを retained alias key とする。 - var persistedLiveGitPaths = writer.ResolveCSharpFilePaths( - liveGitTargetIndexPaths, - cancellationToken); - var persistedLiveGitPathsByAlias = new Dictionary>( - StringComparer.OrdinalIgnoreCase); - foreach (var persistedLiveGitPath in persistedLiveGitPaths) - { - if (!persistedLiveGitPathsByAlias.TryGetValue( - persistedLiveGitPath, - out var aliasSources)) - { - aliasSources = []; - persistedLiveGitPathsByAlias.Add(persistedLiveGitPath, aliasSources); - } - - aliasSources.Add(persistedLiveGitPath); - } - // Resolve source identities lazily per case-fold bucket. Ordinary modified - // paths with exact persisted rows therefore pay no filesystem identity I/O, - // while repeated pathological fold variants still remain O(delta). - // source identity は実際に参照する fold bucket ごとに遅延解決し、通常の - // exact-row更新ではidentity I/Oを避けつつ病的variantもO(delta)に保つ。 - var persistedAliasSourcesByIdentity = new Dictionary< - string, - Dictionary>>( - StringComparer.OrdinalIgnoreCase); - - var persistedCaseAliasSources = new HashSet(StringComparer.Ordinal); - foreach (var liveGitTargetIndexPath in liveGitTargetIndexPaths) - { - if (!persistedLiveGitPaths.Contains(liveGitTargetIndexPath)) - { - // Case folding only narrows the candidates. On mixed-policy directory - // trees and case-sensitive filesystems two live spellings may be distinct - // files, so directly plan only exact persisted rows that resolve to the - // retained target's filesystem identity. - // case folding は候補絞り込みに限定し、mixed-policy directory tree - // でも retained target と filesystem identity が一致する exact row - // だけを直接 plan する。 - var retainedTarget = UpdateFileTarget.Create( - projectRoot, - liveGitTargetIndexPath); - var hasRetainedIdentity = FileIndexer.TryGetFileIdentity( - LongPath.EnsureWindowsPrefix(retainedTarget.FilePath), - out var retainedIdentity); - var provenAliasSource = false; - if (hasRetainedIdentity - && persistedLiveGitPathsByAlias.TryGetValue( - liveGitTargetIndexPath, - out var candidateAliasSources)) - { - if (!persistedAliasSourcesByIdentity.TryGetValue( - liveGitTargetIndexPath, - out var aliasIdentities)) - { - aliasIdentities = []; - foreach (var candidateAliasSource in candidateAliasSources) - { - var sourceTarget = UpdateFileTarget.Create( - projectRoot, - candidateAliasSource); - if (!FileIndexer.TryGetFileIdentity( - LongPath.EnsureWindowsPrefix(sourceTarget.FilePath), - out var sourceIdentity)) - { - continue; - } - - if (!aliasIdentities.TryGetValue( - sourceIdentity, - out var identitySources)) - { - identitySources = []; - aliasIdentities.Add(sourceIdentity, identitySources); - } - identitySources.Add(candidateAliasSource); - } - persistedAliasSourcesByIdentity.Add( - liveGitTargetIndexPath, - aliasIdentities); - } - - if (aliasIdentities.TryGetValue( - retainedIdentity, - out var aliasSources)) - { - foreach (var aliasSource in aliasSources) - { - persistedCaseAliasSources.Add(aliasSource); - provenAliasSource = true; - } - } - } - - if (!provenAliasSource) - { - AddCSharpPreWorkspaceCleanupTarget( - liveGitTargetIndexPath, - checksum: null, - includeDirectoryAndStem: false); - } - } - } - - if (persistedCaseAliasSources.Count > 0) - { - scopedCleanupPlans.Add( - writer.PlanCSharpFilesInPaths( - persistedCaseAliasSources, - cancellationToken)); - } - - // Git reports both casings for a case-only rename, while File.Exists sees - // both paths as the same retained file on an insensitive filesystem. Drop - // only the exact persisted source spelling from the live update set; the - // retained spelling remains authoritative and the immutable alias cleanup - // plan above removes the old row without hashing the file. - // case-only rename では旧/新 casing の両方が存在扱いになるため、永続化済み - // source spelling だけを live update set から除き、alias cleanup に委ねる。 - foreach (var persistedCaseAliasSource in persistedCaseAliasSources) - targetPaths.Remove(persistedCaseAliasSource); - } - } - - if (options.ChangedBetweenSpecified - && priorCSharpStaticInterfaceSourceEvidence != false) - { - ThrowIfUpdateCancelled(); - var skipWorktreePaths = GitHelper.TryGetSkipWorktreePaths(projectRoot, cancellationToken); - var preservedMissingPaths = skipWorktreePaths == null - ? null - : new HashSet(skipWorktreePaths, StringComparer.Ordinal); - scopedCleanupPlans.Add( - writer.PlanStaleCSharpFiles( - projectRoot, - preservedMissingPaths, - cancellationToken)); - } - - // A one-sided rename can name only the retained file. When prior source evidence - // makes C# cleanup relevant, reuse the persisted checksum for caller-selected paths - // whose filesystem stat is unchanged, and hash only new or stat-changed paths. Turn - // matching checksum/stem cleanup into an immutable plan before building the C# - // workspace, including when the retained path already has an indexed row. - // one-sided rename では retained 側しか指定されないため、caller-selected delta - // だけ実 checksum を読み、workspace 構築前に cleanup ID を確定する。 - if (priorCSharpStaticInterfaceSourceEvidence != false) - { - foreach (var targetPath in explicitFileTargetPaths) - { - cancellationToken.ThrowIfCancellationRequested(); - var cleanupTarget = UpdateFileTarget.Create(projectRoot, targetPath); - var ioPath = LongPath.EnsureWindowsPrefix(cleanupTarget.FilePath); - if (!File.Exists(ioPath)) - continue; - - string? checksum = null; - var includeDirectoryAndStem = false; - try - { - var fileInfo = new FileInfo(ioPath); - fileInfo.Refresh(); - if (!fileInfo.Exists) - continue; - - if (writer.TryGetFileChecksumByStat( - cleanupTarget.IndexPath, - fileInfo.Length, - fileInfo.LastWriteTimeUtc, - out checksum, - cancellationToken)) - { - includeDirectoryAndStem = projectRootWritten; - } - else - { - var pathFilter = indexer.EvaluatePathFilter(cleanupTarget.FilePath); - if (pathFilter.ShouldSkip || pathFilter.Errors.Any(error => error.IsFatal)) - continue; - - var indexability = indexer.GetFileIndexabilityForIndexing(cleanupTarget.FilePath); - var detection = indexer.TryDetectLanguageForIndexing( - cleanupTarget.FilePath, - knownIndexability: indexability); - if (indexability == FileIndexer.FileProbeStatus.Supported - && detection.Status == FileIndexer.FileProbeStatus.Supported) - { - UpdateCleanupChecksumReadForTesting?.Invoke(cleanupTarget.IndexPath); - if (FileIndexer.TryComputeChecksum( - cleanupTarget.FilePath, - options.MaxFileSizeBytes ?? FileIndexer.DefaultMaxFileSizeBytes, - out var computedChecksum, - cancellationToken)) - { - checksum = computedChecksum; - } - includeDirectoryAndStem = projectRootWritten; - } - else if (indexability != FileIndexer.FileProbeStatus.ProbeFailed - && detection.Status != FileIndexer.FileProbeStatus.ProbeFailed - && !writer.HasFileAtPath(cleanupTarget.IndexPath)) - { - // The normal update loop permits only the same-stem cleanup for a - // newly unsupported retained target. - includeDirectoryAndStem = projectRootWritten; - } - } - } - catch (Exception ex) when (ex is IOException - or UnauthorizedAccessException - or NotSupportedException - or ArgumentException) - { - // The normal update loop reports the read/probe failure. Do not plan a - // cleanup that the actual target pass would not have reached. - continue; - } - - AddCSharpPreWorkspaceCleanupTarget( - cleanupTarget.IndexPath, - checksum, - includeDirectoryAndStem); - } - - if (csharpPreWorkspaceCleanupTargets.Count > 0) - { - scopedCleanupPlans.Add( - writer.PlanStaleCSharpFilesSharingCleanupKeys( - projectRoot, - csharpPreWorkspaceCleanupTargets, - cancellationToken)); - } - } - - var scopedCleanupPlan = FilePurgePlan.Merge(scopedCleanupPlans); + var scopedCleanupPlan = PlanUpdateCSharpCleanup( + writer, + indexer, + projectRoot, + targetPaths, + gitTargetPaths, + explicitFileTargetPaths, + options, + projectRootWritten, + priorCSharpStaticInterfaceSourceEvidence, + ThrowIfUpdateCancelled, + cancellationToken); var scopedCleanupHadCSharp = scopedCleanupPlan.FileIds.Count > 0; var scopedCleanupHadContract = scopedCleanupHadCSharp && (priorCSharpStaticInterfaceSourceEvidence == true From bc1aeb23bcbbc63892350a9475d89b8bde7e10b1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 22:34:14 +0900 Subject: [PATCH 080/101] Separate full scan stale file planning --- ...dexCommandRunner.FullScan.PurgePlanning.cs | 100 ++++++++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 80 ++++---------- 2 files changed, 118 insertions(+), 62 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.PurgePlanning.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.PurgePlanning.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.PurgePlanning.cs new file mode 100644 index 000000000..13b4eba2f --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.PurgePlanning.cs @@ -0,0 +1,100 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed record FullScanPurgePreparation( + FilePurgePlan StaleFilePurgePlan, + int Purged, + IReadOnlySet? RetainedPaths, + IReadOnlyList IndexedJavaScriptTypeScriptConfigPathsBeforePurge, + bool HadCSharpStaticInterfaceContractsBeforePurge); + + private static FullScanPurgePreparation PlanFullScanStaleFiles( + DbWriter writer, + FileIndexer.ScanFilesResult scanResult, + IReadOnlyList fileTargets, + bool scanHadErrors, + bool startedWithNoIndexedFiles, + bool symbolsOnly, + bool deferCSharpMutationsForIncompleteScan, + bool? priorCSharpStaticInterfaceSourceEvidence, + bool priorFilterRetainedCSharpContractMembers, + CancellationToken cancellationToken) + { + if (startedWithNoIndexedFiles) + { + return new FullScanPurgePreparation( + FilePurgePlan.Empty, + Purged: 0, + RetainedPaths: null, + IndexedJavaScriptTypeScriptConfigPathsBeforePurge: [], + HadCSharpStaticInterfaceContractsBeforePurge: false); + } + + var retainedPaths = new HashSet(fileTargets.Count, StringComparer.Ordinal); + foreach (var target in fileTargets) + retainedPaths.Add(target.IndexPath); + var indexedJavaScriptTypeScriptConfigPathsBeforePurge = + writer.GetIndexedJavaScriptTypeScriptConfigPaths(); + + FilePurgePlan staleFilePurgePlan; + if (scanHadErrors) + { + retainedPaths.UnionWith( + scanResult.ProbeFailedFilePaths.Select(FileIndexer.NormalizeIndexPath)); + var authoritativeDirectories = scanResult.ListedDirectories + .Select(FileIndexer.NormalizeIndexPath) + .ToHashSet(StringComparer.Ordinal); + var attributePrunedDirectories = scanResult.AttributePrunedDirectories + .Select(FileIndexer.NormalizeIndexPath) + .ToHashSet(StringComparer.Ordinal); + attributePrunedDirectories.UnionWith( + scanResult.NestedRepositories.Select(FileIndexer.NormalizeIndexPath)); + var explicitlyRemovedPaths = scanResult.NonIndexablePaths + .Select(FileIndexer.NormalizeIndexPath) + .ToHashSet(StringComparer.Ordinal); + staleFilePurgePlan = writer.PlanFilesOutsideRetainedSetWithinListedDirectories( + retainedPaths, + authoritativeDirectories, + attributePrunedDirectories, + explicitlyRemovedPaths, + cancellationToken); + } + else + { + staleFilePurgePlan = writer.PlanFilesOutsideRetainedSet( + retainedPaths, + cancellationToken); + } + + if (deferCSharpMutationsForIncompleteScan && staleFilePurgePlan.Count > 0) + { + // A fatal discovery gap makes the C# workspace non-authoritative. Keep every + // prior row (including stale candidates) until a clean scan can rebuild implicit + // implementation references from one complete source snapshot. + // fatal discovery gap中はC# workspaceが不完全なため、clean scanまで既存rowを保持する。 + staleFilePurgePlan = FilePurgePlan.Empty; + } + + var hadCSharpStaticInterfaceContractsBeforePurge = !symbolsOnly + && staleFilePurgePlan.Count > 0 + && writer.HasCSharpFilesInFileIds(staleFilePurgePlan.FileIds, cancellationToken) + && (priorCSharpStaticInterfaceSourceEvidence == true + || writer.HasCSharpStaticInterfaceContractMembersInFileIds( + staleFilePurgePlan.FileIds, + includeInterfaceDeclarationsAsConservativeEvidence: + priorCSharpStaticInterfaceSourceEvidence == null + || !priorFilterRetainedCSharpContractMembers, + cancellationToken)); + + return new FullScanPurgePreparation( + staleFilePurgePlan, + staleFilePurgePlan.Count, + retainedPaths, + indexedJavaScriptTypeScriptConfigPathsBeforePurge, + hadCSharpStaticInterfaceContractsBeforePurge); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index efa11e714..a25938c75 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -183,8 +183,6 @@ void ThrowIfFullScanCancelled(int filesProcessed, int? filesTotal) CancellationTokenSource? purgeCts = null; if (!options.Json && !options.Quiet) purgeCts = ConsoleUi.StartSpinner("Cleaning up stale entries...", spinnerFrames); - var purged = 0; - var staleFilePurgePlan = FilePurgePlan.Empty; var startedWithNoIndexedFiles = !writer.HasAnyIndexedFiles(); var priorCSharpStaticInterfaceSourceEvidence = options.Rebuild || startedWithNoIndexedFiles ? null @@ -211,66 +209,24 @@ void ThrowIfFullScanCancelled(int filesProcessed, int? filesTotal) && typeScriptAugmentationVersionMatchesCurrent ? writer.BeginTypeScriptAugmentationDirtyNameTracking(useScopedTypeScriptAugmentationRefresh) : null; - var retainedPaths = startedWithNoIndexedFiles - ? null - : new HashSet(fileTargets.Length, StringComparer.Ordinal); - IReadOnlyList indexedJavaScriptTypeScriptConfigPathsBeforePurge = []; - if (!startedWithNoIndexedFiles) - { - foreach (var target in fileTargets) - retainedPaths!.Add(target.IndexPath); - indexedJavaScriptTypeScriptConfigPathsBeforePurge = writer.GetIndexedJavaScriptTypeScriptConfigPaths(); - } - if (scanHadErrors) - { - if (!startedWithNoIndexedFiles) - { - retainedPaths!.UnionWith(scanResult.ProbeFailedFilePaths.Select(FileIndexer.NormalizeIndexPath)); - var authoritativeDirectories = scanResult.ListedDirectories - .Select(FileIndexer.NormalizeIndexPath) - .ToHashSet(StringComparer.Ordinal); - var attributePrunedDirectories = scanResult.AttributePrunedDirectories - .Select(FileIndexer.NormalizeIndexPath) - .ToHashSet(StringComparer.Ordinal); - attributePrunedDirectories.UnionWith(scanResult.NestedRepositories.Select(FileIndexer.NormalizeIndexPath)); - var explicitlyRemovedPaths = scanResult.NonIndexablePaths - .Select(FileIndexer.NormalizeIndexPath) - .ToHashSet(StringComparer.Ordinal); - staleFilePurgePlan = writer.PlanFilesOutsideRetainedSetWithinListedDirectories( - retainedPaths!, - authoritativeDirectories, - attributePrunedDirectories, - explicitlyRemovedPaths, - cancellationToken); - purged = staleFilePurgePlan.Count; - } - } - else - { - if (!startedWithNoIndexedFiles) - staleFilePurgePlan = writer.PlanFilesOutsideRetainedSet(retainedPaths!, cancellationToken); - purged = staleFilePurgePlan.Count; - } - if (deferCSharpMutationsForIncompleteScan && staleFilePurgePlan.Count > 0) - { - // A fatal discovery gap makes the C# workspace non-authoritative. Keep every - // prior row (including stale candidates) until a clean scan can rebuild implicit - // implementation references from one complete source snapshot. - // fatal discovery gap中はC# workspaceが不完全なため、clean scanまで既存rowを保持する。 - staleFilePurgePlan = FilePurgePlan.Empty; - purged = 0; - } - var hadCSharpStaticInterfaceContractsBeforePurge = !options.SymbolsOnly - && !startedWithNoIndexedFiles - && staleFilePurgePlan.Count > 0 - && writer.HasCSharpFilesInFileIds(staleFilePurgePlan.FileIds, cancellationToken) - && (priorCSharpStaticInterfaceSourceEvidence == true - || writer.HasCSharpStaticInterfaceContractMembersInFileIds( - staleFilePurgePlan.FileIds, - includeInterfaceDeclarationsAsConservativeEvidence: - priorCSharpStaticInterfaceSourceEvidence == null - || !priorFilterRetainedCSharpContractMembers, - cancellationToken)); + var purgePreparation = PlanFullScanStaleFiles( + writer, + scanResult, + fileTargets, + scanHadErrors, + startedWithNoIndexedFiles, + options.SymbolsOnly, + deferCSharpMutationsForIncompleteScan, + priorCSharpStaticInterfaceSourceEvidence, + priorFilterRetainedCSharpContractMembers, + cancellationToken); + var staleFilePurgePlan = purgePreparation.StaleFilePurgePlan; + var purged = purgePreparation.Purged; + var retainedPaths = purgePreparation.RetainedPaths; + var indexedJavaScriptTypeScriptConfigPathsBeforePurge = + purgePreparation.IndexedJavaScriptTypeScriptConfigPathsBeforePurge; + var hadCSharpStaticInterfaceContractsBeforePurge = + purgePreparation.HadCSharpStaticInterfaceContractsBeforePurge; ConsoleUi.StopSpinner(purgeCts); WriteFullScanJsonLiveness(options, purged > 0 ? $"identified {purged:N0} stale file(s); preparing index writes..." From e16adc2071d07a3d14d4742416ff7ec8698d7bf2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 22:42:36 +0900 Subject: [PATCH 081/101] Split export and import command flows --- ...ExportImportCommandRunner.ExportArchive.cs | 215 +++++ .../ExportImportCommandRunner.ExportCtags.cs | 200 +++++ .../Cli/ExportImportCommandRunner.Import.cs | 210 +++++ ...portImportCommandRunner.ImportArguments.cs | 124 +++ .../ExportImportCommandRunner.ImportOutput.cs | 101 +++ .../Cli/ExportImportCommandRunner.cs | 770 ------------------ 6 files changed, 850 insertions(+), 770 deletions(-) create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.ExportCtags.cs create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.Import.cs create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.ImportArguments.cs create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.ImportOutput.cs diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs new file mode 100644 index 000000000..2ead0acd0 --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs @@ -0,0 +1,215 @@ +using System.IO.Compression; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIndex.Archives; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ExportImportCommandRunner +{ + private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOptions, string appVersion, CancellationToken cancellationToken) + { + string? outputPath = null; + string? dbPath = null; + string? lang = null; + string? solution = null; + var pathPatterns = new List(); + var excludePathPatterns = new List(); + var projects = new List(); + var excludeTests = false; + var wantsJson = Array.Exists(args, arg => arg == "--json"); + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "--json") + { + wantsJson = true; + continue; + } + if (arg == "--exclude-tests") + { + excludeTests = true; + continue; + } + + if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) + { + if (dbError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_db_requires_value", dbError, "use `cdidx export --db `.", ArchiveExportUsage); + dbPath = dbValue; + continue; + } + + if (TryReadValueOption(args, ref i, "--lang", arg, out var langValue, out var langError)) + { + if (langError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_lang_requires_value", langError, "pass a language name such as `csharp`, `cs`, or `python`.", ArchiveExportUsage); + lang = DbReader.NormalizeQueryLanguage(langValue); + continue; + } + + if (TryReadValueOption(args, ref i, "--path", arg, out var pathValue, out var pathError)) + { + if (pathError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_path_requires_value", pathError, "pass a path substring or glob such as `src/` or `src/*.cs`.", ArchiveExportUsage); + pathPatterns.Add(pathValue!); + continue; + } + + if (TryReadValueOption(args, ref i, "--exclude-path", arg, out var excludePathValue, out var excludePathError)) + { + if (excludePathError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_exclude_path_requires_value", excludePathError, "pass a path substring or glob to omit.", ArchiveExportUsage); + excludePathPatterns.Add(excludePathValue!); + continue; + } + + if (TryReadValueOption(args, ref i, "--project", arg, out var projectValue, out var projectError)) + { + if (projectError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_project_requires_value", projectError, "pass a project name or project path.", ArchiveExportUsage); + projects.Add(projectValue!); + continue; + } + + if (TryReadValueOption(args, ref i, "--solution", arg, out var solutionValue, out var solutionError)) + { + if (solutionError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_solution_requires_value", solutionError, "pass a solution path used to resolve project names.", ArchiveExportUsage); + solution = solutionValue; + continue; + } + + if (arg.StartsWith("-", StringComparison.Ordinal)) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_unknown_option", $"unknown export option `{arg}`.", "use archive scope flags or `cdidx export ctags`.", ArchiveExportUsage); + + if (outputPath != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_extra_archive_path", $"export accepts exactly one archive path, got extra `{arg}`.", "remove the extra argument.", ArchiveExportUsage); + outputPath = arg; + } + + if (string.IsNullOrWhiteSpace(outputPath)) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_archive_required", "export requires an output archive path.", "pass a destination such as `codeindex.cdidx.zip`, or use `cdidx export ctags`.", ArchiveExportUsage); + if (solution != null && projects.Count == 0) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_solution_requires_project", "--solution requires at least one --project filter.", "add `--project ` or remove `--solution`.", ArchiveExportUsage); + if (!TryValidateArchiveScopeValues(pathPatterns, excludePathPatterns, projects, solution, out var scopeValidationMessage)) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_scope_invalid", scopeValidationMessage, "reduce or shorten the archive scope values.", ArchiveExportUsage); + + dbPath ??= DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; + var normalizedDbPath = DbPathResolver.NormalizeDbPath(dbPath); + if (!DbContext.TryValidateExistingCodeIndexDb( + normalizedDbPath, + requireWritable: false, + requireSupportedUserVersion: false, + out var validationMessage, + out _, + out _)) + return WriteExportError(wantsJson, jsonOptions, PhaseSqliteValidate, "export_database_invalid", validationMessage, "run `cdidx index ` first or pass `--db `.", ArchiveExportUsage); + + var fullSourceDbPath = Path.GetFullPath(normalizedDbPath); + var fullOutputPath = Path.GetFullPath(outputPath); + if (IsDatabaseOrSqliteSidecarPath(fullOutputPath, fullSourceDbPath)) + { + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_archive_overlaps_database", "export archive path must not be the source database or a SQLite sidecar.", "choose a separate archive path, for example `codeindex.cdidx.zip`.", ArchiveExportUsage); + } + + var scopeOptions = new ArchiveExportOptions( + lang, + pathPatterns.ToArray(), + excludePathPatterns.ToArray(), + projects.ToArray(), + solution, + excludeTests); + string? snapshotDirectory = null; + string? snapshotPath = null; + var phase = PhaseWriteArchive; + try + { + cancellationToken.ThrowIfCancellationRequested(); + snapshotDirectory = DataDirectorySecurity.CreateSensitiveTempDirectory("codeindex-export-").FullName; + snapshotPath = Path.Combine(snapshotDirectory, "codeindex.db"); + var outputDirectory = Path.GetDirectoryName(fullOutputPath); + if (!string.IsNullOrWhiteSpace(outputDirectory)) + Directory.CreateDirectory(outputDirectory); + + phase = PhaseSqliteValidate; + CreateDatabaseSnapshot(normalizedDbPath, snapshotPath, cancellationToken); + ExportManifest manifest; + if (scopeOptions.IsScoped) + { + using var snapshotContext = new DbContext(DbOpenIntent.Migration, snapshotPath, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + snapshotContext.TryMigrateForRead(); + if (snapshotContext.LastMigrationFailure is { } migrationFailure) + { + throw new InvalidDataException( + $"export snapshot schema migration failed at {migrationFailure.Step}: {migrationFailure.SqliteMessage}"); + } + phase = PhaseScopeArchive; + var snapshotConnection = snapshotContext.Connection; + var scope = ApplyArchiveScope(snapshotConnection, scopeOptions, cancellationToken); + manifest = BuildManifest(snapshotConnection, appVersion, scope, cancellationToken); + } + else + { + using var snapshotConnection = new SqliteConnection(CreateUnpooledConnectionString(snapshotPath)); + cancellationToken.ThrowIfCancellationRequested(); + snapshotConnection.Open(); + phase = PhaseScopeArchive; + var scope = ApplyArchiveScope(snapshotConnection, scopeOptions, cancellationToken); + manifest = BuildManifest(snapshotConnection, appVersion, scope, cancellationToken); + } + SqliteConnection.ClearAllPools(); + phase = PhaseSha256; + manifest = manifest with { DatabaseSha256 = ComputeSha256(snapshotPath, cancellationToken) }; + phase = PhaseWriteArchive; + WriteExportArchiveFile(fullOutputPath, snapshotPath, manifest, jsonOptions, cancellationToken); + + if (wantsJson) + Console.WriteLine(JsonSerializer.Serialize( + new ExportArchiveResult( + "1", + fullOutputPath, + fullSourceDbPath, + manifest.Scope ?? throw new InvalidDataException("export scope metadata was not created")), + jsonOptions)); + else + Console.WriteLine($"Exported CodeIndex archive to {fullOutputPath}"); + return CommandExitCodes.Success; + } + catch (OperationCanceledException) + { + return WriteExportError( + wantsJson, + jsonOptions, + phase, + CommandErrorCodes.Interrupted, + "export cancelled before it could complete.", + "retry `cdidx export` after the cancelling operation completes.", + ArchiveExportUsage, + CommandExitCodes.CancelledBySignal); + } + catch (Exception ex) + { + return WriteExportError(wantsJson, jsonOptions, phase, "export_failed", $"export failed ({CommandErrorWriter.FormatSanitizedException(ex)}).", "check the database, scope, project, and output archive paths.", ArchiveExportUsage); + } + finally + { + if (snapshotPath != null) + { + TryDeleteFile(snapshotPath, "export temporary database"); + DeleteSqliteSidecars(snapshotPath, "export temporary database sidecar"); + } + if (snapshotDirectory != null) + TryDeleteDirectoryIfEmpty(snapshotDirectory, "export temporary directory", Path.GetTempPath(), "codeindex-export-"); + } + } +} diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ExportCtags.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ExportCtags.cs new file mode 100644 index 000000000..6712fad9b --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ExportCtags.cs @@ -0,0 +1,200 @@ +using System.IO.Compression; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIndex.Archives; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ExportImportCommandRunner +{ + private static int RunExportCtags(string[] args, JsonSerializerOptions jsonOptions) + { + var outputPath = "tags"; + string? dbPath = null; + string? lang = null; + var pathPatterns = new List(); + var excludePathPatterns = new List(); + var excludeTests = false; + var includeGenerated = false; + var wantsJson = Array.Exists(args, arg => arg == "--json"); + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "--json") + { + wantsJson = true; + continue; + } + if (arg == "--exclude-tests") + { + excludeTests = true; + continue; + } + if (arg == "--include-generated") + { + includeGenerated = true; + continue; + } + + if (TryReadValueOption(args, ref i, "--output", arg, out var outputValue, out var outputError)) + { + if (outputError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_output_requires_value", outputError, "use `cdidx export ctags --output tags`.", CtagsExportUsage); + outputPath = outputValue!; + continue; + } + + if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) + { + if (dbError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_db_requires_value", dbError, "use `cdidx export ctags --db `.", CtagsExportUsage); + dbPath = dbValue; + continue; + } + + if (TryReadValueOption(args, ref i, "--lang", arg, out var langValue, out var langError)) + { + if (langError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_lang_requires_value", langError, "pass a language name such as `csharp`, `cs`, or `python`.", CtagsExportUsage); + lang = DbReader.NormalizeQueryLanguage(langValue); + continue; + } + + if (TryReadValueOption(args, ref i, "--path", arg, out var pathValue, out var pathError)) + { + if (pathError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_path_requires_value", pathError, "pass a path substring or glob such as `src/` or `src/*.cs`.", CtagsExportUsage); + pathPatterns.Add(pathValue!); + continue; + } + + if (TryReadValueOption(args, ref i, "--exclude-path", arg, out var excludePathValue, out var excludePathError)) + { + if (excludePathError != null) + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_exclude_path_requires_value", excludePathError, "pass a path substring or glob to omit.", CtagsExportUsage); + excludePathPatterns.Add(excludePathValue!); + continue; + } + + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_unknown_option", $"unknown ctags export option `{arg}`.", "use `--output`, `--db`, `--json`, or filter flags such as `--include-generated`.", CtagsExportUsage); + } + + dbPath ??= DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; + var normalizedDbPath = DbPathResolver.NormalizeDbPath(dbPath); + var fullSourceDbPath = Path.GetFullPath(normalizedDbPath); + var fullOutputPath = Path.GetFullPath(outputPath); + if (IsDatabaseOrSqliteSidecarPath(fullOutputPath, fullSourceDbPath)) + { + return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_output_overlaps_database", "ctags output path must not be the source database or a SQLite sidecar.", "choose a separate tags path, for example `tags`.", CtagsExportUsage); + } + + if (!DbContext.TryValidateExistingCodeIndexDb( + normalizedDbPath, + requireWritable: false, + requireSupportedUserVersion: false, + out var validationMessage, + out _, + out _)) + return WriteExportError(wantsJson, jsonOptions, PhaseSqliteValidate, "ctags_export_database_invalid", validationMessage, "run `cdidx index ` first or pass `--db `.", CtagsExportUsage); + + try + { + using var db = new DbContext(DbOpenIntent.QueryOnly, normalizedDbPath); + var generatedFileFilterAvailable = DbSchemaCache.LoadColumns(db.Connection, "files").Contains("generated"); + var filters = new CtagsExportOptions( + lang, + pathPatterns.ToArray(), + excludePathPatterns.ToArray(), + excludeTests, + includeGenerated, + generatedFileFilterAvailable); + var outputDirectory = Path.GetDirectoryName(fullOutputPath); + if (!string.IsNullOrWhiteSpace(outputDirectory)) + Directory.CreateDirectory(outputDirectory); + + long emittedCount = 0; + var skipReasonCounts = wantsJson + ? CountCtagsSkipReasons(db.Connection, filters) + : null; + WriteCtagsFile(fullOutputPath, writer => + { + writer.WriteLine("!_TAG_FILE_FORMAT\t2\t/extended format/"); + writer.WriteLine("!_TAG_FILE_SORTED\t1\t/0=unsorted, 1=sorted, 2=foldcase/"); + + using var cmd = CreateCtagsSymbolCommand(db.Connection, filters); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var name = SanitizeCtagsField(reader.GetString(0)); + var path = SanitizeCtagsField(reader.GetString(1)); + var line = Math.Max(1, reader.GetInt32(2)); + var kind = SanitizeCtagsField(reader.GetString(3)); + var tagLine = new StringBuilder() + .Append(name) + .Append('\t') + .Append(path) + .Append('\t') + .Append(line.ToString(CultureInfo.InvariantCulture)) + .Append(";\"\tkind:") + .Append(kind) + .Append("\tline:") + .Append(line.ToString(CultureInfo.InvariantCulture)); + AppendCtagsExtensionField(tagLine, "language", ExportImportSqliteRow.ReadNullableString(reader, 4)); + AppendCtagsExtensionField(tagLine, "container_kind", ExportImportSqliteRow.ReadNullableString(reader, 5)); + AppendCtagsExtensionField(tagLine, "container", ExportImportSqliteRow.ReadNullableString(reader, 6)); + AppendCtagsExtensionField(tagLine, "visibility", ExportImportSqliteRow.ReadNullableString(reader, 7)); + writer.WriteLine(tagLine.ToString()); + emittedCount++; + } + }); + + if (wantsJson) + { + var skippedCount = skipReasonCounts!.Values.Sum(); + var totalTagCount = emittedCount + skippedCount; + var result = new CtagsExportResult( + "1", + "success", + fullOutputPath, + fullSourceDbPath, + totalTagCount, + emittedCount, + skippedCount, + skipReasonCounts, + new CtagsExportFilterResult( + filters.Lang, + filters.PathPatterns, + filters.ExcludePathPatterns, + filters.ExcludeTests, + filters.IncludeGenerated, + filters.IncludeGenerated + ? "include" + : filters.GeneratedFileFilterAvailable + ? "exclude" + : "unavailable", + filters.GeneratedFileFilterAvailable), + ["kind", "line", "language", "container_kind", "container", "visibility"]); + Console.WriteLine(JsonSerializer.Serialize( + result, + CliJsonSerializerContextFactory.Create(jsonOptions).CtagsExportResult)); + } + else + { + Console.WriteLine($"Exported ctags to {fullOutputPath}"); + } + return CommandExitCodes.Success; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SqliteException) + { + return WriteExportError(wantsJson, jsonOptions, PhaseWriteCtags, "ctags_export_failed", $"ctags export failed ({CommandErrorWriter.FormatSanitizedException(ex)}).", "check the database and output paths.", CtagsExportUsage); + } + } +} diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.Import.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.Import.cs new file mode 100644 index 000000000..59532023e --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.Import.cs @@ -0,0 +1,210 @@ +using System.IO.Compression; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIndex.Archives; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ExportImportCommandRunner +{ + public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, CancellationToken cancellationToken = default) + { + var parseResult = ParseImportArguments(args, jsonOptions); + var importArguments = parseResult.Arguments; + if (importArguments == null) + return parseResult.ExitCode; + + var archivePath = importArguments.ArchivePath; + var wantsJson = importArguments.WantsJson; + var prunePaths = importArguments.PrunePaths; + var importMode = importArguments.ImportMode; + var dryRun = importArguments.DryRun; + var limit = importArguments.Limit; + var offset = importArguments.Offset; + var dbPath = importArguments.DbPath + ?? DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; + var fullDbPath = Path.GetFullPath(DbPathResolver.NormalizeDbPath(dbPath)); + var importTargetProjectRoot = ResolveImportTargetProjectRoot(fullDbPath); + var dbDirectory = Path.GetDirectoryName(fullDbPath); + if (string.IsNullOrWhiteSpace(dbDirectory)) + return WriteImportError(wantsJson, jsonOptions, PhaseParseArgs, "import_db_directory_unresolved", $"could not resolve destination DB directory for `{dbPath}`.", "pass an explicit `--db `.", ImportUsage); + + string? tempDirectory = null; + string? tempPath = null; + ExportManifest? importedManifest = null; + var validationPhases = new List(); + var phase = PhaseOpenArchive; + try + { + cancellationToken.ThrowIfCancellationRequested(); + if (dryRun) + { + tempDirectory = DataDirectorySecurity.CreateSensitiveTempDirectory("codeindex-import-").FullName; + tempPath = Path.Combine(tempDirectory, "codeindex.db"); + } + else + { + Directory.CreateDirectory(dbDirectory); + tempPath = Path.Combine(dbDirectory, $".codeindex-import-{Guid.NewGuid():N}.db"); + } + + using (var archive = ZipFile.OpenRead(archivePath)) + { + cancellationToken.ThrowIfCancellationRequested(); + AddImportValidationPhase(validationPhases, PhaseOpenArchive); + if (!TryValidateImportArchiveEntries( + archive, + out var manifestEntry, + out var dbEntry, + out var entryValidationPhase, + out var entryValidationErrorCode, + out var entryValidationMessage)) + { + return WriteImportError( + wantsJson, + jsonOptions, + entryValidationPhase, + entryValidationErrorCode, + entryValidationMessage, + "use an archive produced by `cdidx export `.", + ImportUsage); + } + + phase = PhaseManifest; + if (!TryReadManifest(manifestEntry, jsonOptions, out var manifest, out var manifestError, cancellationToken)) + return WriteImportError(wantsJson, jsonOptions, PhaseManifest, "import_manifest_invalid", $"archive manifest is invalid: {manifestError}.", "use an archive produced by `cdidx export `.", ImportUsage); + if (!ExportImportManifestCodec.TryValidateHeader(manifest, out var manifestHeaderError)) + return WriteImportError(wantsJson, jsonOptions, PhaseManifest, "import_manifest_incompatible", $"archive manifest is invalid: {manifestHeaderError}.", "re-export from a compatible CodeIndex database.", ImportUsage); + importedManifest = manifest; + AddImportValidationPhase(validationPhases, PhaseManifest); + + phase = PhaseDatabaseEntry; + if (dbEntry == null) + return WriteImportError(wantsJson, jsonOptions, PhaseDatabaseEntry, "import_database_entry_missing", $"archive is missing {DatabaseEntryName}.", "use an archive produced by `cdidx export `.", ImportUsage); + if (!TryValidateDatabaseEntrySize(dbEntry.Length, dbEntry.CompressedLength, out var sizeValidationMessage)) + return WriteImportError(wantsJson, jsonOptions, PhaseDatabaseEntry, "import_database_entry_too_large", sizeValidationMessage, "re-export a smaller CodeIndex database or rebuild a smaller index.", ImportUsage); + + ExtractDatabaseEntryToFile(dbEntry, tempPath, cancellationToken); + AddImportValidationPhase(validationPhases, PhaseDatabaseEntry); + + phase = PhaseSha256; + if (!TryValidateImportedManifest(manifest, tempPath, out var manifestValidationMessage, out var manifestValidationPhase, cancellationToken)) + return WriteImportError(wantsJson, jsonOptions, manifestValidationPhase, "import_manifest_mismatch", $"archive manifest mismatch: {manifestValidationMessage}.", "re-export from a compatible CodeIndex database.", ImportUsage); + AddImportValidationPhase(validationPhases, PhaseSha256); + } + + phase = PhaseSqliteValidate; + if (!DbContext.TryValidateExistingCodeIndexDb( + tempPath, + requireWritable: true, + requireSupportedUserVersion: false, + out var validationMessage, + out _, + out _, + cancellationToken)) + return WriteImportError(wantsJson, jsonOptions, PhaseSqliteValidate, "import_database_invalid", $"archive database is invalid: {validationMessage}.", "re-export from a compatible CodeIndex database.", ImportUsage); + AddImportValidationPhase(validationPhases, PhaseSqliteValidate); + SqliteConnection.ClearAllPools(); + + if (prunePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + phase = PhasePrunePaths; + RewriteImportedProjectRoot(tempPath, importTargetProjectRoot); + AddImportValidationPhase(validationPhases, PhasePrunePaths); + SqliteConnection.ClearAllPools(); + } + + if (dryRun) + { + phase = PhaseDestinationDelta; + var destinationDelta = BuildImportDestinationDelta( + fullDbPath, + tempPath, + Path.GetFullPath(archivePath), + limit, + offset, + cancellationToken); + AddImportValidationPhase( + validationPhases, + PhaseDestinationDelta, + destinationDelta.Comparable ? "success" : "unavailable", + destinationDelta.Message); + AddImportValidationPhase(validationPhases, PhaseReplaceDb, "skipped", $"{importMode} mode does not replace the destination database"); + var manifest = importedManifest ?? throw new InvalidDataException("archive manifest was not loaded"); + return WriteImportDryRunResult( + importArguments, + jsonOptions, + fullDbPath, + importTargetProjectRoot, + validationPhases, + destinationDelta, + manifest); + } + + phase = PhaseReplaceDb; + ReplaceImportedDatabase(tempPath, fullDbPath, cancellationToken); + AddImportValidationPhase(validationPhases, PhaseReplaceDb); + return WriteImportResult( + importArguments, + jsonOptions, + fullDbPath, + importTargetProjectRoot, + validationPhases, + importedManifest ?? throw new InvalidDataException("archive manifest was not loaded")); + } + catch (OperationCanceledException) + { + return WriteImportError( + wantsJson, + jsonOptions, + phase, + CommandErrorCodes.Interrupted, + "import cancelled before it could complete.", + "retry `cdidx import` after the cancelling operation completes.", + ImportUsage, + CommandExitCodes.CancelledBySignal); + } + catch (ImportReplacementException ex) + { + return WriteImportError( + wantsJson, + jsonOptions, + PhaseReplaceDb, + "import_replacement_failed", + $"import failed ({CommandErrorWriter.FormatSanitizedException(ex.InnerException ?? ex)}).", + "check destination database permissions and inspect diagnostics for residual replacement state.", + ImportUsage, + diagnostics: ex.Diagnostics); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or SqliteException) + { + return WriteImportError( + wantsJson, + jsonOptions, + phase, + "import_failed", + $"import failed ({CommandErrorWriter.FormatSanitizedException(ex)}).", + "check the archive path and destination database permissions.", + ImportUsage, + rootCause: ClassifyImportFailureRootCause(phase, ex)); + } + finally + { + if (tempPath != null) + { + TryDeleteFile(tempPath, "import temporary database"); + DeleteSqliteSidecars(tempPath, "import temporary database sidecar"); + } + if (tempDirectory != null) + TryDeleteDirectoryIfEmpty(tempDirectory, "import temporary directory", Path.GetTempPath(), "codeindex-import-"); + } + } +} diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ImportArguments.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportArguments.cs new file mode 100644 index 000000000..67e4314a6 --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportArguments.cs @@ -0,0 +1,124 @@ +using System.IO.Compression; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIndex.Archives; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ExportImportCommandRunner +{ + private static ImportArgumentParseResult ParseImportArguments( + string[] args, + JsonSerializerOptions jsonOptions) + { + string? archivePath = null; + string? dbPath = null; + var wantsJson = Array.Exists(args, arg => arg == "--json"); + var prunePaths = false; + var importMode = "import"; + var dryRun = false; + var limit = DiffCommandRunner.DefaultDiffLimit; + var offset = 0; + var pagingOptionSpecified = false; + + ImportArgumentParseResult Fail(string errorCode, string message, string recommendedAction) + { + return new ImportArgumentParseResult( + Arguments: null, + WriteImportError( + wantsJson, + jsonOptions, + PhaseParseArgs, + errorCode, + message, + recommendedAction, + ImportUsage)); + } + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "--json") + { + wantsJson = true; + continue; + } + if (arg == "--prune-paths") + { + prunePaths = true; + continue; + } + if (arg is "--dry-run" or "--check") + { + importMode = arg == "--check" ? "check" : "dry_run"; + dryRun = true; + continue; + } + + if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) + { + if (dbError != null) + return Fail("import_db_requires_value", dbError, "use `cdidx import --db `."); + dbPath = dbValue; + continue; + } + + if (TryReadValueOption(args, ref i, "--limit", arg, out var limitValue, out var limitError)) + { + pagingOptionSpecified = true; + if (limitError != null + || !int.TryParse(limitValue, NumberStyles.None, CultureInfo.InvariantCulture, out limit) + || limit < 0 + || limit > DiffCommandRunner.MaxDiffLimit) + { + return Fail( + "import_limit_invalid", + $"--limit requires an integer from 0 to {DiffCommandRunner.MaxDiffLimit}.", + "use `--limit 20` to bound destination delta samples."); + } + continue; + } + + if (TryReadValueOption(args, ref i, "--offset", arg, out var offsetValue, out var offsetError)) + { + pagingOptionSpecified = true; + if (offsetError != null + || !int.TryParse(offsetValue, NumberStyles.None, CultureInfo.InvariantCulture, out offset) + || offset < 0 + || offset > int.MaxValue - limit) + { + return Fail( + "import_offset_invalid", + "--offset requires a non-negative integer that can be combined with --limit.", + "use `--offset 0` for the first destination delta page."); + } + continue; + } + + if (arg.StartsWith("-", StringComparison.Ordinal)) + return Fail("import_unknown_option", $"unknown import option `{arg}`.", "use `cdidx import [--db ]`."); + + if (archivePath != null) + return Fail("import_extra_archive_path", $"import accepts exactly one archive path, got extra `{arg}`.", "remove the extra argument."); + archivePath = arg; + } + + if (string.IsNullOrWhiteSpace(archivePath)) + return Fail("import_archive_required", "import requires an archive path.", "pass an archive produced by `cdidx export `."); + if (pagingOptionSpecified && !dryRun) + return Fail("import_paging_requires_dry_run", "--limit and --offset are only valid with --dry-run or --check.", "add `--dry-run` to preview bounded destination deltas."); + if (offset > int.MaxValue - limit) + return Fail("import_offset_invalid", "--offset is too large for the requested --limit.", "choose a lower --offset."); + + return new ImportArgumentParseResult( + new ImportArguments(archivePath, dbPath, wantsJson, prunePaths, importMode, dryRun, limit, offset), + CommandExitCodes.Success); + } +} diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ImportOutput.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportOutput.cs new file mode 100644 index 000000000..b764b219b --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportOutput.cs @@ -0,0 +1,101 @@ +using System.IO.Compression; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIndex.Archives; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Models; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ExportImportCommandRunner +{ + private static int WriteImportDryRunResult( + ImportArguments importArguments, + JsonSerializerOptions jsonOptions, + string fullDbPath, + string importTargetProjectRoot, + IReadOnlyList validationPhases, + ImportDestinationDeltaResult destinationDelta, + ExportManifest manifest) + { + if (importArguments.WantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + new ImportDryRunResult( + "1", + "success", + Path.GetFullPath(importArguments.ArchivePath), + fullDbPath, + importArguments.ImportMode, + importArguments.DryRun, + importArguments.PrunePaths, + importArguments.PrunePaths ? importTargetProjectRoot : null, + ReplacementWouldBeAllowed: true, + validationPhases, + DestinationDelta: destinationDelta, + UnknownExtensionFileCount: manifest.UnknownExtensionFileCount, + UnknownExtensionFiles: manifest.UnknownExtensionFiles, + UnknownExtensionFilesTruncated: manifest.UnknownExtensionFilesTruncated, + UnknownExtensionFilePathLimit: manifest.UnknownExtensionFilePathLimit, + UnknownExtensionFileSampleCount: manifest.UnknownExtensionFileSampleCount, + UnknownExtensionFileSampleLimit: manifest.UnknownExtensionFileSampleLimit, + UnknownExtensionFileSampleTruncated: manifest.UnknownExtensionFileSampleTruncated), + CliJsonSerializerContextFactory.Create(jsonOptions).ImportDryRunResult)); + } + else + { + Console.WriteLine(FormatImportSuccessMessage( + $"Validated CodeIndex archive {Path.GetFullPath(importArguments.ArchivePath)}; replacement would be allowed for {fullDbPath}{FormatDestinationDeltaSummary(destinationDelta)}", + importArguments.PrunePaths, + importTargetProjectRoot)); + } + + return CommandExitCodes.Success; + } + + private static int WriteImportResult( + ImportArguments importArguments, + JsonSerializerOptions jsonOptions, + string fullDbPath, + string importTargetProjectRoot, + IReadOnlyList validationPhases, + ExportManifest manifest) + { + if (importArguments.WantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + new ImportResult( + "1", + "success", + Path.GetFullPath(importArguments.ArchivePath), + fullDbPath, + importArguments.ImportMode, + DryRun: false, + importArguments.PrunePaths, + importArguments.PrunePaths ? importTargetProjectRoot : null, + validationPhases, + UnknownExtensionFileCount: manifest.UnknownExtensionFileCount, + UnknownExtensionFiles: manifest.UnknownExtensionFiles, + UnknownExtensionFilesTruncated: manifest.UnknownExtensionFilesTruncated, + UnknownExtensionFilePathLimit: manifest.UnknownExtensionFilePathLimit, + UnknownExtensionFileSampleCount: manifest.UnknownExtensionFileSampleCount, + UnknownExtensionFileSampleLimit: manifest.UnknownExtensionFileSampleLimit, + UnknownExtensionFileSampleTruncated: manifest.UnknownExtensionFileSampleTruncated), + jsonOptions)); + } + else + { + Console.WriteLine(FormatImportSuccessMessage( + $"Imported CodeIndex database to {fullDbPath}", + importArguments.PrunePaths, + importTargetProjectRoot)); + } + + return CommandExitCodes.Success; + } +} diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index d87e6c792..825545fa2 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -81,774 +81,4 @@ public static int RunExport( return RunExportArchive(args, jsonOptions, appVersion, cancellationToken); } - public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, CancellationToken cancellationToken = default) - { - var parseResult = ParseImportArguments(args, jsonOptions); - var importArguments = parseResult.Arguments; - if (importArguments == null) - return parseResult.ExitCode; - - var archivePath = importArguments.ArchivePath; - var wantsJson = importArguments.WantsJson; - var prunePaths = importArguments.PrunePaths; - var importMode = importArguments.ImportMode; - var dryRun = importArguments.DryRun; - var limit = importArguments.Limit; - var offset = importArguments.Offset; - var dbPath = importArguments.DbPath - ?? DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; - var fullDbPath = Path.GetFullPath(DbPathResolver.NormalizeDbPath(dbPath)); - var importTargetProjectRoot = ResolveImportTargetProjectRoot(fullDbPath); - var dbDirectory = Path.GetDirectoryName(fullDbPath); - if (string.IsNullOrWhiteSpace(dbDirectory)) - return WriteImportError(wantsJson, jsonOptions, PhaseParseArgs, "import_db_directory_unresolved", $"could not resolve destination DB directory for `{dbPath}`.", "pass an explicit `--db `.", ImportUsage); - - string? tempDirectory = null; - string? tempPath = null; - ExportManifest? importedManifest = null; - var validationPhases = new List(); - var phase = PhaseOpenArchive; - try - { - cancellationToken.ThrowIfCancellationRequested(); - if (dryRun) - { - tempDirectory = DataDirectorySecurity.CreateSensitiveTempDirectory("codeindex-import-").FullName; - tempPath = Path.Combine(tempDirectory, "codeindex.db"); - } - else - { - Directory.CreateDirectory(dbDirectory); - tempPath = Path.Combine(dbDirectory, $".codeindex-import-{Guid.NewGuid():N}.db"); - } - - using (var archive = ZipFile.OpenRead(archivePath)) - { - cancellationToken.ThrowIfCancellationRequested(); - AddImportValidationPhase(validationPhases, PhaseOpenArchive); - if (!TryValidateImportArchiveEntries( - archive, - out var manifestEntry, - out var dbEntry, - out var entryValidationPhase, - out var entryValidationErrorCode, - out var entryValidationMessage)) - { - return WriteImportError( - wantsJson, - jsonOptions, - entryValidationPhase, - entryValidationErrorCode, - entryValidationMessage, - "use an archive produced by `cdidx export `.", - ImportUsage); - } - - phase = PhaseManifest; - if (!TryReadManifest(manifestEntry, jsonOptions, out var manifest, out var manifestError, cancellationToken)) - return WriteImportError(wantsJson, jsonOptions, PhaseManifest, "import_manifest_invalid", $"archive manifest is invalid: {manifestError}.", "use an archive produced by `cdidx export `.", ImportUsage); - if (!ExportImportManifestCodec.TryValidateHeader(manifest, out var manifestHeaderError)) - return WriteImportError(wantsJson, jsonOptions, PhaseManifest, "import_manifest_incompatible", $"archive manifest is invalid: {manifestHeaderError}.", "re-export from a compatible CodeIndex database.", ImportUsage); - importedManifest = manifest; - AddImportValidationPhase(validationPhases, PhaseManifest); - - phase = PhaseDatabaseEntry; - if (dbEntry == null) - return WriteImportError(wantsJson, jsonOptions, PhaseDatabaseEntry, "import_database_entry_missing", $"archive is missing {DatabaseEntryName}.", "use an archive produced by `cdidx export `.", ImportUsage); - if (!TryValidateDatabaseEntrySize(dbEntry.Length, dbEntry.CompressedLength, out var sizeValidationMessage)) - return WriteImportError(wantsJson, jsonOptions, PhaseDatabaseEntry, "import_database_entry_too_large", sizeValidationMessage, "re-export a smaller CodeIndex database or rebuild a smaller index.", ImportUsage); - - ExtractDatabaseEntryToFile(dbEntry, tempPath, cancellationToken); - AddImportValidationPhase(validationPhases, PhaseDatabaseEntry); - - phase = PhaseSha256; - if (!TryValidateImportedManifest(manifest, tempPath, out var manifestValidationMessage, out var manifestValidationPhase, cancellationToken)) - return WriteImportError(wantsJson, jsonOptions, manifestValidationPhase, "import_manifest_mismatch", $"archive manifest mismatch: {manifestValidationMessage}.", "re-export from a compatible CodeIndex database.", ImportUsage); - AddImportValidationPhase(validationPhases, PhaseSha256); - } - - phase = PhaseSqliteValidate; - if (!DbContext.TryValidateExistingCodeIndexDb( - tempPath, - requireWritable: true, - requireSupportedUserVersion: false, - out var validationMessage, - out _, - out _, - cancellationToken)) - return WriteImportError(wantsJson, jsonOptions, PhaseSqliteValidate, "import_database_invalid", $"archive database is invalid: {validationMessage}.", "re-export from a compatible CodeIndex database.", ImportUsage); - AddImportValidationPhase(validationPhases, PhaseSqliteValidate); - SqliteConnection.ClearAllPools(); - - if (prunePaths) - { - cancellationToken.ThrowIfCancellationRequested(); - phase = PhasePrunePaths; - RewriteImportedProjectRoot(tempPath, importTargetProjectRoot); - AddImportValidationPhase(validationPhases, PhasePrunePaths); - SqliteConnection.ClearAllPools(); - } - - if (dryRun) - { - phase = PhaseDestinationDelta; - var destinationDelta = BuildImportDestinationDelta( - fullDbPath, - tempPath, - Path.GetFullPath(archivePath), - limit, - offset, - cancellationToken); - AddImportValidationPhase( - validationPhases, - PhaseDestinationDelta, - destinationDelta.Comparable ? "success" : "unavailable", - destinationDelta.Message); - AddImportValidationPhase(validationPhases, PhaseReplaceDb, "skipped", $"{importMode} mode does not replace the destination database"); - var manifest = importedManifest ?? throw new InvalidDataException("archive manifest was not loaded"); - return WriteImportDryRunResult( - importArguments, - jsonOptions, - fullDbPath, - importTargetProjectRoot, - validationPhases, - destinationDelta, - manifest); - } - - phase = PhaseReplaceDb; - ReplaceImportedDatabase(tempPath, fullDbPath, cancellationToken); - AddImportValidationPhase(validationPhases, PhaseReplaceDb); - return WriteImportResult( - importArguments, - jsonOptions, - fullDbPath, - importTargetProjectRoot, - validationPhases, - importedManifest ?? throw new InvalidDataException("archive manifest was not loaded")); - } - catch (OperationCanceledException) - { - return WriteImportError( - wantsJson, - jsonOptions, - phase, - CommandErrorCodes.Interrupted, - "import cancelled before it could complete.", - "retry `cdidx import` after the cancelling operation completes.", - ImportUsage, - CommandExitCodes.CancelledBySignal); - } - catch (ImportReplacementException ex) - { - return WriteImportError( - wantsJson, - jsonOptions, - PhaseReplaceDb, - "import_replacement_failed", - $"import failed ({CommandErrorWriter.FormatSanitizedException(ex.InnerException ?? ex)}).", - "check destination database permissions and inspect diagnostics for residual replacement state.", - ImportUsage, - diagnostics: ex.Diagnostics); - } - catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or SqliteException) - { - return WriteImportError( - wantsJson, - jsonOptions, - phase, - "import_failed", - $"import failed ({CommandErrorWriter.FormatSanitizedException(ex)}).", - "check the archive path and destination database permissions.", - ImportUsage, - rootCause: ClassifyImportFailureRootCause(phase, ex)); - } - finally - { - if (tempPath != null) - { - TryDeleteFile(tempPath, "import temporary database"); - DeleteSqliteSidecars(tempPath, "import temporary database sidecar"); - } - if (tempDirectory != null) - TryDeleteDirectoryIfEmpty(tempDirectory, "import temporary directory", Path.GetTempPath(), "codeindex-import-"); - } - } - - private static ImportArgumentParseResult ParseImportArguments( - string[] args, - JsonSerializerOptions jsonOptions) - { - string? archivePath = null; - string? dbPath = null; - var wantsJson = Array.Exists(args, arg => arg == "--json"); - var prunePaths = false; - var importMode = "import"; - var dryRun = false; - var limit = DiffCommandRunner.DefaultDiffLimit; - var offset = 0; - var pagingOptionSpecified = false; - - ImportArgumentParseResult Fail(string errorCode, string message, string recommendedAction) - { - return new ImportArgumentParseResult( - Arguments: null, - WriteImportError( - wantsJson, - jsonOptions, - PhaseParseArgs, - errorCode, - message, - recommendedAction, - ImportUsage)); - } - - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (arg == "--json") - { - wantsJson = true; - continue; - } - if (arg == "--prune-paths") - { - prunePaths = true; - continue; - } - if (arg is "--dry-run" or "--check") - { - importMode = arg == "--check" ? "check" : "dry_run"; - dryRun = true; - continue; - } - - if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) - { - if (dbError != null) - return Fail("import_db_requires_value", dbError, "use `cdidx import --db `."); - dbPath = dbValue; - continue; - } - - if (TryReadValueOption(args, ref i, "--limit", arg, out var limitValue, out var limitError)) - { - pagingOptionSpecified = true; - if (limitError != null - || !int.TryParse(limitValue, NumberStyles.None, CultureInfo.InvariantCulture, out limit) - || limit < 0 - || limit > DiffCommandRunner.MaxDiffLimit) - { - return Fail( - "import_limit_invalid", - $"--limit requires an integer from 0 to {DiffCommandRunner.MaxDiffLimit}.", - "use `--limit 20` to bound destination delta samples."); - } - continue; - } - - if (TryReadValueOption(args, ref i, "--offset", arg, out var offsetValue, out var offsetError)) - { - pagingOptionSpecified = true; - if (offsetError != null - || !int.TryParse(offsetValue, NumberStyles.None, CultureInfo.InvariantCulture, out offset) - || offset < 0 - || offset > int.MaxValue - limit) - { - return Fail( - "import_offset_invalid", - "--offset requires a non-negative integer that can be combined with --limit.", - "use `--offset 0` for the first destination delta page."); - } - continue; - } - - if (arg.StartsWith("-", StringComparison.Ordinal)) - return Fail("import_unknown_option", $"unknown import option `{arg}`.", "use `cdidx import [--db ]`."); - - if (archivePath != null) - return Fail("import_extra_archive_path", $"import accepts exactly one archive path, got extra `{arg}`.", "remove the extra argument."); - archivePath = arg; - } - - if (string.IsNullOrWhiteSpace(archivePath)) - return Fail("import_archive_required", "import requires an archive path.", "pass an archive produced by `cdidx export `."); - if (pagingOptionSpecified && !dryRun) - return Fail("import_paging_requires_dry_run", "--limit and --offset are only valid with --dry-run or --check.", "add `--dry-run` to preview bounded destination deltas."); - if (offset > int.MaxValue - limit) - return Fail("import_offset_invalid", "--offset is too large for the requested --limit.", "choose a lower --offset."); - - return new ImportArgumentParseResult( - new ImportArguments(archivePath, dbPath, wantsJson, prunePaths, importMode, dryRun, limit, offset), - CommandExitCodes.Success); - } - - private static int WriteImportDryRunResult( - ImportArguments importArguments, - JsonSerializerOptions jsonOptions, - string fullDbPath, - string importTargetProjectRoot, - IReadOnlyList validationPhases, - ImportDestinationDeltaResult destinationDelta, - ExportManifest manifest) - { - if (importArguments.WantsJson) - { - Console.WriteLine(JsonSerializer.Serialize( - new ImportDryRunResult( - "1", - "success", - Path.GetFullPath(importArguments.ArchivePath), - fullDbPath, - importArguments.ImportMode, - importArguments.DryRun, - importArguments.PrunePaths, - importArguments.PrunePaths ? importTargetProjectRoot : null, - ReplacementWouldBeAllowed: true, - validationPhases, - DestinationDelta: destinationDelta, - UnknownExtensionFileCount: manifest.UnknownExtensionFileCount, - UnknownExtensionFiles: manifest.UnknownExtensionFiles, - UnknownExtensionFilesTruncated: manifest.UnknownExtensionFilesTruncated, - UnknownExtensionFilePathLimit: manifest.UnknownExtensionFilePathLimit, - UnknownExtensionFileSampleCount: manifest.UnknownExtensionFileSampleCount, - UnknownExtensionFileSampleLimit: manifest.UnknownExtensionFileSampleLimit, - UnknownExtensionFileSampleTruncated: manifest.UnknownExtensionFileSampleTruncated), - CliJsonSerializerContextFactory.Create(jsonOptions).ImportDryRunResult)); - } - else - { - Console.WriteLine(FormatImportSuccessMessage( - $"Validated CodeIndex archive {Path.GetFullPath(importArguments.ArchivePath)}; replacement would be allowed for {fullDbPath}{FormatDestinationDeltaSummary(destinationDelta)}", - importArguments.PrunePaths, - importTargetProjectRoot)); - } - - return CommandExitCodes.Success; - } - - private static int WriteImportResult( - ImportArguments importArguments, - JsonSerializerOptions jsonOptions, - string fullDbPath, - string importTargetProjectRoot, - IReadOnlyList validationPhases, - ExportManifest manifest) - { - if (importArguments.WantsJson) - { - Console.WriteLine(JsonSerializer.Serialize( - new ImportResult( - "1", - "success", - Path.GetFullPath(importArguments.ArchivePath), - fullDbPath, - importArguments.ImportMode, - DryRun: false, - importArguments.PrunePaths, - importArguments.PrunePaths ? importTargetProjectRoot : null, - validationPhases, - UnknownExtensionFileCount: manifest.UnknownExtensionFileCount, - UnknownExtensionFiles: manifest.UnknownExtensionFiles, - UnknownExtensionFilesTruncated: manifest.UnknownExtensionFilesTruncated, - UnknownExtensionFilePathLimit: manifest.UnknownExtensionFilePathLimit, - UnknownExtensionFileSampleCount: manifest.UnknownExtensionFileSampleCount, - UnknownExtensionFileSampleLimit: manifest.UnknownExtensionFileSampleLimit, - UnknownExtensionFileSampleTruncated: manifest.UnknownExtensionFileSampleTruncated), - jsonOptions)); - } - else - { - Console.WriteLine(FormatImportSuccessMessage( - $"Imported CodeIndex database to {fullDbPath}", - importArguments.PrunePaths, - importTargetProjectRoot)); - } - - return CommandExitCodes.Success; - } - - private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOptions, string appVersion, CancellationToken cancellationToken) - { - string? outputPath = null; - string? dbPath = null; - string? lang = null; - string? solution = null; - var pathPatterns = new List(); - var excludePathPatterns = new List(); - var projects = new List(); - var excludeTests = false; - var wantsJson = Array.Exists(args, arg => arg == "--json"); - - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (arg == "--json") - { - wantsJson = true; - continue; - } - if (arg == "--exclude-tests") - { - excludeTests = true; - continue; - } - - if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) - { - if (dbError != null) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_db_requires_value", dbError, "use `cdidx export --db `.", ArchiveExportUsage); - dbPath = dbValue; - continue; - } - - if (TryReadValueOption(args, ref i, "--lang", arg, out var langValue, out var langError)) - { - if (langError != null) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_lang_requires_value", langError, "pass a language name such as `csharp`, `cs`, or `python`.", ArchiveExportUsage); - lang = DbReader.NormalizeQueryLanguage(langValue); - continue; - } - - if (TryReadValueOption(args, ref i, "--path", arg, out var pathValue, out var pathError)) - { - if (pathError != null) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_path_requires_value", pathError, "pass a path substring or glob such as `src/` or `src/*.cs`.", ArchiveExportUsage); - pathPatterns.Add(pathValue!); - continue; - } - - if (TryReadValueOption(args, ref i, "--exclude-path", arg, out var excludePathValue, out var excludePathError)) - { - if (excludePathError != null) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_exclude_path_requires_value", excludePathError, "pass a path substring or glob to omit.", ArchiveExportUsage); - excludePathPatterns.Add(excludePathValue!); - continue; - } - - if (TryReadValueOption(args, ref i, "--project", arg, out var projectValue, out var projectError)) - { - if (projectError != null) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_project_requires_value", projectError, "pass a project name or project path.", ArchiveExportUsage); - projects.Add(projectValue!); - continue; - } - - if (TryReadValueOption(args, ref i, "--solution", arg, out var solutionValue, out var solutionError)) - { - if (solutionError != null) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_solution_requires_value", solutionError, "pass a solution path used to resolve project names.", ArchiveExportUsage); - solution = solutionValue; - continue; - } - - if (arg.StartsWith("-", StringComparison.Ordinal)) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_unknown_option", $"unknown export option `{arg}`.", "use archive scope flags or `cdidx export ctags`.", ArchiveExportUsage); - - if (outputPath != null) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_extra_archive_path", $"export accepts exactly one archive path, got extra `{arg}`.", "remove the extra argument.", ArchiveExportUsage); - outputPath = arg; - } - - if (string.IsNullOrWhiteSpace(outputPath)) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_archive_required", "export requires an output archive path.", "pass a destination such as `codeindex.cdidx.zip`, or use `cdidx export ctags`.", ArchiveExportUsage); - if (solution != null && projects.Count == 0) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_solution_requires_project", "--solution requires at least one --project filter.", "add `--project ` or remove `--solution`.", ArchiveExportUsage); - if (!TryValidateArchiveScopeValues(pathPatterns, excludePathPatterns, projects, solution, out var scopeValidationMessage)) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_scope_invalid", scopeValidationMessage, "reduce or shorten the archive scope values.", ArchiveExportUsage); - - dbPath ??= DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; - var normalizedDbPath = DbPathResolver.NormalizeDbPath(dbPath); - if (!DbContext.TryValidateExistingCodeIndexDb( - normalizedDbPath, - requireWritable: false, - requireSupportedUserVersion: false, - out var validationMessage, - out _, - out _)) - return WriteExportError(wantsJson, jsonOptions, PhaseSqliteValidate, "export_database_invalid", validationMessage, "run `cdidx index ` first or pass `--db `.", ArchiveExportUsage); - - var fullSourceDbPath = Path.GetFullPath(normalizedDbPath); - var fullOutputPath = Path.GetFullPath(outputPath); - if (IsDatabaseOrSqliteSidecarPath(fullOutputPath, fullSourceDbPath)) - { - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_archive_overlaps_database", "export archive path must not be the source database or a SQLite sidecar.", "choose a separate archive path, for example `codeindex.cdidx.zip`.", ArchiveExportUsage); - } - - var scopeOptions = new ArchiveExportOptions( - lang, - pathPatterns.ToArray(), - excludePathPatterns.ToArray(), - projects.ToArray(), - solution, - excludeTests); - string? snapshotDirectory = null; - string? snapshotPath = null; - var phase = PhaseWriteArchive; - try - { - cancellationToken.ThrowIfCancellationRequested(); - snapshotDirectory = DataDirectorySecurity.CreateSensitiveTempDirectory("codeindex-export-").FullName; - snapshotPath = Path.Combine(snapshotDirectory, "codeindex.db"); - var outputDirectory = Path.GetDirectoryName(fullOutputPath); - if (!string.IsNullOrWhiteSpace(outputDirectory)) - Directory.CreateDirectory(outputDirectory); - - phase = PhaseSqliteValidate; - CreateDatabaseSnapshot(normalizedDbPath, snapshotPath, cancellationToken); - ExportManifest manifest; - if (scopeOptions.IsScoped) - { - using var snapshotContext = new DbContext(DbOpenIntent.Migration, snapshotPath, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - snapshotContext.TryMigrateForRead(); - if (snapshotContext.LastMigrationFailure is { } migrationFailure) - { - throw new InvalidDataException( - $"export snapshot schema migration failed at {migrationFailure.Step}: {migrationFailure.SqliteMessage}"); - } - phase = PhaseScopeArchive; - var snapshotConnection = snapshotContext.Connection; - var scope = ApplyArchiveScope(snapshotConnection, scopeOptions, cancellationToken); - manifest = BuildManifest(snapshotConnection, appVersion, scope, cancellationToken); - } - else - { - using var snapshotConnection = new SqliteConnection(CreateUnpooledConnectionString(snapshotPath)); - cancellationToken.ThrowIfCancellationRequested(); - snapshotConnection.Open(); - phase = PhaseScopeArchive; - var scope = ApplyArchiveScope(snapshotConnection, scopeOptions, cancellationToken); - manifest = BuildManifest(snapshotConnection, appVersion, scope, cancellationToken); - } - SqliteConnection.ClearAllPools(); - phase = PhaseSha256; - manifest = manifest with { DatabaseSha256 = ComputeSha256(snapshotPath, cancellationToken) }; - phase = PhaseWriteArchive; - WriteExportArchiveFile(fullOutputPath, snapshotPath, manifest, jsonOptions, cancellationToken); - - if (wantsJson) - Console.WriteLine(JsonSerializer.Serialize( - new ExportArchiveResult( - "1", - fullOutputPath, - fullSourceDbPath, - manifest.Scope ?? throw new InvalidDataException("export scope metadata was not created")), - jsonOptions)); - else - Console.WriteLine($"Exported CodeIndex archive to {fullOutputPath}"); - return CommandExitCodes.Success; - } - catch (OperationCanceledException) - { - return WriteExportError( - wantsJson, - jsonOptions, - phase, - CommandErrorCodes.Interrupted, - "export cancelled before it could complete.", - "retry `cdidx export` after the cancelling operation completes.", - ArchiveExportUsage, - CommandExitCodes.CancelledBySignal); - } - catch (Exception ex) - { - return WriteExportError(wantsJson, jsonOptions, phase, "export_failed", $"export failed ({CommandErrorWriter.FormatSanitizedException(ex)}).", "check the database, scope, project, and output archive paths.", ArchiveExportUsage); - } - finally - { - if (snapshotPath != null) - { - TryDeleteFile(snapshotPath, "export temporary database"); - DeleteSqliteSidecars(snapshotPath, "export temporary database sidecar"); - } - if (snapshotDirectory != null) - TryDeleteDirectoryIfEmpty(snapshotDirectory, "export temporary directory", Path.GetTempPath(), "codeindex-export-"); - } - } - - private static int RunExportCtags(string[] args, JsonSerializerOptions jsonOptions) - { - var outputPath = "tags"; - string? dbPath = null; - string? lang = null; - var pathPatterns = new List(); - var excludePathPatterns = new List(); - var excludeTests = false; - var includeGenerated = false; - var wantsJson = Array.Exists(args, arg => arg == "--json"); - - for (var i = 0; i < args.Length; i++) - { - var arg = args[i]; - if (arg == "--json") - { - wantsJson = true; - continue; - } - if (arg == "--exclude-tests") - { - excludeTests = true; - continue; - } - if (arg == "--include-generated") - { - includeGenerated = true; - continue; - } - - if (TryReadValueOption(args, ref i, "--output", arg, out var outputValue, out var outputError)) - { - if (outputError != null) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_output_requires_value", outputError, "use `cdidx export ctags --output tags`.", CtagsExportUsage); - outputPath = outputValue!; - continue; - } - - if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) - { - if (dbError != null) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_db_requires_value", dbError, "use `cdidx export ctags --db `.", CtagsExportUsage); - dbPath = dbValue; - continue; - } - - if (TryReadValueOption(args, ref i, "--lang", arg, out var langValue, out var langError)) - { - if (langError != null) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_lang_requires_value", langError, "pass a language name such as `csharp`, `cs`, or `python`.", CtagsExportUsage); - lang = DbReader.NormalizeQueryLanguage(langValue); - continue; - } - - if (TryReadValueOption(args, ref i, "--path", arg, out var pathValue, out var pathError)) - { - if (pathError != null) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_path_requires_value", pathError, "pass a path substring or glob such as `src/` or `src/*.cs`.", CtagsExportUsage); - pathPatterns.Add(pathValue!); - continue; - } - - if (TryReadValueOption(args, ref i, "--exclude-path", arg, out var excludePathValue, out var excludePathError)) - { - if (excludePathError != null) - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_exclude_path_requires_value", excludePathError, "pass a path substring or glob to omit.", CtagsExportUsage); - excludePathPatterns.Add(excludePathValue!); - continue; - } - - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_unknown_option", $"unknown ctags export option `{arg}`.", "use `--output`, `--db`, `--json`, or filter flags such as `--include-generated`.", CtagsExportUsage); - } - - dbPath ??= DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; - var normalizedDbPath = DbPathResolver.NormalizeDbPath(dbPath); - var fullSourceDbPath = Path.GetFullPath(normalizedDbPath); - var fullOutputPath = Path.GetFullPath(outputPath); - if (IsDatabaseOrSqliteSidecarPath(fullOutputPath, fullSourceDbPath)) - { - return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "ctags_export_output_overlaps_database", "ctags output path must not be the source database or a SQLite sidecar.", "choose a separate tags path, for example `tags`.", CtagsExportUsage); - } - - if (!DbContext.TryValidateExistingCodeIndexDb( - normalizedDbPath, - requireWritable: false, - requireSupportedUserVersion: false, - out var validationMessage, - out _, - out _)) - return WriteExportError(wantsJson, jsonOptions, PhaseSqliteValidate, "ctags_export_database_invalid", validationMessage, "run `cdidx index ` first or pass `--db `.", CtagsExportUsage); - - try - { - using var db = new DbContext(DbOpenIntent.QueryOnly, normalizedDbPath); - var generatedFileFilterAvailable = DbSchemaCache.LoadColumns(db.Connection, "files").Contains("generated"); - var filters = new CtagsExportOptions( - lang, - pathPatterns.ToArray(), - excludePathPatterns.ToArray(), - excludeTests, - includeGenerated, - generatedFileFilterAvailable); - var outputDirectory = Path.GetDirectoryName(fullOutputPath); - if (!string.IsNullOrWhiteSpace(outputDirectory)) - Directory.CreateDirectory(outputDirectory); - - long emittedCount = 0; - var skipReasonCounts = wantsJson - ? CountCtagsSkipReasons(db.Connection, filters) - : null; - WriteCtagsFile(fullOutputPath, writer => - { - writer.WriteLine("!_TAG_FILE_FORMAT\t2\t/extended format/"); - writer.WriteLine("!_TAG_FILE_SORTED\t1\t/0=unsorted, 1=sorted, 2=foldcase/"); - - using var cmd = CreateCtagsSymbolCommand(db.Connection, filters); - using var reader = cmd.ExecuteReader(); - while (reader.Read()) - { - var name = SanitizeCtagsField(reader.GetString(0)); - var path = SanitizeCtagsField(reader.GetString(1)); - var line = Math.Max(1, reader.GetInt32(2)); - var kind = SanitizeCtagsField(reader.GetString(3)); - var tagLine = new StringBuilder() - .Append(name) - .Append('\t') - .Append(path) - .Append('\t') - .Append(line.ToString(CultureInfo.InvariantCulture)) - .Append(";\"\tkind:") - .Append(kind) - .Append("\tline:") - .Append(line.ToString(CultureInfo.InvariantCulture)); - AppendCtagsExtensionField(tagLine, "language", ExportImportSqliteRow.ReadNullableString(reader, 4)); - AppendCtagsExtensionField(tagLine, "container_kind", ExportImportSqliteRow.ReadNullableString(reader, 5)); - AppendCtagsExtensionField(tagLine, "container", ExportImportSqliteRow.ReadNullableString(reader, 6)); - AppendCtagsExtensionField(tagLine, "visibility", ExportImportSqliteRow.ReadNullableString(reader, 7)); - writer.WriteLine(tagLine.ToString()); - emittedCount++; - } - }); - - if (wantsJson) - { - var skippedCount = skipReasonCounts!.Values.Sum(); - var totalTagCount = emittedCount + skippedCount; - var result = new CtagsExportResult( - "1", - "success", - fullOutputPath, - fullSourceDbPath, - totalTagCount, - emittedCount, - skippedCount, - skipReasonCounts, - new CtagsExportFilterResult( - filters.Lang, - filters.PathPatterns, - filters.ExcludePathPatterns, - filters.ExcludeTests, - filters.IncludeGenerated, - filters.IncludeGenerated - ? "include" - : filters.GeneratedFileFilterAvailable - ? "exclude" - : "unavailable", - filters.GeneratedFileFilterAvailable), - ["kind", "line", "language", "container_kind", "container", "visibility"]); - Console.WriteLine(JsonSerializer.Serialize( - result, - CliJsonSerializerContextFactory.Create(jsonOptions).CtagsExportResult)); - } - else - { - Console.WriteLine($"Exported ctags to {fullOutputPath}"); - } - return CommandExitCodes.Success; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SqliteException) - { - return WriteExportError(wantsJson, jsonOptions, PhaseWriteCtags, "ctags_export_failed", $"ctags export failed ({CommandErrorWriter.FormatSanitizedException(ex)}).", "check the database and output paths.", CtagsExportUsage); - } - } - } From 9302a31e5f270b61e894ce8748788112fed1fa74 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 23:08:37 +0900 Subject: [PATCH 082/101] Separate index readiness finalization --- .../IndexCommandRunner.FullScan.Readiness.cs | 261 ++++++++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 247 ++++------------- .../IndexCommandRunner.Update.Readiness.cs | 197 +++++++++++++ .../Cli/IndexCommandRunner.Update.cs | 222 +++------------ 4 files changed, 548 insertions(+), 379 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.Readiness.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.Update.Readiness.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Readiness.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Readiness.cs new file mode 100644 index 000000000..bd749dcc2 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Readiness.cs @@ -0,0 +1,261 @@ +using System.Diagnostics; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class FullScanReadinessContext + { + internal required DbWriter Writer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required Stopwatch Stopwatch { get; init; } + internal required DateTime RunStartedAtUtc { get; init; } + internal required string ProjectRoot { get; init; } + internal string? CurrentHeadCommit { get; init; } + internal List? IndexRunDiagnostics { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required int Errors { get; init; } + internal required List FileErrorList { get; init; } + internal required int Processed { get; init; } + internal required int FileCount { get; init; } + internal required int Skipped { get; init; } + internal required int Purged { get; init; } + internal required bool ScanHadErrors { get; init; } + internal required bool StartedWithNoIndexedFiles { get; init; } + internal required bool HasCSharpFilesAfter { get; init; } + internal required bool CSharpSourceEvidenceComplete { get; init; } + internal required bool CSharpSourceEvidenceForStamp { get; init; } + internal required bool PreservePriorPositiveCSharpSourceNoOp { get; init; } + internal required bool CSharpMetadataTargetsNeedRefresh { get; init; } + internal required bool TypeScriptAugmentationNeedsRefresh { get; init; } + internal DbWriter.TypeScriptAugmentationDirtyNameScope? TypeScriptAugmentationDirtyNames { get; init; } + internal required bool UseScopedTypeScriptAugmentationRefresh { get; init; } + internal required IReadOnlyDictionary LanguageCounts { get; init; } + internal HashSet? ReusedHotspotFamilyLanguages { get; init; } + internal required IReadOnlyDictionary PriorHotspotFamilyVersions { get; init; } + internal required IReadOnlyDictionary PriorHotspotFamilyMarkerFingerprints { get; init; } + internal required IReadOnlyDictionary CurrentHotspotFamilyMarkerFingerprints { get; init; } + internal required IReadOnlyCollection IndexedSymbolExtractorLanguages { get; init; } + internal HashSet? SkippedSymbolExtractorLanguages { get; init; } + internal string? PriorFoldVersion { get; init; } + internal string? PriorFoldFingerprint { get; init; } + internal required FileIndexer.ScanFilesResult ScanResult { get; init; } + internal required ReadableFileByteTracker ReadableFileBytes { get; init; } + internal required List MemorySamples { get; init; } + internal required long FreshCountReferences { get; init; } + internal required Action WriteProjectRootOnce { get; init; } + } + + private sealed record FullScanReadinessResult( + bool GraphTableAvailable, + bool IssuesTableAvailable, + bool CSharpSymbolNameReady, + bool CSharpMetadataTargetReady, + bool FoldReady, + string? FoldReadyReason, + long FreshCountReferences); + + private static FullScanReadinessResult FinalizeFullScanReadiness( + FullScanReadinessContext context) + { + var writer = context.Writer; + var options = context.Options; + var cancellationToken = context.CancellationToken; + var graphTableAvailableAfter = false; + var issuesTableAvailableAfter = false; + var csharpSymbolNameReadyAfter = !context.HasCSharpFilesAfter; + var csharpMetadataTargetReadyAfter = !context.HasCSharpFilesAfter; + var foldReadyAfter = false; + string? foldReadyReasonAfter = null; + var freshCountReferences = context.FreshCountReferences; + + if (context.Errors > 0) + { + if (!options.SymbolsOnly) + { + writer.MarkGraphReady(); + graphTableAvailableAfter = true; + } + writer.MarkIndexIncomplete(["file_index_error"]); + writer.SetMetaValues( + (DbContext.LastFailedIndexRunStatusMetaKey, "partial"), + (DbContext.LastFailedIndexRunModeMetaKey, options.Rebuild ? "rebuild" : "incremental"), + (DbContext.LastFailedIndexRunStartedAtMetaKey, context.RunStartedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunDurationMsMetaKey, context.Stopwatch.ElapsedMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunFilesProcessedMetaKey, context.Processed.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunFilesTotalMetaKey, context.FileCount.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunErrorCodeMetaKey, CommandErrorCodes.IndexPartial), + (DbContext.LastFailedIndexRunReasonMetaKey, "file_index_error"), + (DbContext.LastFailedIndexRunProgressPersistedMetaKey, true.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunRecoveryHintMetaKey, "Fix the reported file/extractor error, then rerun the same index command. Successful files and graph edges remain persisted; a rebuild is not required."), + (DbContext.LastFailedIndexRunFileErrorsMetaKey, JsonSerializer.Serialize(context.FileErrorList, StatusMetadataJsonContext.Default.ListStatusIndexFileError))); + } + + if (context.Errors == 0) + { + writer.MarkIssuesReady(); + if (!options.SymbolsOnly) + { + writer.MarkGraphReady(); + writer.MarkHdlGraphContractReady(); + } + writer.MarkIndexReaderContractsReady(options.SymbolsOnly); + if (!options.SymbolsOnly + && !context.ScanHadErrors + && context.CSharpSourceEvidenceComplete + && !context.PreservePriorPositiveCSharpSourceNoOp) + { + writer.SetCSharpStaticInterfaceSourceEvidence( + context.CSharpSourceEvidenceForStamp); + } + if (context.HasCSharpFilesAfter) + { + if (context.CSharpMetadataTargetsNeedRefresh) + { + FullScanCSharpMetadataResolveForTesting?.Invoke(); + writer.ResolveCSharpMetadataTargets(cancellationToken); + } + writer.MarkMetadataTargetReady("csharp"); + csharpMetadataTargetReadyAfter = true; + } + else + { + csharpMetadataTargetReadyAfter = true; + } + graphTableAvailableAfter = !options.SymbolsOnly; + issuesTableAvailableAfter = true; + csharpSymbolNameReadyAfter = true; + + if (!options.SymbolsOnly + && (context.TypeScriptAugmentationNeedsRefresh + || context.TypeScriptAugmentationDirtyNames?.RequiresRefresh == true)) + { + if (context.StartedWithNoIndexedFiles + && !context.LanguageCounts.ContainsKey("typescript")) + { + writer.MarkTypeScriptAugmentationReady(); + } + else + { + FullScanTypeScriptAugmentationRebuildForTesting?.Invoke(); + var augmentationReferences = writer.RebuildTypeScriptAugmentationReferences( + context.ProjectRoot, + context.UseScopedTypeScriptAugmentationRefresh + ? context.TypeScriptAugmentationDirtyNames?.DirtyNames + : null, + cancellationToken); + if (context.StartedWithNoIndexedFiles) + freshCountReferences += augmentationReferences; + } + } + RestampHotspotFamilyTrustForFullScan( + writer, + context.ReusedHotspotFamilyLanguages, + context.PriorHotspotFamilyVersions, + context.PriorHotspotFamilyMarkerFingerprints, + context.CurrentHotspotFamilyMarkerFingerprints); + writer.StampSymbolExtractorVersions(context.IndexedSymbolExtractorLanguages); + writer.StampDynamicReferenceGraphContracts(context.IndexedSymbolExtractorLanguages); + + IReadOnlyCollection skippedSymbolExtractorLanguageSet = + context.SkippedSymbolExtractorLanguages is null + ? Array.Empty() + : context.SkippedSymbolExtractorLanguages; + var currentFoldVersion = NameFold.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); + var currentFoldFingerprint = NameFold.Fingerprint(); + var foldVersionMatchesCurrent = context.PriorFoldVersion == currentFoldVersion; + var foldFingerprintMatchesCurrent = context.PriorFoldFingerprint == currentFoldFingerprint; + var canRestampExistingFoldTrust = foldVersionMatchesCurrent + && foldFingerprintMatchesCurrent + && writer.SymbolExtractorVersionsMatchCurrent(skippedSymbolExtractorLanguageSet); + if (context.Skipped == 0 || canRestampExistingFoldTrust) + { + var foldStampResult = writer.MarkFoldReadyWithResult( + stampCurrentSymbolExtractorVersions: context.Skipped == 0, + symbolExtractorLanguagesToStamp: + context.Skipped == 0 ? context.IndexedSymbolExtractorLanguages : null); + foldReadyAfter = foldStampResult == FoldReadyStampResult.Ready; + if (foldStampResult == FoldReadyStampResult.MissingBackfill) + { + foldReadyReasonAfter = GetFoldReadyReason( + false, + foldVersionMatchesCurrent, + foldFingerprintMatchesCurrent); + } + else if (foldStampResult == FoldReadyStampResult.NonCurrentFoldValues) + { + foldReadyReasonAfter = DegradationReasonCodes.FoldRowsNotRestamped; + } + } + else + { + var backfillReady = + writer.AllFoldedColumnsBackfilled(skippedSymbolExtractorLanguageSet); + foldReadyReasonAfter = GetFoldReadyReason( + backfillReady, + foldVersionMatchesCurrent, + foldFingerprintMatchesCurrent); + } + + StampWriterVersionAndSymbolKindFilter( + writer, + ConsoleUi.LoadVersion(), + options.SymbolKindFilter.Signature); + context.WriteProjectRootOnce(); + writer.WriteUnknownExtensionFileMetadata(context.ScanResult.UnknownExtensionFiles); + var currentHeadBranch = + GitHelper.TryGetHeadBranch(context.ProjectRoot, cancellationToken); + var lastFullScanElapsedMs = context.Stopwatch.ElapsedMilliseconds.ToString( + System.Globalization.CultureInfo.InvariantCulture); + writer.SetMetaValues( + (DbContext.IndexedHeadCommitMetaKey, context.CurrentHeadCommit), + (DbContext.IndexedHeadCommitBranchMetaKey, currentHeadBranch), + (DbContext.LastFullScanElapsedMsMetaKey, lastFullScanElapsedMs)); + TryStampIndexedHeadMetadata( + writer, + context.CurrentHeadCommit, + currentHeadBranch, + context.IndexRunDiagnostics); + StampWorkspacePathCaseSensitivity( + writer, + context.ProjectRoot, + context.IndexRunDiagnostics, + cancellationToken); + StampIndexedSymlinkPolicy( + writer, + options.SymlinkPolicy, + context.IndexRunDiagnostics); + if (options.MemoryTrace) + context.MemorySamples.Add(CaptureMemorySample("finalize", context.Stopwatch)); + var memoryTimelineForStamp = BuildMemoryTimeline(context.MemorySamples); + var bytesRead = context.ReadableFileBytes.MeasureRemaining(); + StampLastIndexRunMetadata( + writer, + options.Rebuild ? "rebuild" : "incremental", + context.RunStartedAtUtc, + context.Stopwatch.ElapsedMilliseconds, + context.FileCount, + context.Skipped, + context.Errors, + bytesRead.BytesRead, + bytesRead.SkippedFileCount, + context.Processed, + context.Purged, + memoryTimelineForStamp, + context.IndexRunDiagnostics, + writer.GetReferenceExtractionCapHits(issuesTableAvailableAfter)); + } + + return new FullScanReadinessResult( + graphTableAvailableAfter, + issuesTableAvailableAfter, + csharpSymbolNameReadyAfter, + csharpMetadataTargetReadyAfter, + foldReadyAfter, + foldReadyReasonAfter, + freshCountReferences); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index a25938c75..865db5729 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -2313,207 +2313,54 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis writer.SetCSharpStaticInterfaceSourceEvidence(true); } } - if (errors > 0) + var readiness = FinalizeFullScanReadiness(new FullScanReadinessContext { - if (!options.SymbolsOnly) - { - // Keep successfully committed graph generations queryable while the separate - // completeness/currentness signals remain false for the failed-file coverage. - writer.MarkGraphReady(); - graphTableAvailableAfter = true; - } - writer.MarkIndexIncomplete(["file_index_error"]); - writer.SetMetaValues( - (DbContext.LastFailedIndexRunStatusMetaKey, "partial"), - (DbContext.LastFailedIndexRunModeMetaKey, options.Rebuild ? "rebuild" : "incremental"), - (DbContext.LastFailedIndexRunStartedAtMetaKey, runStartedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunDurationMsMetaKey, stopwatch.ElapsedMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunFilesProcessedMetaKey, processed.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunFilesTotalMetaKey, files.Count.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunErrorCodeMetaKey, CommandErrorCodes.IndexPartial), - (DbContext.LastFailedIndexRunReasonMetaKey, "file_index_error"), - (DbContext.LastFailedIndexRunProgressPersistedMetaKey, true.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunRecoveryHintMetaKey, "Fix the reported file/extractor error, then rerun the same index command. Successful files and graph edges remain persisted; a rebuild is not required."), - (DbContext.LastFailedIndexRunFileErrorsMetaKey, JsonSerializer.Serialize(fileErrorList, StatusMetadataJsonContext.Default.ListStatusIndexFileError))); - } - if (errors == 0) - { - // Full-scan covers the whole repo, so it may always stamp Graph / Issues on - // success regardless of what the DB carried before. Fold still gates on the - // backfill verification below because incremental-by-default full scans skip - // unchanged legacy files whose folded columns remain NULL. - // full-scan は全repo をカバーするため、Graph / Issues は常に stamp。Fold のみ条件付き。 - writer.MarkIssuesReady(); - if (!options.SymbolsOnly) - { - writer.MarkGraphReady(); - writer.MarkHdlGraphContractReady(); - } - writer.MarkIndexReaderContractsReady(options.SymbolsOnly); - if (!options.SymbolsOnly - && !scanHadErrors - && csharpSourceEvidenceComplete - && !preservePriorPositiveCSharpSourceNoOp) - writer.SetCSharpStaticInterfaceSourceEvidence(csharpSourceEvidenceForStamp); - if (hasCSharpFilesAfter) - { - if (csharpMetadataTargetsNeedRefresh) - { - FullScanCSharpMetadataResolveForTesting?.Invoke(); - writer.ResolveCSharpMetadataTargets(cancellationToken); - } - writer.MarkMetadataTargetReady("csharp"); - csharpMetadataTargetReadyAfter = true; - } - else - { - csharpMetadataTargetReadyAfter = true; - } - graphTableAvailableAfter = !options.SymbolsOnly; - issuesTableAvailableAfter = true; - csharpSymbolNameReadyAfter = true; - if (!options.SymbolsOnly) - { - if (typeScriptAugmentationNeedsRefresh - || typeScriptAugmentationDirtyNames?.RequiresRefresh == true) - { - if (startedWithNoIndexedFiles && !languageCounts.ContainsKey("typescript")) - { - writer.MarkTypeScriptAugmentationReady(); - } - else - { - FullScanTypeScriptAugmentationRebuildForTesting?.Invoke(); - var augmentationReferences = writer.RebuildTypeScriptAugmentationReferences( - projectRoot, - useScopedTypeScriptAugmentationRefresh - ? typeScriptAugmentationDirtyNames?.DirtyNames - : null, - cancellationToken); - if (startedWithNoIndexedFiles) - freshCountReferences += augmentationReferences; - } - } - } - RestampHotspotFamilyTrustForFullScan( - writer, - reusedHotspotFamilyLanguages, - priorHotspotFamilyVersions, - priorHotspotFamilyMarkerFingerprints, - currentHotspotFamilyMarkerFingerprints); - // Extractor versions describe rows regenerated during this successful run and - // must not depend on whether the independent fold-key contract can be restamped. - // extractor version は今回再生成した row の契約であり、独立した fold-key - // 契約を restamp できるかどうかに依存させない。 - writer.StampSymbolExtractorVersions(indexedSymbolExtractorLanguages); - writer.StampDynamicReferenceGraphContracts(indexedSymbolExtractorLanguages); - // FoldReady must reflect reality (#86). Full-scan is INCREMENTAL by default — it - // skips unchanged files via GetUnchangedFileId, so a legacy DB's pre-#86 rows - // keep NULL name_folded / *_folded values. Stamping FoldReady anyway would flip - // readers onto the folded-equality path and silently miss those rows. Verify - // every existing row has its folded column populated before stamping, and tell - // the user how to upgrade when not (only --rebuild / a truly-fresh index can - // guarantee 100% backfill on a legacy DB). - // fold は実検証が通ったときだけ stamp。legacy DB で skip された行は NULL のため、 - // 黙って stamp すると reader が fold 経路で legacy 行を見逃す。codex #86 レビュー。 - IReadOnlyCollection skippedSymbolExtractorLanguageSet = skippedSymbolExtractorLanguages is null - ? Array.Empty() - : skippedSymbolExtractorLanguages; - var currentFoldVersion = NameFold.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); - var currentFoldFingerprint = NameFold.Fingerprint(); - var foldVersionMatchesCurrent = priorFoldVersion == currentFoldVersion; - var foldFingerprintMatchesCurrent = priorFoldFingerprint == currentFoldFingerprint; - var canRestampExistingFoldTrust = foldVersionMatchesCurrent - && foldFingerprintMatchesCurrent - && writer.SymbolExtractorVersionsMatchCurrent(skippedSymbolExtractorLanguageSet); - // A normal `index .` run still skips unchanged files. If the prior fold metadata - // is stale, those skipped rows keep the old physical folded keys, so stamping the - // NEW metadata for the whole DB would silently misadvertise trust. Only stamp when - // every row was regenerated this run (skipped==0) or when the carried metadata is - // already known-good for the current runtime, even if user_version was cleared by - // an interrupted refresh before MarkFoldReady ran. Issue #97 codex review. - // 通常の `index .` は unchanged 行を skip するため、事前 metadata が stale なら - // skipped 行は旧 key のまま残る。全件再生成済み(skipped==0)か、事前 metadata が - // current と一致しているときだけ FoldReady を stamp する。途中中断で - // user_version だけ落ちた current DB もここで回復させる。 - if (skipped == 0 || canRestampExistingFoldTrust) - { - // Validate once inside BEGIN IMMEDIATE and retain the precise failure category. - // This avoids scanning every folded value before MarkFoldReady repeats the same - // work, while preserving the concurrent-writer safety from Issue #1535. - // BEGIN IMMEDIATE 内で一度だけ検証し、Issue #1535 の concurrent-writer safety と - // 失敗理由を維持しながら、stamp 前後の重複した全 folded-value scan を避ける。 - var foldStampResult = writer.MarkFoldReadyWithResult( - stampCurrentSymbolExtractorVersions: skipped == 0, - symbolExtractorLanguagesToStamp: skipped == 0 ? indexedSymbolExtractorLanguages : null); - foldReadyAfter = foldStampResult == FoldReadyStampResult.Ready; - if (foldStampResult == FoldReadyStampResult.MissingBackfill) - { - foldReadyReasonAfter = GetFoldReadyReason(false, foldVersionMatchesCurrent, foldFingerprintMatchesCurrent); - } - else if (foldStampResult == FoldReadyStampResult.NonCurrentFoldValues) - { - foldReadyReasonAfter = DegradationReasonCodes.FoldRowsNotRestamped; - } - } - else - { - var backfillReady = writer.AllFoldedColumnsBackfilled(skippedSymbolExtractorLanguageSet); - foldReadyReasonAfter = GetFoldReadyReason(backfillReady, foldVersionMatchesCurrent, foldFingerprintMatchesCurrent); - } - - StampWriterVersionAndSymbolKindFilter(writer, ConsoleUi.LoadVersion(), options.SymbolKindFilter.Signature); - - // Successful no-op full scans should repair stale / missing explicit-DB roots - // only after readiness stamps succeed, so an interruption cannot rewrite trust - // metadata ahead of the success markers. - // no-op full-scan の explicit DB root backfill は readiness stamp 後に限定する。 - WriteProjectRootOnce(); - writer.WriteUnknownExtensionFileMetadata(scanResult.UnknownExtensionFiles); - // Persist the current HEAD only after the run is fully successful (errors == 0). - // We deliberately only stamp on full scans (rebuild or default incremental). Update - // mode (`--commits` / `--files`) leaves the captured HEAD untouched so the next - // default scan can still detect "branch moved since the last full scan." A - // best-effort `null` from a non-git workspace simply clears the field. Issue #1508. - // フル成功時のみ HEAD を記録する。partial update は HEAD を触らないので、後続の - // full scan が「直近 full scan からブランチが動いた」をきちんと検知できる。 - // 非 git workspace で null になった場合はキーごとクリアされる。Issue #1508。 - var currentHeadBranch = GitHelper.TryGetHeadBranch(projectRoot, cancellationToken); - var lastFullScanElapsedMs = stopwatch.ElapsedMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture); - writer.SetMetaValues( - (DbContext.IndexedHeadCommitMetaKey, currentHeadCommit), - (DbContext.IndexedHeadCommitBranchMetaKey, currentHeadBranch), - (DbContext.LastFullScanElapsedMsMetaKey, lastFullScanElapsedMs)); - // #1509: also stamp the always-updated "last indexed HEAD" triple (SHA + branch + - // timestamp). Unlike #1508's IndexedHeadCommitMetaKey which only fires here on - // full scans, this triple is also stamped at the end of incremental update runs - // (see RunUpdateMode) so cross-session `commits_ahead_of_indexed_head` always - // reflects the true HEAD at the time of the most recent successful index. - // #1509: あらゆる成功 index の終端で更新する HEAD トリプル (SHA + branch + 時刻) も - // ここで stamp する。full scan / partial update を問わず最新の HEAD を保存する。 - TryStampIndexedHeadMetadata(writer, currentHeadCommit, currentHeadBranch, indexRunDiagnostics); - StampWorkspacePathCaseSensitivity(writer, projectRoot, indexRunDiagnostics, cancellationToken); - StampIndexedSymlinkPolicy(writer, options.SymlinkPolicy, indexRunDiagnostics); - if (options.MemoryTrace) - memorySamples.Add(CaptureMemorySample("finalize", stopwatch)); - var memoryTimelineForStamp = BuildMemoryTimeline(memorySamples); - var bytesRead = readableFileBytes.MeasureRemaining(); - StampLastIndexRunMetadata( - writer, - options.Rebuild ? "rebuild" : "incremental", - runStartedAtUtc, - stopwatch.ElapsedMilliseconds, - files.Count, - skipped, - errors, - bytesRead.BytesRead, - bytesRead.SkippedFileCount, - processed, - purged, - memoryTimelineForStamp, - indexRunDiagnostics, - writer.GetReferenceExtractionCapHits(issuesTableAvailableAfter)); - } + Writer = writer, + Options = options, + Stopwatch = stopwatch, + RunStartedAtUtc = runStartedAtUtc, + ProjectRoot = projectRoot, + CurrentHeadCommit = currentHeadCommit, + IndexRunDiagnostics = indexRunDiagnostics, + CancellationToken = cancellationToken, + Errors = errors, + FileErrorList = fileErrorList, + Processed = processed, + FileCount = files.Count, + Skipped = skipped, + Purged = purged, + ScanHadErrors = scanHadErrors, + StartedWithNoIndexedFiles = startedWithNoIndexedFiles, + HasCSharpFilesAfter = hasCSharpFilesAfter, + CSharpSourceEvidenceComplete = csharpSourceEvidenceComplete, + CSharpSourceEvidenceForStamp = csharpSourceEvidenceForStamp, + PreservePriorPositiveCSharpSourceNoOp = preservePriorPositiveCSharpSourceNoOp, + CSharpMetadataTargetsNeedRefresh = csharpMetadataTargetsNeedRefresh, + TypeScriptAugmentationNeedsRefresh = typeScriptAugmentationNeedsRefresh, + TypeScriptAugmentationDirtyNames = typeScriptAugmentationDirtyNames, + UseScopedTypeScriptAugmentationRefresh = useScopedTypeScriptAugmentationRefresh, + LanguageCounts = languageCounts, + ReusedHotspotFamilyLanguages = reusedHotspotFamilyLanguages, + PriorHotspotFamilyVersions = priorHotspotFamilyVersions, + PriorHotspotFamilyMarkerFingerprints = priorHotspotFamilyMarkerFingerprints, + CurrentHotspotFamilyMarkerFingerprints = currentHotspotFamilyMarkerFingerprints, + IndexedSymbolExtractorLanguages = indexedSymbolExtractorLanguages, + SkippedSymbolExtractorLanguages = skippedSymbolExtractorLanguages, + PriorFoldVersion = priorFoldVersion, + PriorFoldFingerprint = priorFoldFingerprint, + ScanResult = scanResult, + ReadableFileBytes = readableFileBytes, + MemorySamples = memorySamples, + FreshCountReferences = freshCountReferences, + WriteProjectRootOnce = WriteProjectRootOnce, + }); + graphTableAvailableAfter = readiness.GraphTableAvailable; + issuesTableAvailableAfter = readiness.IssuesTableAvailable; + csharpSymbolNameReadyAfter = readiness.CSharpSymbolNameReady; + csharpMetadataTargetReadyAfter = readiness.CSharpMetadataTargetReady; + foldReadyAfter = readiness.FoldReady; + foldReadyReasonAfter = readiness.FoldReadyReason; + freshCountReferences = readiness.FreshCountReferences; hotspotAggregateRefresh.Complete(cancellationToken); writer.ClearBatchInProgress(); fullScanTxn.Commit(); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.Readiness.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.Readiness.cs new file mode 100644 index 000000000..52bf0d406 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.Readiness.cs @@ -0,0 +1,197 @@ +using System.Diagnostics; +using System.Text.Json; +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class UpdateReadinessContext + { + internal required DbWriter Writer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required Stopwatch Stopwatch { get; init; } + internal required DateTime RunStartedAtUtc { get; init; } + internal required string ProjectRoot { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required int PriorReadiness { get; init; } + internal string? PriorFoldVersion { get; init; } + internal string? PriorFoldFingerprint { get; init; } + internal required string CurrentFoldVersion { get; init; } + internal required string CurrentFoldFingerprint { get; init; } + internal required bool PriorSymbolExtractorVersionsMatchCurrent { get; init; } + internal required bool CSharpSymbolNameContractMatchesCurrent { get; init; } + internal required bool PriorMetadataTargetCsharpMatchesCurrent { get; init; } + internal required bool SqlGraphContractMatchesCurrent { get; init; } + internal required bool HdlGraphContractMatchesCurrent { get; init; } + internal required IReadOnlyDictionary PriorHotspotFamilyVersions { get; init; } + internal required IReadOnlyDictionary PriorHotspotFamilyMarkerFingerprints { get; init; } + internal required IReadOnlyDictionary CurrentHotspotFamilyMarkerFingerprints { get; init; } + internal required bool ReadinessDemoted { get; init; } + internal required bool MutualRecursionRefreshNeeded { get; init; } + internal required bool ReferenceIdentityContractMatchedBeforeMutation { get; init; } + internal required bool CSharpMetadataTargetsNeedRefresh { get; init; } + internal required bool TypeScriptAugmentationNeedsRefresh { get; init; } + internal DbWriter.TypeScriptAugmentationDirtyNameScope? TypeScriptAugmentationDirtyNames { get; init; } + internal required bool UseScopedTypeScriptAugmentationRefresh { get; init; } + internal required int Updated { get; init; } + internal required int Removed { get; init; } + internal required int Skipped { get; init; } + internal required int TargetCount { get; init; } + internal required int Errors { get; init; } + internal required List FileErrorList { get; init; } + internal required IReadOnlyList FullyRefreshedDynamicGraphLanguages { get; init; } + } + + private sealed record UpdateReadinessResult( + bool GraphTableAvailable, + bool IssuesTableAvailable, + bool CSharpSymbolNameReady, + bool CSharpMetadataTargetReady, + bool FoldReady, + string? FoldReadyReason); + + private static UpdateReadinessResult FinalizeUpdateReadiness(UpdateReadinessContext context) + { + var writer = context.Writer; + var options = context.Options; + var cancellationToken = context.CancellationToken; + var hasCSharpFilesAfter = writer.HasAnyFilesWithLanguage("csharp"); + var hasSqlFilesAfter = writer.HasAnyFilesWithLanguage("sql"); + var graphTableAvailableAfter = !context.ReadinessDemoted + ? (context.PriorReadiness & DbContext.GraphReadyFlag) != 0 + : false; + var issuesTableAvailableAfter = !context.ReadinessDemoted + ? (context.PriorReadiness & DbContext.IssuesReadyFlag) != 0 + : false; + var csharpSymbolNameReadyAfter = !hasCSharpFilesAfter + || (!context.ReadinessDemoted && context.CSharpSymbolNameContractMatchesCurrent); + var csharpMetadataTargetReadyAfter = !hasCSharpFilesAfter + || (!context.ReadinessDemoted && context.PriorMetadataTargetCsharpMatchesCurrent); + var foldReadyAfter = !context.ReadinessDemoted + && (context.PriorReadiness & DbContext.FoldReadyFlag) != 0 + && context.PriorFoldVersion == context.CurrentFoldVersion + && context.PriorFoldFingerprint == context.CurrentFoldFingerprint + && context.PriorSymbolExtractorVersionsMatchCurrent; + string? foldReadyReasonAfter = foldReadyAfter + ? null + : GetFoldReadyReason( + (context.PriorReadiness & DbContext.FoldReadyFlag) != 0, + context.PriorFoldVersion == context.CurrentFoldVersion, + context.PriorFoldFingerprint == context.CurrentFoldFingerprint); + + if (context.Errors > 0) + { + if (!options.SymbolsOnly && (context.PriorReadiness & DbContext.GraphReadyFlag) != 0) + { + writer.MarkGraphReady(); + graphTableAvailableAfter = true; + } + writer.MarkIndexIncomplete(["file_index_error"]); + writer.SetMetaValues( + (DbContext.LastFailedIndexRunStatusMetaKey, "partial"), + (DbContext.LastFailedIndexRunModeMetaKey, "update"), + (DbContext.LastFailedIndexRunStartedAtMetaKey, context.RunStartedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunDurationMsMetaKey, context.Stopwatch.ElapsedMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunFilesProcessedMetaKey, (context.Updated + context.Removed + context.Skipped).ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunFilesTotalMetaKey, context.TargetCount.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunErrorCodeMetaKey, CommandErrorCodes.IndexPartial), + (DbContext.LastFailedIndexRunReasonMetaKey, "file_index_error"), + (DbContext.LastFailedIndexRunProgressPersistedMetaKey, true.ToString(System.Globalization.CultureInfo.InvariantCulture)), + (DbContext.LastFailedIndexRunRecoveryHintMetaKey, "Fix the reported file/extractor error, then rerun the same index command. Successful files and graph edges remain persisted; a rebuild is not required."), + (DbContext.LastFailedIndexRunFileErrorsMetaKey, JsonSerializer.Serialize(context.FileErrorList, StatusMetadataJsonContext.Default.ListStatusIndexFileError))); + } + + if (context.ReadinessDemoted && context.Errors == 0) + { + writer.MarkBatchInProgress(); + using var readinessTxn = writer.BeginTransaction(cancellationToken, "update readiness restamp"); + if ((context.PriorReadiness & DbContext.GraphReadyFlag) != 0) + { + writer.MarkGraphReady(); + graphTableAvailableAfter = true; + } + if (!options.SymbolsOnly + && !context.MutualRecursionRefreshNeeded + && context.ReferenceIdentityContractMatchedBeforeMutation) + { + writer.MarkReferenceIdentityContractReady(); + } + writer.StampSymbolExtractorVersions(context.FullyRefreshedDynamicGraphLanguages); + writer.StampDynamicReferenceGraphContracts(context.FullyRefreshedDynamicGraphLanguages); + if ((context.PriorReadiness & DbContext.IssuesReadyFlag) != 0) + { + writer.MarkIssuesReady(); + issuesTableAvailableAfter = true; + } + if (context.SqlGraphContractMatchesCurrent || !hasSqlFilesAfter) + writer.MarkSqlGraphContractReady(); + var hasHdlFilesAfter = writer.HasAnyFilesWithLanguage("verilog") + || writer.HasAnyFilesWithLanguage("systemverilog") + || writer.HasAnyFilesWithLanguage("vhdl"); + if (context.HdlGraphContractMatchesCurrent || !hasHdlFilesAfter) + writer.MarkHdlGraphContractReady(); + if (context.CSharpSymbolNameContractMatchesCurrent || !hasCSharpFilesAfter) + { + writer.MarkCSharpSymbolNameContractReady(); + csharpSymbolNameReadyAfter = true; + } + if (hasCSharpFilesAfter) + { + if (context.CSharpMetadataTargetsNeedRefresh) + { + UpdateCSharpMetadataResolveForTesting?.Invoke(); + writer.ResolveCSharpMetadataTargets(cancellationToken); + } + writer.MarkMetadataTargetReady("csharp"); + csharpMetadataTargetReadyAfter = true; + } + else + { + csharpMetadataTargetReadyAfter = true; + } + + using (var hotspotFamilyTxn = writer.BeginTransaction(cancellationToken, "update hotspot-family restamp")) + { + if (!options.SymbolsOnly + && (context.TypeScriptAugmentationNeedsRefresh + || context.TypeScriptAugmentationDirtyNames?.RequiresRefresh == true)) + { + UpdateTypeScriptAugmentationRebuildForTesting?.Invoke(); + writer.RebuildTypeScriptAugmentationReferences( + context.ProjectRoot, + context.UseScopedTypeScriptAugmentationRefresh + ? context.TypeScriptAugmentationDirtyNames?.DirtyNames + : null, + cancellationToken); + } + RestampHotspotFamilyTrustForUpdate( + writer, + context.PriorHotspotFamilyVersions, + context.PriorHotspotFamilyMarkerFingerprints, + context.CurrentHotspotFamilyMarkerFingerprints); + HotspotFamilyUpdateRestampReadyForCommitForTesting?.Invoke(); + hotspotFamilyTxn.Commit(); + } + if ((context.PriorReadiness & DbContext.FoldReadyFlag) != 0 + && context.PriorFoldVersion == context.CurrentFoldVersion + && context.PriorFoldFingerprint == context.CurrentFoldFingerprint + && context.PriorSymbolExtractorVersionsMatchCurrent) + { + foldReadyAfter = writer.MarkFoldReady(); + } + StampWriterVersionAndSymbolKindFilter(writer, ConsoleUi.LoadVersion(), options.SymbolKindFilter.Signature); + writer.ClearBatchInProgress(); + readinessTxn.Commit(); + } + + return new UpdateReadinessResult( + graphTableAvailableAfter, + issuesTableAvailableAfter, + csharpSymbolNameReadyAfter, + csharpMetadataTargetReadyAfter, + foldReadyAfter, + foldReadyReasonAfter); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index db2991507..df81b880a 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -1912,186 +1912,50 @@ or IndexInterruptedException }; } } - // Only stamp readiness on a fully successful run (errors == 0). A partial / error - // run leaves the DB unstamped so readers correctly treat graph / issues data as - // degraded rather than authoritative. Interrupted runs also stay unstamped because - // readiness was demoted before the first committed mutation. - // errors==0 の成功 run のみマーカーを打つ。途中失敗は未 stamp のままで縮退扱い。 - var hasCSharpFilesAfter = writer.HasAnyFilesWithLanguage("csharp"); - var hasSqlFilesAfter = writer.HasAnyFilesWithLanguage("sql"); - var graphTableAvailableAfter = !readinessDemoted - ? (priorReadiness & DbContext.GraphReadyFlag) != 0 - : false; - var issuesTableAvailableAfter = !readinessDemoted - ? (priorReadiness & DbContext.IssuesReadyFlag) != 0 - : false; - var csharpSymbolNameReadyAfter = !hasCSharpFilesAfter - || (!readinessDemoted && csharpSymbolNameContractMatchesCurrent); - var csharpMetadataTargetReadyAfter = !hasCSharpFilesAfter - || (!readinessDemoted && priorMetadataTargetCsharpMatchesCurrent); - var foldReadyAfter = !readinessDemoted - && (priorReadiness & DbContext.FoldReadyFlag) != 0 - && priorFoldVersion == currentFoldVersion - && priorFoldFingerprint == currentFoldFingerprint - && priorSymbolExtractorVersionsMatchCurrent; - string? foldReadyReasonAfter = foldReadyAfter - ? null - : GetFoldReadyReason( - (priorReadiness & DbContext.FoldReadyFlag) != 0, - priorFoldVersion == currentFoldVersion, - priorFoldFingerprint == currentFoldFingerprint); - if (errors > 0) - { - if (!options.SymbolsOnly && (priorReadiness & DbContext.GraphReadyFlag) != 0) - { - // Successful file transactions remain a useful graph generation. Keep the - // presence signal while completeness/currentness identifies the failed files. - writer.MarkGraphReady(); - graphTableAvailableAfter = true; - } - writer.MarkIndexIncomplete(["file_index_error"]); - writer.SetMetaValues( - (DbContext.LastFailedIndexRunStatusMetaKey, "partial"), - (DbContext.LastFailedIndexRunModeMetaKey, "update"), - (DbContext.LastFailedIndexRunStartedAtMetaKey, runStartedAtUtc.ToString("o", System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunDurationMsMetaKey, stopwatch.ElapsedMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunFilesProcessedMetaKey, (updated + removed + skipped).ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunFilesTotalMetaKey, targetPaths.Count.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunErrorCodeMetaKey, CommandErrorCodes.IndexPartial), - (DbContext.LastFailedIndexRunReasonMetaKey, "file_index_error"), - (DbContext.LastFailedIndexRunProgressPersistedMetaKey, true.ToString(System.Globalization.CultureInfo.InvariantCulture)), - (DbContext.LastFailedIndexRunRecoveryHintMetaKey, "Fix the reported file/extractor error, then rerun the same index command. Successful files and graph edges remain persisted; a rebuild is not required."), - (DbContext.LastFailedIndexRunFileErrorsMetaKey, JsonSerializer.Serialize(fileErrorList, StatusMetadataJsonContext.Default.ListStatusIndexFileError))); - } - var fullyRefreshedDynamicGraphLanguages = errors == 0 && readinessDemoted - ? GetFullyRefreshedDynamicGraphLanguages() - : []; - if (readinessDemoted && errors == 0) + var readiness = FinalizeUpdateReadiness(new UpdateReadinessContext { - writer.MarkBatchInProgress(); - using var readinessTxn = writer.BeginTransaction(cancellationToken, "update readiness restamp"); - // Restore each readiness bit independently based on what the DB carried BEFORE - // ClearReadyFlags wiped them. A pre-#86 DB (user_version=3, i.e. Graph+Issues but - // no Fold) must keep Graph+Issues after a successful partial update, even though - // FoldReady can't be restamped. Codex #86 second-pass review: the old single-flag - // `wasFullyReady` gate silently dropped Graph/Issues for the whole workspace on - // such DBs, breaking references/callers/callees/impact. - // Fold is the only bit that needs the runtime verify: the other two only require - // that the DB previously reached end-of-run for those subsystems. Fold also - // requires name_folded to be populated for every row, but the invariant holds - // when the prior bit was set AND this update rewrote its touched rows with - // name_folded populated, so no extra scan is needed here. - // update mode は事前 bit を個別に復元。Graph/Issues は prior bit があれば復元、 - // Fold も prior bit があれば invariant を信じて restamp(codex 2nd review 対応)。 - // unreadable ignore file の true no-op skip は ClearReadyFlags 自体を避けるので、 - // ここでは通常どおり errors==0 の成功 run だけを復元対象にする。 - if ((priorReadiness & DbContext.GraphReadyFlag) != 0) - { - writer.MarkGraphReady(); - graphTableAvailableAfter = true; - } - if (!options.SymbolsOnly - && !mutualRecursionRefreshNeeded - && referenceIdentityContractMatchedBeforeMutation) - { - writer.MarkReferenceIdentityContractReady(); - } - // A scoped update can certify a per-language graph contract only when every - // remaining file in that language was regenerated by this run. This covers a - // newly introduced language and an explicitly complete language refresh without - // hiding stale graph rows from untouched files of the same language. - // scoped update では、対象言語の現存ファイルを今回すべて再生成した場合だけ - // graph contract を stamp する。新規言語と全件更新を復元しつつ、未更新の - // stale row を current と誤認しない。 - writer.StampSymbolExtractorVersions(fullyRefreshedDynamicGraphLanguages); - writer.StampDynamicReferenceGraphContracts(fullyRefreshedDynamicGraphLanguages); - if ((priorReadiness & DbContext.IssuesReadyFlag) != 0) - { - writer.MarkIssuesReady(); - issuesTableAvailableAfter = true; - } - if (sqlGraphContractMatchesCurrent || !hasSqlFilesAfter) - writer.MarkSqlGraphContractReady(); - var hasHdlFilesAfter = writer.HasAnyFilesWithLanguage("verilog") - || writer.HasAnyFilesWithLanguage("systemverilog") - || writer.HasAnyFilesWithLanguage("vhdl"); - if (hdlGraphContractMatchesCurrent || !hasHdlFilesAfter) - writer.MarkHdlGraphContractReady(); - if (csharpSymbolNameContractMatchesCurrent || !hasCSharpFilesAfter) - { - writer.MarkCSharpSymbolNameContractReady(); - csharpSymbolNameReadyAfter = true; - } - // Issue #435: run the metadata-target resolver across all currently-indexed C# - // class rows. This is always safe because the resolver classifies every row and - // rewrites only values that differ from the authoritative result, so - // legacy NULL rows from a pre-#435 DB and untouched rows from this partial - // update both end up authoritative. Only stamp readiness when the resolver - // actually ran (i.e. there are C# files to resolve). - // Issue #435: 成功 update の末尾で全 csharp class 行を resolver で再分類する。 - // resolver は全行を再分類し、authoritative な結果と異なる値だけを書き直すため、 - // pre-#435 DB の NULL 行と未更新行の両方を正規化できる。csharp ファイルがある場合のみ readiness も立てる。 - if (hasCSharpFilesAfter) - { - if (csharpMetadataTargetsNeedRefresh) - { - UpdateCSharpMetadataResolveForTesting?.Invoke(); - writer.ResolveCSharpMetadataTargets(cancellationToken); - } - writer.MarkMetadataTargetReady("csharp"); - csharpMetadataTargetReadyAfter = true; - } - else - { - csharpMetadataTargetReadyAfter = true; - } - // Keep hotspot-family maintenance rewrites and readiness restamps in one rollback - // boundary. If the process dies after SetMeta but before commit, SQLite rolls back - // the version stamp along with any maintenance rows, so readers never see a partial - // family_key/container_qualified_name state as authoritative (#1488). - using (var hotspotFamilyTxn = writer.BeginTransaction(cancellationToken, "update hotspot-family restamp")) - { - if (!options.SymbolsOnly - && (typeScriptAugmentationNeedsRefresh - || typeScriptAugmentationDirtyNames?.RequiresRefresh == true)) - { - UpdateTypeScriptAugmentationRebuildForTesting?.Invoke(); - writer.RebuildTypeScriptAugmentationReferences( - projectRoot, - useScopedTypeScriptAugmentationRefresh - ? typeScriptAugmentationDirtyNames?.DirtyNames - : null, - cancellationToken); - } - RestampHotspotFamilyTrustForUpdate( - writer, - priorHotspotFamilyVersions, - priorHotspotFamilyMarkerFingerprints, - currentHotspotFamilyMarkerFingerprints); - HotspotFamilyUpdateRestampReadyForCommitForTesting?.Invoke(); - hotspotFamilyTxn.Commit(); - } - // FoldReady restamp requires both the prior stored version and fingerprint to - // match the current binary/runtime. Otherwise untouched rows still carry keys - // from an older fold implementation or runtime table set, and advertising - // FoldReady would silently mismatch on --exact. Only full rebuild can re-fold all rows. - // fold は version / fingerprint の両一致時のみ restamp。ズレた DB は rebuild まで - // fold_ready=false のまま残す。 - if ((priorReadiness & DbContext.FoldReadyFlag) != 0 - && priorFoldVersion == currentFoldVersion - && priorFoldFingerprint == currentFoldFingerprint - && priorSymbolExtractorVersionsMatchCurrent) - { - // MarkFoldReady re-verifies inside BEGIN IMMEDIATE; a concurrent NULL-folded - // insert during this restamp window leaves foldReadyAfter=false. Issue #1535. - // MarkFoldReady は BEGIN IMMEDIATE 内で再検証する。restamp 窓の concurrent - // 書き込みで NULL 行が残った場合は foldReadyAfter=false のまま。Issue #1535。 - foldReadyAfter = writer.MarkFoldReady(); - } - StampWriterVersionAndSymbolKindFilter(writer, ConsoleUi.LoadVersion(), options.SymbolKindFilter.Signature); - writer.ClearBatchInProgress(); - readinessTxn.Commit(); - } + Writer = writer, + Options = options, + Stopwatch = stopwatch, + RunStartedAtUtc = runStartedAtUtc, + ProjectRoot = projectRoot, + CancellationToken = cancellationToken, + PriorReadiness = priorReadiness, + PriorFoldVersion = priorFoldVersion, + PriorFoldFingerprint = priorFoldFingerprint, + CurrentFoldVersion = currentFoldVersion, + CurrentFoldFingerprint = currentFoldFingerprint, + PriorSymbolExtractorVersionsMatchCurrent = priorSymbolExtractorVersionsMatchCurrent, + CSharpSymbolNameContractMatchesCurrent = csharpSymbolNameContractMatchesCurrent, + PriorMetadataTargetCsharpMatchesCurrent = priorMetadataTargetCsharpMatchesCurrent, + SqlGraphContractMatchesCurrent = sqlGraphContractMatchesCurrent, + HdlGraphContractMatchesCurrent = hdlGraphContractMatchesCurrent, + PriorHotspotFamilyVersions = priorHotspotFamilyVersions, + PriorHotspotFamilyMarkerFingerprints = priorHotspotFamilyMarkerFingerprints, + CurrentHotspotFamilyMarkerFingerprints = currentHotspotFamilyMarkerFingerprints, + ReadinessDemoted = readinessDemoted, + MutualRecursionRefreshNeeded = mutualRecursionRefreshNeeded, + ReferenceIdentityContractMatchedBeforeMutation = referenceIdentityContractMatchedBeforeMutation, + CSharpMetadataTargetsNeedRefresh = csharpMetadataTargetsNeedRefresh, + TypeScriptAugmentationNeedsRefresh = typeScriptAugmentationNeedsRefresh, + TypeScriptAugmentationDirtyNames = typeScriptAugmentationDirtyNames, + UseScopedTypeScriptAugmentationRefresh = useScopedTypeScriptAugmentationRefresh, + Updated = updated, + Removed = removed, + Skipped = skipped, + TargetCount = targetPaths.Count, + Errors = errors, + FileErrorList = fileErrorList, + FullyRefreshedDynamicGraphLanguages = errors == 0 && readinessDemoted + ? GetFullyRefreshedDynamicGraphLanguages() + : [], + }); + var graphTableAvailableAfter = readiness.GraphTableAvailable; + var issuesTableAvailableAfter = readiness.IssuesTableAvailable; + var csharpSymbolNameReadyAfter = readiness.CSharpSymbolNameReady; + var csharpMetadataTargetReadyAfter = readiness.CSharpMetadataTargetReady; + var foldReadyAfter = readiness.FoldReady; + var foldReadyReasonAfter = readiness.FoldReadyReason; if (postExtractionHooks.ValueIfCreated?.SawCSharpStaticInterfaceSourceContract == true && !csharpWorkspaceDriftDetected) { From fda0edb109eae2d67a2984c9573869460b1b1357 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 23:17:34 +0900 Subject: [PATCH 083/101] Separate update file persistence --- ...dexCommandRunner.Update.FilePersistence.cs | 286 ++++++++++++++++++ .../Cli/IndexCommandRunner.Update.cs | 199 ++---------- 2 files changed, 315 insertions(+), 170 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.Update.FilePersistence.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.FilePersistence.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.FilePersistence.cs new file mode 100644 index 000000000..4dc216ec8 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.FilePersistence.cs @@ -0,0 +1,286 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class UpdateFilePersistenceContext + { + internal required DbWriter Writer { get; init; } + internal required FileIndexer Indexer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required string ProjectRoot { get; init; } + internal required string RelativePath { get; init; } + internal required string AbsolutePath { get; init; } + internal required FileRecord Record { get; init; } + internal required LoadedFileRecord Loaded { get; init; } + internal FileIssue? GeneratedSuppressionIssue { get; init; } + internal required CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace { get; init; } + internal required PostExtractionHookRunner PostExtractionHooks { get; init; } + internal required SymbolExtractionWorkerClient SymbolExtractionWorker { get; init; } + internal required bool ProjectRootWritten { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required Action RequireTypeScriptAugmentationRefresh { get; init; } + internal required Func PurgeStaleUpdateCleanupPaths { get; init; } + internal required Action WriteProjectRootOnce { get; init; } + internal required Action RecordDynamicGraphFileRefresh { get; init; } + internal required Action SetBatchMarkerOwned { get; init; } + internal required Action SetPhase { get; init; } + } + + private sealed record UpdateFilePersistenceResult( + int SymbolsDroppedByKindFilter, + bool MutualRecursionRefreshNeeded, + string VerboseMessage); + + private static UpdateFilePersistenceResult PersistUpdateFile( + UpdateFilePersistenceContext context) + { + var writer = context.Writer; + var options = context.Options; + var record = context.Record; + var loaded = context.Loaded; + var cancellationToken = context.CancellationToken; + var mutualRecursionRefreshNeeded = false; + var symbolsDroppedByKindFilter = 0; + + writer.MarkBatchInProgress(); + context.SetBatchMarkerOwned(true); + var recordRequiresTypeScriptAugmentationRefresh = record.Lang == "typescript"; + using var txn = writer.BeginTransaction(cancellationToken, "update file"); + if (recordRequiresTypeScriptAugmentationRefresh) + context.RequireTypeScriptAugmentationRefresh(); + var stalePurged = context.PurgeStaleUpdateCleanupPaths( + record.Path, + record.Checksum, + context.ProjectRootWritten); + if (stalePurged > 0) + { + context.RequireTypeScriptAugmentationRefresh(); + if (!options.SymbolsOnly) + mutualRecursionRefreshNeeded = true; + } + context.WriteProjectRootOnce(); + var fileId = writer.UpsertFile(record, out var referenceIdentityChanged); + if (!options.SymbolsOnly && referenceIdentityChanged) + mutualRecursionRefreshNeeded = true; + context.SetPhase(FormatIndexPhasePath(context.RelativePath, "chunking"), "chunking"); + var chunks = ChunkSplitter.SplitNormalized( + fileId, + loaded.Content, + loaded.HasOversizeLine, + record.Lines); + if (context.GeneratedSuppressionIssue != null) + { + writer.InsertChunks(chunks, cancellationToken); + writer.InsertSymbols([], cancellationToken); + writer.InsertReferencesInAtomicFileScope( + [], + refreshMutualRecursionFlags: false, + cancellationToken); + context.SetPhase( + FormatIndexPhasePath(context.RelativePath, "validating"), + "validating"); + var generatedIssues = AppendIssueIfMissing( + FileIndexer.ValidateContent( + record.Path, + loaded.RawBytes, + loaded.Content, + record.Lang, + loaded.Inspection, + loaded.HasOversizeLine, + loaded.ConflictMarkerLine), + context.GeneratedSuppressionIssue); + writer.InsertIssues(fileId, generatedIssues); + context.SetPhase( + FormatIndexPhasePath(context.RelativePath, "committing"), + "committing"); + writer.ClearBatchInProgress(); + txn.Commit(); + context.SetBatchMarkerOwned(false); + context.RecordDynamicGraphFileRefresh(record.Lang); + return new UpdateFilePersistenceResult( + 0, + mutualRecursionRefreshNeeded, + $" [OK ] {context.RelativePath} ({chunks.Count} chunks, generated-code extraction skipped)"); + } + + context.SetPhase(FormatIndexPhasePath(context.RelativePath, "symbols"), "symbols"); + var symbolExtraction = ExtractSymbolsWithStallTimeout( + fileId, + record.Lang, + loaded.Content, + context.AbsolutePath, + context.ProjectRoot, + record.Path, + FormatIndexPhasePath(context.RelativePath, "symbols"), + true, + loaded.HasOversizeLine, + loaded.ConflictMarkerLine, + context.SymbolExtractionWorker, + cancellationToken); + var symbols = symbolExtraction.Symbols; + var symbolRegexTimeoutIssue = symbolExtraction.RegexTimeoutIssue; + var fileContext = new FileContext( + context.ProjectRoot, + record.Path, + context.AbsolutePath, + record.Lang); + var sourceContractSeenBeforeObservation = + context.PostExtractionHooks.SawCSharpStaticInterfaceSourceContract; + context.PostExtractionHooks.ObserveCSharpStaticInterfaceSourceSymbols( + fileContext, + symbols); + if (record.Lang == "csharp" + && !context.CSharpWorkspace.HasSourceStaticInterfaceContracts + && !sourceContractSeenBeforeObservation + && context.PostExtractionHooks.SawCSharpStaticInterfaceSourceContract) + { + writer.SetCSharpStaticInterfaceSourceEvidence(null); + throw new CSharpWorkspaceChangedException( + "A C# static-interface contract appeared after workspace preflight."); + } + if (symbols.Count > options.MaxSymbolsPerFile) + { + var issue = BuildSymbolCountExceededIssue( + record.Path, + symbols.Count, + options.MaxSymbolsPerFile); + IReadOnlyList capIssues = symbolRegexTimeoutIssue == null + ? [issue] + : AppendIssue([symbolRegexTimeoutIssue], issue); + writer.InsertSymbols([], cancellationToken); + writer.InsertReferencesInAtomicFileScope( + [], + refreshMutualRecursionFlags: false, + cancellationToken); + writer.InsertIssues(fileId, capIssues); + writer.ClearBatchInProgress(); + txn.Commit(); + context.SetBatchMarkerOwned(false); + context.RecordDynamicGraphFileRefresh(record.Lang); + return new UpdateFilePersistenceResult( + 0, + mutualRecursionRefreshNeeded, + $" [SKIP] {context.RelativePath} ({issue.Message})"); + } + + SymbolExtractor.ApplyFamilyScope( + symbols, + context.Indexer.GetFamilyScopeKey(context.AbsolutePath, record.Lang)); + context.PostExtractionHooks.OnSymbolsExtractedAfterSourceObservation( + fileContext, + symbols); + symbolsDroppedByKindFilter = options.SymbolKindFilter.Apply(symbols); + if (symbols.Count > options.MaxSymbolsPerFile) + { + var issue = BuildSymbolCountExceededIssue( + record.Path, + symbols.Count, + options.MaxSymbolsPerFile); + IReadOnlyList capIssues = symbolRegexTimeoutIssue == null + ? [issue] + : AppendIssue([symbolRegexTimeoutIssue], issue); + writer.InsertSymbols([], cancellationToken); + writer.InsertReferencesInAtomicFileScope( + [], + refreshMutualRecursionFlags: false, + cancellationToken); + writer.InsertIssues(fileId, capIssues); + writer.ClearBatchInProgress(); + txn.Commit(); + context.SetBatchMarkerOwned(false); + context.RecordDynamicGraphFileRefresh(record.Lang); + return new UpdateFilePersistenceResult( + symbolsDroppedByKindFilter, + mutualRecursionRefreshNeeded, + $" [SKIP] {context.RelativePath} ({issue.Message})"); + } + + writer.InsertChunks(chunks, cancellationToken); + FileIndexer.ValidateSymbolLineRanges(record, symbols); + writer.InsertSymbols(symbols, cancellationToken); + context.SetPhase( + FormatIndexPhasePath(context.RelativePath, "references"), + "references"); + List references; + FileIssue? referenceRegexTimeoutIssue; + ReferenceExtractionResult referenceExtraction; + using (var regexTimeouts = BoundedRegex.CaptureTimeouts( + record.Lang, + "reference_extraction")) + { + referenceExtraction = ReferenceExtractor.ExtractDetailedNormalized( + fileId, + record.Lang, + loaded.Content, + loaded.HasOversizeLine, + symbols, + record.Path, + record.Lang == "csharp" ? context.CSharpWorkspace.Symbols : null, + cancellationToken, + maxReferenceCount: options.MaxReferencesPerFile + 1, + conflictMarkerLine: loaded.ConflictMarkerLine, + workspaceRoot: context.ProjectRoot, + csharpStaticInterfaceMemberLookups: + context.CSharpWorkspace.StaticInterfaceMemberLookups); + references = referenceExtraction.References; + referenceRegexTimeoutIssue = + BuildRegexTimeoutIssue(record.Path, regexTimeouts); + } + context.PostExtractionHooks.OnReferencesExtracted(fileContext, references); + FileIssue? referenceCapIssue = null; + if (references.Count > options.MaxReferencesPerFile) + { + referenceCapIssue = BuildReferenceCountExceededIssue( + record.Path, + references.Count, + options.MaxReferencesPerFile); + references = []; + } + writer.InsertReferencesInAtomicFileScope( + references, + refreshMutualRecursionFlags: false, + cancellationToken); + context.SetPhase( + FormatIndexPhasePath(context.RelativePath, "validating"), + "validating"); + IReadOnlyList issues = FileIndexer.ValidateContent( + record.Path, + loaded.RawBytes, + loaded.Content, + record.Lang, + loaded.Inspection, + loaded.HasOversizeLine, + loaded.ConflictMarkerLine); + if (symbolRegexTimeoutIssue != null) + issues = AppendIssue(issues, symbolRegexTimeoutIssue); + if (referenceRegexTimeoutIssue != null) + issues = AppendIssue(issues, referenceRegexTimeoutIssue); + issues = AppendReferenceExtractionDiagnosticIssues( + issues, + record.Path, + referenceExtraction.Diagnostics); + if (referenceCapIssue != null) + issues = AppendIssue(issues, referenceCapIssue); + writer.InsertIssues(fileId, issues); + context.SetPhase( + FormatIndexPhasePath(context.RelativePath, "committing"), + "committing"); + writer.ClearBatchInProgress(); + txn.Commit(); + context.SetBatchMarkerOwned(false); + context.RecordDynamicGraphFileRefresh(record.Lang); + if (!options.SymbolsOnly && (symbols.Count > 0 || references.Count > 0)) + mutualRecursionRefreshNeeded = true; + + return new UpdateFilePersistenceResult( + symbolsDroppedByKindFilter, + mutualRecursionRefreshNeeded, + $" [OK ] {context.RelativePath} ({chunks.Count} chunks, {symbols.Count} symbols, {references.Count} refs)"); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index df81b880a..4c8cbd618 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -1282,8 +1282,6 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) continue; } readableFileBytes.Remember(targetIndex, record.Size); - var content = loaded.Content; - var rawBytes = loaded.RawBytes; var warning = loaded.Warning; var generatedSuppressionIssue = generatedExtractionSuppressed ? indexer.BuildGeneratedCodeExtractionSkippedIssue(record.Path) @@ -1342,181 +1340,42 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) } DemoteReadinessOnce(); - writer.MarkBatchInProgress(); - fileBatchMarked = true; if (record.Lang == "csharp") csharpMetadataTargetsNeedRefresh = true; - var recordRequiresTypeScriptAugmentationRefresh = record.Lang == "typescript"; - using var txn = writer.BeginTransaction(cancellationToken, "update file"); - if (recordRequiresTypeScriptAugmentationRefresh) - RequireTypeScriptAugmentationRefresh(); - var stalePurged = PurgeStaleUpdateCleanupPaths( - record.Path, - record.Checksum, - includeDirectoryAndStem: projectRootWritten); - if (stalePurged > 0) - { - RequireTypeScriptAugmentationRefresh(); - if (!options.SymbolsOnly) - mutualRecursionRefreshNeeded = true; - } - WriteProjectRootOnce(); - var fileId = writer.UpsertFile(record, out var referenceIdentityChanged); - if (!options.SymbolsOnly && referenceIdentityChanged) - mutualRecursionRefreshNeeded = true; - currentUpdatePath = FormatIndexPhasePath(relPath, "chunking"); - currentUpdatePhase = "chunking"; - var chunks = ChunkSplitter.SplitNormalized(fileId, content, loaded.HasOversizeLine, record.Lines); - if (generatedSuppressionIssue != null) - { - writer.InsertChunks(chunks, cancellationToken); - writer.InsertSymbols([], cancellationToken); - writer.InsertReferencesInAtomicFileScope([], refreshMutualRecursionFlags: false, cancellationToken); - currentUpdatePath = FormatIndexPhasePath(relPath, "validating"); - currentUpdatePhase = "validating"; - var generatedIssues = AppendIssueIfMissing( - FileIndexer.ValidateContent(record.Path, rawBytes, content, record.Lang, loaded.Inspection, loaded.HasOversizeLine, loaded.ConflictMarkerLine), - generatedSuppressionIssue); - writer.InsertIssues(fileId, generatedIssues); - currentUpdatePath = FormatIndexPhasePath(relPath, "committing"); - currentUpdatePhase = "committing"; - writer.ClearBatchInProgress(); - txn.Commit(); - fileBatchMarked = false; - RecordDynamicGraphFileRefresh(record.Lang); - updated++; - ftsMutated = true; - updateProgress.WriteVerbose($" [OK ] {relPath} ({chunks.Count} chunks, generated-code extraction skipped)"); - continue; - } - currentUpdatePath = FormatIndexPhasePath(relPath, "symbols"); - currentUpdatePhase = "symbols"; - var symbolExtraction = ExtractSymbolsWithStallTimeout( - fileId, - record.Lang, - content, - absPath, - projectRoot, - record.Path, - currentUpdatePath, - true, - loaded.HasOversizeLine, - loaded.ConflictMarkerLine, - symbolExtractionWorker.Value, - cancellationToken); - var symbols = symbolExtraction.Symbols; - var symbolRegexTimeoutIssue = symbolExtraction.RegexTimeoutIssue; - var fileContext = new FileContext(projectRoot, record.Path, absPath, record.Lang); - var sourceContractSeenBeforeObservation = - postExtractionHooks.Value.SawCSharpStaticInterfaceSourceContract; - postExtractionHooks.Value.ObserveCSharpStaticInterfaceSourceSymbols(fileContext, symbols); - if (record.Lang == "csharp" - && !csharpWorkspace.HasSourceStaticInterfaceContracts - && !sourceContractSeenBeforeObservation - && postExtractionHooks.Value.SawCSharpStaticInterfaceSourceContract) - { - writer.SetCSharpStaticInterfaceSourceEvidence(null); - throw new CSharpWorkspaceChangedException( - "A C# static-interface contract appeared after workspace preflight."); - } - if (symbols.Count > options.MaxSymbolsPerFile) - { - var issue = BuildSymbolCountExceededIssue(record.Path, symbols.Count, options.MaxSymbolsPerFile); - IReadOnlyList capIssues = symbolRegexTimeoutIssue == null - ? [issue] - : AppendIssue([symbolRegexTimeoutIssue], issue); - writer.InsertSymbols([], cancellationToken); - writer.InsertReferencesInAtomicFileScope([], refreshMutualRecursionFlags: false, cancellationToken); - writer.InsertIssues(fileId, capIssues); - writer.ClearBatchInProgress(); - txn.Commit(); - fileBatchMarked = false; - RecordDynamicGraphFileRefresh(record.Lang); - updated++; - ftsMutated = true; - updateProgress.WriteVerbose($" [SKIP] {relPath} ({issue.Message})"); - continue; - } - SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(absPath, record.Lang)); - postExtractionHooks.Value.OnSymbolsExtractedAfterSourceObservation(fileContext, symbols); - symbolsDroppedByKindFilter += options.SymbolKindFilter.Apply(symbols); - if (symbols.Count > options.MaxSymbolsPerFile) - { - var issue = BuildSymbolCountExceededIssue(record.Path, symbols.Count, options.MaxSymbolsPerFile); - IReadOnlyList capIssues = symbolRegexTimeoutIssue == null - ? [issue] - : AppendIssue([symbolRegexTimeoutIssue], issue); - writer.InsertSymbols([], cancellationToken); - writer.InsertReferencesInAtomicFileScope([], refreshMutualRecursionFlags: false, cancellationToken); - writer.InsertIssues(fileId, capIssues); - writer.ClearBatchInProgress(); - txn.Commit(); - fileBatchMarked = false; - RecordDynamicGraphFileRefresh(record.Lang); - updated++; - ftsMutated = true; - updateProgress.WriteVerbose($" [SKIP] {relPath} ({issue.Message})"); - continue; - } - writer.InsertChunks(chunks, cancellationToken); - FileIndexer.ValidateSymbolLineRanges(record, symbols); - writer.InsertSymbols(symbols, cancellationToken); - currentUpdatePath = FormatIndexPhasePath(relPath, "references"); - currentUpdatePhase = "references"; - List references; - FileIssue? referenceRegexTimeoutIssue; - ReferenceExtractionResult referenceExtraction; - using (var regexTimeouts = BoundedRegex.CaptureTimeouts(record.Lang, "reference_extraction")) + var persistence = PersistUpdateFile(new UpdateFilePersistenceContext { - referenceExtraction = ReferenceExtractor.ExtractDetailedNormalized( - fileId, - record.Lang, - content, - loaded.HasOversizeLine, - symbols, - record.Path, - record.Lang == "csharp" ? csharpWorkspace.Symbols : null, - cancellationToken, - maxReferenceCount: options.MaxReferencesPerFile + 1, - conflictMarkerLine: loaded.ConflictMarkerLine, - workspaceRoot: projectRoot, - csharpStaticInterfaceMemberLookups: csharpWorkspace.StaticInterfaceMemberLookups); - references = referenceExtraction.References; - referenceRegexTimeoutIssue = BuildRegexTimeoutIssue(record.Path, regexTimeouts); - } - postExtractionHooks.Value.OnReferencesExtracted(fileContext, references); - FileIssue? referenceCapIssue = null; - if (references.Count > options.MaxReferencesPerFile) - { - referenceCapIssue = BuildReferenceCountExceededIssue(record.Path, references.Count, options.MaxReferencesPerFile); - references = []; - } - writer.InsertReferencesInAtomicFileScope(references, refreshMutualRecursionFlags: false, cancellationToken); - // Validate content for encoding issues / エンコーディング問題を検証 - currentUpdatePath = FormatIndexPhasePath(relPath, "validating"); - currentUpdatePhase = "validating"; - IReadOnlyList issues = FileIndexer.ValidateContent(record.Path, rawBytes, content, record.Lang, loaded.Inspection, loaded.HasOversizeLine, loaded.ConflictMarkerLine); - if (symbolRegexTimeoutIssue != null) - issues = AppendIssue(issues, symbolRegexTimeoutIssue); - if (referenceRegexTimeoutIssue != null) - issues = AppendIssue(issues, referenceRegexTimeoutIssue); - issues = AppendReferenceExtractionDiagnosticIssues(issues, record.Path, referenceExtraction.Diagnostics); - if (referenceCapIssue != null) - issues = AppendIssue(issues, referenceCapIssue); - writer.InsertIssues(fileId, issues); - currentUpdatePath = FormatIndexPhasePath(relPath, "committing"); - currentUpdatePhase = "committing"; - writer.ClearBatchInProgress(); - txn.Commit(); - - RecordDynamicGraphFileRefresh(record.Lang); + Writer = writer, + Indexer = indexer, + Options = options, + ProjectRoot = projectRoot, + RelativePath = relPath, + AbsolutePath = absPath, + Record = record, + Loaded = loaded, + GeneratedSuppressionIssue = generatedSuppressionIssue, + CSharpWorkspace = csharpWorkspace, + PostExtractionHooks = postExtractionHooks.Value, + SymbolExtractionWorker = symbolExtractionWorker.Value, + ProjectRootWritten = projectRootWritten, + CancellationToken = cancellationToken, + RequireTypeScriptAugmentationRefresh = RequireTypeScriptAugmentationRefresh, + PurgeStaleUpdateCleanupPaths = PurgeStaleUpdateCleanupPaths, + WriteProjectRootOnce = WriteProjectRootOnce, + RecordDynamicGraphFileRefresh = RecordDynamicGraphFileRefresh, + SetBatchMarkerOwned = owned => fileBatchMarked = owned, + SetPhase = (path, phase) => + { + currentUpdatePath = path; + currentUpdatePhase = phase; + }, + }); + symbolsDroppedByKindFilter += persistence.SymbolsDroppedByKindFilter; + mutualRecursionRefreshNeeded |= persistence.MutualRecursionRefreshNeeded; updated++; ftsMutated = true; - if (!options.SymbolsOnly && (symbols.Count > 0 || references.Count > 0)) - mutualRecursionRefreshNeeded = true; UpdateFileCommittedForTesting?.Invoke(updated + removed, targetPaths.Count); ThrowIfUpdateCancelled(); - updateProgress.WriteVerbose($" [OK ] {relPath} ({chunks.Count} chunks, {symbols.Count} symbols, {references.Count} refs)"); + updateProgress.WriteVerbose(persistence.VerboseMessage); } catch (IndexExtractionStalledException) { From 72f4f454f8b68e7ce7a06d5457db71b1949a5488 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 23:29:36 +0900 Subject: [PATCH 084/101] Separate full scan extraction pipeline --- ...ommandRunner.FullScan.ExtractionWorkers.cs | 365 +++++++++++ ...xCommandRunner.FullScan.FilePersistence.cs | 326 ++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 595 ++---------------- 3 files changed, 745 insertions(+), 541 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.ExtractionWorkers.cs create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.FilePersistence.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.ExtractionWorkers.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.ExtractionWorkers.cs new file mode 100644 index 000000000..d47024b60 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.ExtractionWorkers.cs @@ -0,0 +1,365 @@ +using System.Collections.Concurrent; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class FullScanExtractionWorkerContext + { + internal required FileIndexer Indexer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required string ProjectRoot { get; init; } + internal required FullScanFileTarget[] FileTargets { get; init; } + internal IReadOnlyList? ExtractionFileIndexes { get; init; } + internal required int ExtractionWorkItemCount { get; init; } + internal required int ExtractionWorkerCount { get; init; } + internal required bool ParallelizeExtraction { get; init; } + internal required CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace { get; init; } + internal Dictionary? CSharpWorkspaceFileSnapshots { get; init; } + internal required PostExtractionHookRunner PostExtractionHooks { get; init; } + internal required ActiveExtractionPhase?[] ActiveExtractionPhases { get; init; } + internal required BlockingCollection ExtractionResults { get; init; } + internal required CancellationToken ExtractionCancellationToken { get; init; } + internal required CancellationToken CancellationToken { get; init; } + } + + private static Task[] StartFullScanExtractionWorkers( + FullScanExtractionWorkerContext context) + { + var indexer = context.Indexer; + var options = context.Options; + var projectRoot = context.ProjectRoot; + var fileTargets = context.FileTargets; + var extractionFileIndexes = context.ExtractionFileIndexes; + var extractionWorkItemCount = context.ExtractionWorkItemCount; + var extractionWorkerCount = context.ExtractionWorkerCount; + var parallelizeExtraction = context.ParallelizeExtraction; + var csharpWorkspace = context.CSharpWorkspace; + var csharpWorkspaceFileSnapshots = context.CSharpWorkspaceFileSnapshots; + var postExtractionHooks = context.PostExtractionHooks; + var activeExtractionPhases = context.ActiveExtractionPhases; + var extractionResults = context.ExtractionResults; + var extractionCancellationToken = context.ExtractionCancellationToken; + var cancellationToken = context.CancellationToken; + var nextExtractionIndex = -1; + var workers = Enumerable.Range(0, extractionWorkerCount) + .Select(workerIndex => Task.Factory.StartNew(() => + { + using var workerSymbolExtractionWorker = new LazyDisposable( + () => new SymbolExtractionWorkerClient(options.MaxFileSizeBytes)); + while (true) + { + extractionCancellationToken.ThrowIfCancellationRequested(); + var extractionIndex = Interlocked.Increment(ref nextExtractionIndex); + if (extractionIndex >= extractionWorkItemCount) + break; + + var fileIndex = extractionFileIndexes == null + ? extractionIndex + : extractionFileIndexes[extractionIndex]; + var target = fileTargets[fileIndex]; + var filePath = target.FilePath; + var relativeFilePath = target.RelativePath; + var displayRelativePath = target.DisplayRelativePath; + try + { + Volatile.Write(ref activeExtractionPhases[workerIndex], new(displayRelativePath, "reading")); + FullScanFileContentLoadForTesting?.Invoke(displayRelativePath); + var loaded = indexer.BuildLoadedRecordWithRawBytes( + filePath, + relativeFilePath, + target.Language, + extractionCancellationToken); + var record = loaded.Record; + var workspaceFileSnapshots = csharpWorkspaceFileSnapshots; + if (target.Language == "csharp" + && workspaceFileSnapshots != null + && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + target.FilePath, + target.IndexPath, + target.DisplayRelativePath, + record.Size, + record.Modified, + workspaceFileSnapshots, + out var changedPath, + extractionCancellationToken)) + { + extractionResults.Add( + FullScanFileWorkItem.Failure( + fileIndex, + filePath, + displayRelativePath, + "csharp_workspace_validation", + new CSharpWorkspaceSnapshotDriftException( + FormatCSharpWorkspaceSnapshotPath(projectRoot, changedPath))), + extractionCancellationToken); + continue; + } + var content = loaded.Content; + var rawBytes = loaded.RawBytes; + var warning = loaded.Warning; + var hasOversizeLine = loaded.HasOversizeLine; + IReadOnlyList? chunks = null; + IReadOnlyList? symbols = null; + IReadOnlyList? references = null; + IReadOnlyList? issues = null; + var generatedSuppressionIssue = target.GeneratedExtractionSuppressed + ? indexer.BuildGeneratedCodeExtractionSkippedIssue(record.Path) + : null; + if (parallelizeExtraction) + { + Volatile.Write(ref activeExtractionPhases[workerIndex], new(record.Path, "chunking")); + chunks = ChunkSplitter.SplitNormalized(0, content, hasOversizeLine, record.Lines); + if (generatedSuppressionIssue != null) + { + symbols = []; + references = []; + Volatile.Write(ref activeExtractionPhases[workerIndex], new(record.Path, "validating")); + issues = AppendIssueIfMissing( + FileIndexer.ValidateContent(record.Path, rawBytes, content, record.Lang, loaded.Inspection, hasOversizeLine, loaded.ConflictMarkerLine), + generatedSuppressionIssue); + extractionResults.Add( + FullScanFileWorkItem.Precomputed( + fileIndex, + filePath, + displayRelativePath, + record, + warning, + chunks, + symbols, + references, + issues, + generatedSuppressionIssue, + generatedSuppressionChecked: true), + extractionCancellationToken); + continue; + } + Volatile.Write(ref activeExtractionPhases[workerIndex], new(record.Path, "symbols")); + FullScanFilePhaseForTesting?.Invoke(record.Path, "symbols"); + var symbolExtraction = ExtractSymbolsWithStallTimeout( + 0, + record.Lang, + content, + filePath, + projectRoot, + record.Path, + Volatile.Read(ref activeExtractionPhases[workerIndex])!.Format(), + true, + hasOversizeLine, + loaded.ConflictMarkerLine, + workerSymbolExtractionWorker.Value, + extractionCancellationToken); + symbols = symbolExtraction.Symbols; + var symbolRegexTimeoutIssue = symbolExtraction.RegexTimeoutIssue; + if (string.Equals(record.Lang, "csharp", StringComparison.Ordinal)) + { + var sourceFileContext = new FileContext( + projectRoot, + record.Path, + filePath, + record.Lang); + postExtractionHooks.ObserveCSharpStaticInterfaceSourceSymbols( + sourceFileContext, + symbols); + } + if (symbols.Count > options.MaxSymbolsPerFile) + { + var issue = BuildSymbolCountExceededIssue(record.Path, symbols.Count, options.MaxSymbolsPerFile); + IReadOnlyList capIssues = symbolRegexTimeoutIssue == null + ? [issue] + : AppendIssue([symbolRegexTimeoutIssue], issue); + extractionResults.Add( + FullScanFileWorkItem.Precomputed(fileIndex, filePath, displayRelativePath, record, issue.Message, [], [], [], capIssues), + extractionCancellationToken); + continue; + } + SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(filePath, record.Lang)); + FileIssue? referenceRegexTimeoutIssue = null; + ReferenceExtractionResult? referenceExtraction = null; + if (options.SymbolsOnly) + { + references = []; + } + else + { + Volatile.Write(ref activeExtractionPhases[workerIndex], new(record.Path, "references")); + FullScanFilePhaseForTesting?.Invoke(record.Path, "references"); + using var regexTimeouts = BoundedRegex.CaptureTimeouts(record.Lang, "reference_extraction"); + referenceExtraction = ReferenceExtractor.ExtractDetailedNormalized( + 0, + record.Lang, + content, + hasOversizeLine, + symbols, + record.Path, + record.Lang == "csharp" ? csharpWorkspace.Symbols : null, + extractionCancellationToken, + maxReferenceCount: options.MaxReferencesPerFile + 1, + conflictMarkerLine: loaded.ConflictMarkerLine, + workspaceRoot: projectRoot, + csharpStaticInterfaceMemberLookups: csharpWorkspace.StaticInterfaceMemberLookups); + references = referenceExtraction.References; + referenceRegexTimeoutIssue = BuildRegexTimeoutIssue(record.Path, regexTimeouts); + } + Volatile.Write(ref activeExtractionPhases[workerIndex], new(record.Path, "validating")); + issues = FileIndexer.ValidateContent(record.Path, rawBytes, content, record.Lang, loaded.Inspection, hasOversizeLine, loaded.ConflictMarkerLine); + if (symbolRegexTimeoutIssue != null) + issues = AppendIssue(issues, symbolRegexTimeoutIssue); + if (referenceRegexTimeoutIssue != null) + issues = AppendIssue(issues, referenceRegexTimeoutIssue); + if (referenceExtraction != null) + issues = AppendReferenceExtractionDiagnosticIssues(issues, record.Path, referenceExtraction.Diagnostics); + if (references.Count > options.MaxReferencesPerFile) + { + var issue = BuildReferenceCountExceededIssue(record.Path, references.Count, options.MaxReferencesPerFile); + references = []; + issues = AppendIssue(issues, issue); + } + } + else + { + Volatile.Write(ref activeExtractionPhases[workerIndex], new(record.Path, "validating")); + issues = FileIndexer.ValidateContent(record.Path, rawBytes, content, record.Lang, loaded.Inspection, hasOversizeLine, loaded.ConflictMarkerLine); + } + extractionResults.Add( + parallelizeExtraction + ? FullScanFileWorkItem.Precomputed( + fileIndex, + filePath, + displayRelativePath, + record, + warning, + chunks!, + symbols!, + references!, + issues!, + generatedSuppressionIssue, + generatedSuppressionChecked: true) + : FullScanFileWorkItem.Success( + fileIndex, + filePath, + displayRelativePath, + record, + content, + hasOversizeLine, + loaded.ConflictMarkerLine, + warning, + chunks, + symbols, + references, + issues, + generatedSuppressionIssue, + generatedSuppressionChecked: true), + extractionCancellationToken); + } + catch (OperationCanceledException) when (extractionCancellationToken.IsCancellationRequested) + { + throw; + } + catch (FileIndexer.BinaryFileSkippedException ex) + { + var record = indexer.BuildSkippedFileRecord(filePath, relativeFilePath, target.Language); + var workspaceFileSnapshots = csharpWorkspaceFileSnapshots; + if (target.Language == "csharp" + && workspaceFileSnapshots != null + && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + target.FilePath, + target.IndexPath, + target.DisplayRelativePath, + record.Size, + record.Modified, + workspaceFileSnapshots, + out var changedPath, + extractionCancellationToken)) + { + extractionResults.Add( + FullScanFileWorkItem.Failure( + fileIndex, + filePath, + displayRelativePath, + "csharp_workspace_validation", + new CSharpWorkspaceSnapshotDriftException( + FormatCSharpWorkspaceSnapshotPath(projectRoot, changedPath))), + extractionCancellationToken); + continue; + } + var issue = BuildNullByteIssue(ex); + var sanitizedMessage = CommandErrorWriter.FormatSanitizedExceptionMessage(ex); + extractionResults.Add( + FullScanFileWorkItem.Precomputed(fileIndex, filePath, displayRelativePath, record, sanitizedMessage, [], [], [], [issue]), + extractionCancellationToken); + } + catch (FileIndexer.FileTooLargeSkippedException ex) + { + var sanitizedMessage = CommandErrorWriter.FormatSanitizedExceptionMessage(ex); + var record = indexer.BuildSkippedFileRecord(filePath, relativeFilePath, target.Language); + var workspaceFileSnapshots = csharpWorkspaceFileSnapshots; + if (target.Language == "csharp" + && workspaceFileSnapshots != null + && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + target.FilePath, + target.IndexPath, + target.DisplayRelativePath, + record.Size, + record.Modified, + workspaceFileSnapshots, + out var changedPath, + extractionCancellationToken)) + { + extractionResults.Add( + FullScanFileWorkItem.Failure( + fileIndex, + filePath, + displayRelativePath, + "csharp_workspace_validation", + new CSharpWorkspaceSnapshotDriftException( + FormatCSharpWorkspaceSnapshotPath(projectRoot, changedPath))), + extractionCancellationToken); + continue; + } + var issue = new FileIssue + { + Path = ex.RelativePath, + Kind = "file_too_large", + Line = 0, + Message = sanitizedMessage, + }; + extractionResults.Add( + FullScanFileWorkItem.Precomputed(fileIndex, filePath, displayRelativePath, record, sanitizedMessage, [], [], [], [issue]), + extractionCancellationToken); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + var item = target.Language == "csharp" && csharpWorkspaceFileSnapshots != null + ? FullScanFileWorkItem.Failure( + fileIndex, + filePath, + displayRelativePath, + "csharp_workspace_validation", + new CSharpWorkspaceSnapshotDriftException(target.DisplayRelativePath)) + : FullScanFileWorkItem.Skipped( + fileIndex, + filePath, + displayRelativePath, + $"{displayRelativePath}: skipped because it was deleted during indexing."); + extractionResults.Add(item, extractionCancellationToken); + } + catch (Exception ex) + { + var failedPhase = Volatile.Read(ref activeExtractionPhases[workerIndex])?.Phase ?? "unknown"; + extractionResults.Add(FullScanFileWorkItem.Failure(fileIndex, filePath, displayRelativePath, failedPhase, ex), extractionCancellationToken); + } + finally + { + Volatile.Write(ref activeExtractionPhases[workerIndex], null); + } + } + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default)) + .ToArray(); + return workers; + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.FilePersistence.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.FilePersistence.cs new file mode 100644 index 000000000..058383c00 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.FilePersistence.cs @@ -0,0 +1,326 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class FullScanFilePersistenceContext + { + internal required DbWriter Writer { get; init; } + internal required FileIndexer Indexer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required string ProjectRoot { get; init; } + internal required FullScanFileWorkItem Item { get; init; } + internal required FileRecord Record { get; init; } + internal FileIssue? GeneratedSuppressionIssue { get; init; } + internal required bool StartedWithNoIndexedFiles { get; init; } + internal required bool DeferCSharpMutationsForIncompleteScan { get; init; } + internal required CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace { get; init; } + internal required PostExtractionHookRunner PostExtractionHooks { get; init; } + internal required SymbolExtractionWorkerClient SymbolExtractionWorker { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required Action> InsertIssuesForIndexedFile { get; init; } + internal required Action WriteProjectRootOnce { get; init; } + internal required Action SetPhase { get; init; } + } + + private sealed record FullScanFilePersistenceResult( + int ExtractedChunks, + int ExtractedSymbols, + int ExtractedReferences, + int PersistedChunks, + int PersistedSymbols, + int PersistedReferences, + int SymbolsDroppedByKindFilter, + bool MutualRecursionRefreshNeeded, + bool CSharpMetadataTargetsNeedRefresh, + bool StampSymbolExtractorLanguage, + string VerboseMessage); + + private static FullScanFilePersistenceResult PersistFullScanFile( + FullScanFilePersistenceContext context) + { + var writer = context.Writer; + var item = context.Item; + var record = context.Record; + var options = context.Options; + var cancellationToken = context.CancellationToken; + var mutualRecursionRefreshNeeded = false; + var csharpMetadataTargetsNeedRefresh = false; + var symbolsDroppedByKindFilter = 0; + + using var txn = writer.BeginTransaction(cancellationToken, "full scan file"); + if (!context.StartedWithNoIndexedFiles) + { + var stalePurged = context.DeferCSharpMutationsForIncompleteScan + ? 0 + : writer.PurgeStaleFilesSharingChecksum( + context.ProjectRoot, + record.Path, + record.Checksum); + if (stalePurged > 0) + { + csharpMetadataTargetsNeedRefresh = true; + if (!options.SymbolsOnly) + mutualRecursionRefreshNeeded = true; + } + } + var referenceIdentityChanged = false; + var fileId = context.StartedWithNoIndexedFiles + ? writer.InsertNewFile(record) + : writer.UpsertFile(record, out referenceIdentityChanged); + if (!options.SymbolsOnly && referenceIdentityChanged) + mutualRecursionRefreshNeeded = true; + + context.SetPhase(FormatIndexPhasePath(record.Path, "chunking"), "chunking"); + var chunks = item.Chunks == null + ? ChunkSplitter.SplitNormalized( + fileId, + item.Content!, + item.HasOversizeLine ?? ChunkSplitter.HasOversizeLine(item.Content!), + record.Lines) + : ReassignChunkFileIds(item.Chunks, fileId); + if (context.GeneratedSuppressionIssue != null) + { + writer.InsertChunks(chunks, cancellationToken); + writer.InsertSymbols([], cancellationToken); + writer.InsertReferencesInAtomicFileScope([], cancellationToken); + var generatedIssues = AppendIssueIfMissing( + RequireWorkItemIssues(item), + context.GeneratedSuppressionIssue); + context.InsertIssuesForIndexedFile(fileId, generatedIssues); + context.SetPhase( + FormatIndexPhasePath(record.Path, "committing"), + "committing"); + context.WriteProjectRootOnce(); + txn.Commit(); + return new FullScanFilePersistenceResult( + chunks.Count, + item.Symbols?.Count ?? 0, + item.References?.Count ?? 0, + chunks.Count, + 0, + 0, + 0, + mutualRecursionRefreshNeeded, + csharpMetadataTargetsNeedRefresh, + StampSymbolExtractorLanguage: true, + $" [OK ] {record.Path} ({chunks.Count} chunks, generated-code extraction skipped)"); + } + + context.SetPhase(FormatIndexPhasePath(record.Path, "symbols"), "symbols"); + FullScanFilePhaseForTesting?.Invoke(record.Path, "symbols"); + SymbolExtractionResult? symbolExtraction = null; + var symbols = item.Symbols == null + ? (symbolExtraction = ExtractSymbolsWithStallTimeout( + fileId, + record.Lang, + item.Content!, + item.FilePath, + context.ProjectRoot, + record.Path, + FormatIndexPhasePath(record.Path, "symbols"), + true, + item.HasOversizeLine, + item.ConflictMarkerLine, + context.SymbolExtractionWorker, + cancellationToken)).Symbols + : ReassignSymbolFileIds(item.Symbols, fileId); + var extractedSymbolCount = symbols.Count; + var symbolRegexTimeoutIssue = symbolExtraction?.RegexTimeoutIssue; + var fileContext = new FileContext( + context.ProjectRoot, + record.Path, + item.FilePath, + record.Lang); + context.PostExtractionHooks.ObserveCSharpStaticInterfaceSourceSymbols( + fileContext, + symbols); + if (symbols.Count > options.MaxSymbolsPerFile) + { + var issue = BuildSymbolCountExceededIssue( + record.Path, + symbols.Count, + options.MaxSymbolsPerFile); + IReadOnlyList capIssues = symbolRegexTimeoutIssue == null + ? [issue] + : AppendIssue([symbolRegexTimeoutIssue], issue); + writer.InsertSymbols([], cancellationToken); + writer.InsertReferencesInAtomicFileScope([], cancellationToken); + context.InsertIssuesForIndexedFile(fileId, capIssues); + txn.Commit(); + return new FullScanFilePersistenceResult( + chunks.Count, + extractedSymbolCount, + item.References?.Count ?? 0, + 0, + 0, + 0, + 0, + mutualRecursionRefreshNeeded, + csharpMetadataTargetsNeedRefresh, + StampSymbolExtractorLanguage: false, + $" [SKIP] {record.Path} ({issue.Message})"); + } + + if (item.Symbols == null) + { + SymbolExtractor.ApplyFamilyScope( + symbols, + context.Indexer.GetFamilyScopeKey(item.FilePath, record.Lang)); + } + var mutableSymbols = symbols as IList ?? symbols.ToList(); + context.PostExtractionHooks.OnSymbolsExtractedAfterSourceObservation( + fileContext, + mutableSymbols); + symbolsDroppedByKindFilter = options.SymbolKindFilter.Apply(mutableSymbols); + symbols = (IReadOnlyList)mutableSymbols; + if (symbols.Count > options.MaxSymbolsPerFile) + { + var issue = BuildSymbolCountExceededIssue( + record.Path, + symbols.Count, + options.MaxSymbolsPerFile); + IReadOnlyList capIssues = symbolRegexTimeoutIssue == null + ? [issue] + : AppendIssue([symbolRegexTimeoutIssue], issue); + writer.InsertSymbols([], cancellationToken); + writer.InsertReferencesInAtomicFileScope([], cancellationToken); + writer.InsertIssues(fileId, capIssues); + txn.Commit(); + return new FullScanFilePersistenceResult( + chunks.Count, + extractedSymbolCount, + item.References?.Count ?? 0, + 0, + 0, + 0, + symbolsDroppedByKindFilter, + mutualRecursionRefreshNeeded, + csharpMetadataTargetsNeedRefresh, + StampSymbolExtractorLanguage: false, + $" [SKIP] {record.Path} ({issue.Message})"); + } + + writer.InsertChunks(chunks, cancellationToken); + FileIndexer.ValidateSymbolLineRanges(record, symbols); + writer.InsertSymbols(symbols, cancellationToken); + if (symbolRegexTimeoutIssue != null) + { + var baseIssues = RequireWorkItemIssues(item); + item = item with { Issues = AppendIssue(baseIssues, symbolRegexTimeoutIssue) }; + } + context.SetPhase(FormatIndexPhasePath(record.Path, "references"), "references"); + FullScanFilePhaseForTesting?.Invoke(record.Path, "references"); + IReadOnlyList references; + var extractedReferenceCount = item.References?.Count ?? 0; + if (options.SymbolsOnly) + { + references = []; + } + else + { + FileIssue? regexTimeoutIssue = null; + ReferenceExtractionResult? referenceExtraction = null; + if (item.References == null) + { + using var regexTimeouts = BoundedRegex.CaptureTimeouts( + record.Lang, + "reference_extraction"); + referenceExtraction = ReferenceExtractor.ExtractDetailedNormalized( + fileId, + record.Lang, + item.Content!, + item.HasOversizeLine + ?? ChunkSplitter.HasOversizeLine(item.Content!), + symbols, + record.Path, + record.Lang == "csharp" + ? context.CSharpWorkspace.Symbols + : null, + cancellationToken, + maxReferenceCount: options.MaxReferencesPerFile + 1, + conflictMarkerLine: item.ConflictMarkerLine, + workspaceRoot: context.ProjectRoot, + csharpStaticInterfaceMemberLookups: + context.CSharpWorkspace.StaticInterfaceMemberLookups); + references = referenceExtraction.References; + regexTimeoutIssue = BuildRegexTimeoutIssue(record.Path, regexTimeouts); + } + else + { + references = ReassignReferenceFileIds(item.References, fileId); + } + extractedReferenceCount = references.Count; + context.PostExtractionHooks.OnReferencesExtracted( + fileContext, + AsMutableList(references)); + if (regexTimeoutIssue != null) + { + var baseIssues = RequireWorkItemIssues(item); + item = item with { Issues = AppendIssue(baseIssues, regexTimeoutIssue) }; + } + if (referenceExtraction != null) + { + var baseIssues = RequireWorkItemIssues(item); + item = item with + { + Issues = AppendReferenceExtractionDiagnosticIssues( + baseIssues, + record.Path, + referenceExtraction.Diagnostics), + }; + } + if (references.Count > options.MaxReferencesPerFile) + { + var issue = BuildReferenceCountExceededIssue( + record.Path, + references.Count, + options.MaxReferencesPerFile); + references = []; + var baseIssues = RequireWorkItemIssues(item); + item = item with { Issues = AppendIssue(baseIssues, issue) }; + } + } + + if (context.StartedWithNoIndexedFiles) + { + writer.InsertReferencesForNewFilesInAtomicFileScope( + references, + refreshMutualRecursionFlags: false, + cancellationToken); + } + else + { + writer.InsertReferencesInAtomicFileScope( + references, + refreshMutualRecursionFlags: false, + cancellationToken); + } + if (!options.SymbolsOnly && (symbols.Count > 0 || references.Count > 0)) + mutualRecursionRefreshNeeded = true; + context.SetPhase(FormatIndexPhasePath(record.Path, "validating"), "validating"); + var issues = RequireWorkItemIssues(item); + context.InsertIssuesForIndexedFile(fileId, issues); + context.SetPhase(FormatIndexPhasePath(record.Path, "committing"), "committing"); + context.WriteProjectRootOnce(); + txn.Commit(); + + return new FullScanFilePersistenceResult( + chunks.Count, + extractedSymbolCount, + extractedReferenceCount, + chunks.Count, + symbols.Count, + references.Count, + symbolsDroppedByKindFilter, + mutualRecursionRefreshNeeded, + csharpMetadataTargetsNeedRefresh, + StampSymbolExtractorLanguage: true, + $" [OK ] {record.Path} ({chunks.Count} chunks, {symbols.Count} symbols, {references.Count} refs)"); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 865db5729..560283b92 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -1337,321 +1337,24 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis using var mainSymbolExtractionWorker = new LazyDisposable( () => new SymbolExtractionWorkerClient(options.MaxFileSizeBytes)); var extractionCancellationToken = extractionStallCts.Token; - var nextExtractionIndex = -1; - var workers = Enumerable.Range(0, extractionWorkerCount) - .Select(workerIndex => Task.Factory.StartNew(() => - { - using var workerSymbolExtractionWorker = new LazyDisposable( - () => new SymbolExtractionWorkerClient(options.MaxFileSizeBytes)); - while (true) - { - extractionCancellationToken.ThrowIfCancellationRequested(); - var extractionIndex = Interlocked.Increment(ref nextExtractionIndex); - if (extractionIndex >= extractionWorkItemCount) - break; - - var fileIndex = extractionFileIndexes == null - ? extractionIndex - : extractionFileIndexes[extractionIndex]; - var target = fileTargets[fileIndex]; - var filePath = target.FilePath; - var relativeFilePath = target.RelativePath; - var displayRelativePath = target.DisplayRelativePath; - try - { - Volatile.Write(ref activeExtractionPhases[workerIndex], new(displayRelativePath, "reading")); - FullScanFileContentLoadForTesting?.Invoke(displayRelativePath); - var loaded = indexer.BuildLoadedRecordWithRawBytes( - filePath, - relativeFilePath, - target.Language, - extractionCancellationToken); - var record = loaded.Record; - var workspaceFileSnapshots = csharpWorkspaceFileSnapshots; - if (target.Language == "csharp" - && workspaceFileSnapshots != null - && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - target.FilePath, - target.IndexPath, - target.DisplayRelativePath, - record.Size, - record.Modified, - workspaceFileSnapshots, - out var changedPath, - extractionCancellationToken)) - { - extractionResults.Add( - FullScanFileWorkItem.Failure( - fileIndex, - filePath, - displayRelativePath, - "csharp_workspace_validation", - new CSharpWorkspaceSnapshotDriftException( - FormatCSharpWorkspaceSnapshotPath(projectRoot, changedPath))), - extractionCancellationToken); - continue; - } - var content = loaded.Content; - var rawBytes = loaded.RawBytes; - var warning = loaded.Warning; - var hasOversizeLine = loaded.HasOversizeLine; - IReadOnlyList? chunks = null; - IReadOnlyList? symbols = null; - IReadOnlyList? references = null; - IReadOnlyList? issues = null; - var generatedSuppressionIssue = target.GeneratedExtractionSuppressed - ? indexer.BuildGeneratedCodeExtractionSkippedIssue(record.Path) - : null; - if (parallelizeExtraction) - { - Volatile.Write(ref activeExtractionPhases[workerIndex], new(record.Path, "chunking")); - chunks = ChunkSplitter.SplitNormalized(0, content, hasOversizeLine, record.Lines); - if (generatedSuppressionIssue != null) - { - symbols = []; - references = []; - Volatile.Write(ref activeExtractionPhases[workerIndex], new(record.Path, "validating")); - issues = AppendIssueIfMissing( - FileIndexer.ValidateContent(record.Path, rawBytes, content, record.Lang, loaded.Inspection, hasOversizeLine, loaded.ConflictMarkerLine), - generatedSuppressionIssue); - extractionResults.Add( - FullScanFileWorkItem.Precomputed( - fileIndex, - filePath, - displayRelativePath, - record, - warning, - chunks, - symbols, - references, - issues, - generatedSuppressionIssue, - generatedSuppressionChecked: true), - extractionCancellationToken); - continue; - } - Volatile.Write(ref activeExtractionPhases[workerIndex], new(record.Path, "symbols")); - FullScanFilePhaseForTesting?.Invoke(record.Path, "symbols"); - var symbolExtraction = ExtractSymbolsWithStallTimeout( - 0, - record.Lang, - content, - filePath, - projectRoot, - record.Path, - Volatile.Read(ref activeExtractionPhases[workerIndex])!.Format(), - true, - hasOversizeLine, - loaded.ConflictMarkerLine, - workerSymbolExtractionWorker.Value, - extractionCancellationToken); - symbols = symbolExtraction.Symbols; - var symbolRegexTimeoutIssue = symbolExtraction.RegexTimeoutIssue; - if (string.Equals(record.Lang, "csharp", StringComparison.Ordinal)) - { - var sourceFileContext = new FileContext( - projectRoot, - record.Path, - filePath, - record.Lang); - postExtractionHooks.ObserveCSharpStaticInterfaceSourceSymbols( - sourceFileContext, - symbols); - } - if (symbols.Count > options.MaxSymbolsPerFile) - { - var issue = BuildSymbolCountExceededIssue(record.Path, symbols.Count, options.MaxSymbolsPerFile); - IReadOnlyList capIssues = symbolRegexTimeoutIssue == null - ? [issue] - : AppendIssue([symbolRegexTimeoutIssue], issue); - extractionResults.Add( - FullScanFileWorkItem.Precomputed(fileIndex, filePath, displayRelativePath, record, issue.Message, [], [], [], capIssues), - extractionCancellationToken); - continue; - } - SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(filePath, record.Lang)); - FileIssue? referenceRegexTimeoutIssue = null; - ReferenceExtractionResult? referenceExtraction = null; - if (options.SymbolsOnly) - { - references = []; - } - else - { - Volatile.Write(ref activeExtractionPhases[workerIndex], new(record.Path, "references")); - FullScanFilePhaseForTesting?.Invoke(record.Path, "references"); - using var regexTimeouts = BoundedRegex.CaptureTimeouts(record.Lang, "reference_extraction"); - referenceExtraction = ReferenceExtractor.ExtractDetailedNormalized( - 0, - record.Lang, - content, - hasOversizeLine, - symbols, - record.Path, - record.Lang == "csharp" ? csharpWorkspace.Symbols : null, - extractionCancellationToken, - maxReferenceCount: options.MaxReferencesPerFile + 1, - conflictMarkerLine: loaded.ConflictMarkerLine, - workspaceRoot: projectRoot, - csharpStaticInterfaceMemberLookups: csharpWorkspace.StaticInterfaceMemberLookups); - references = referenceExtraction.References; - referenceRegexTimeoutIssue = BuildRegexTimeoutIssue(record.Path, regexTimeouts); - } - Volatile.Write(ref activeExtractionPhases[workerIndex], new(record.Path, "validating")); - issues = FileIndexer.ValidateContent(record.Path, rawBytes, content, record.Lang, loaded.Inspection, hasOversizeLine, loaded.ConflictMarkerLine); - if (symbolRegexTimeoutIssue != null) - issues = AppendIssue(issues, symbolRegexTimeoutIssue); - if (referenceRegexTimeoutIssue != null) - issues = AppendIssue(issues, referenceRegexTimeoutIssue); - if (referenceExtraction != null) - issues = AppendReferenceExtractionDiagnosticIssues(issues, record.Path, referenceExtraction.Diagnostics); - if (references.Count > options.MaxReferencesPerFile) - { - var issue = BuildReferenceCountExceededIssue(record.Path, references.Count, options.MaxReferencesPerFile); - references = []; - issues = AppendIssue(issues, issue); - } - } - else - { - Volatile.Write(ref activeExtractionPhases[workerIndex], new(record.Path, "validating")); - issues = FileIndexer.ValidateContent(record.Path, rawBytes, content, record.Lang, loaded.Inspection, hasOversizeLine, loaded.ConflictMarkerLine); - } - extractionResults.Add( - parallelizeExtraction - ? FullScanFileWorkItem.Precomputed( - fileIndex, - filePath, - displayRelativePath, - record, - warning, - chunks!, - symbols!, - references!, - issues!, - generatedSuppressionIssue, - generatedSuppressionChecked: true) - : FullScanFileWorkItem.Success( - fileIndex, - filePath, - displayRelativePath, - record, - content, - hasOversizeLine, - loaded.ConflictMarkerLine, - warning, - chunks, - symbols, - references, - issues, - generatedSuppressionIssue, - generatedSuppressionChecked: true), - extractionCancellationToken); - } - catch (OperationCanceledException) when (extractionCancellationToken.IsCancellationRequested) - { - throw; - } - catch (FileIndexer.BinaryFileSkippedException ex) - { - var record = indexer.BuildSkippedFileRecord(filePath, relativeFilePath, target.Language); - var workspaceFileSnapshots = csharpWorkspaceFileSnapshots; - if (target.Language == "csharp" - && workspaceFileSnapshots != null - && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - target.FilePath, - target.IndexPath, - target.DisplayRelativePath, - record.Size, - record.Modified, - workspaceFileSnapshots, - out var changedPath, - extractionCancellationToken)) - { - extractionResults.Add( - FullScanFileWorkItem.Failure( - fileIndex, - filePath, - displayRelativePath, - "csharp_workspace_validation", - new CSharpWorkspaceSnapshotDriftException( - FormatCSharpWorkspaceSnapshotPath(projectRoot, changedPath))), - extractionCancellationToken); - continue; - } - var issue = BuildNullByteIssue(ex); - var sanitizedMessage = CommandErrorWriter.FormatSanitizedExceptionMessage(ex); - extractionResults.Add( - FullScanFileWorkItem.Precomputed(fileIndex, filePath, displayRelativePath, record, sanitizedMessage, [], [], [], [issue]), - extractionCancellationToken); - } - catch (FileIndexer.FileTooLargeSkippedException ex) - { - var sanitizedMessage = CommandErrorWriter.FormatSanitizedExceptionMessage(ex); - var record = indexer.BuildSkippedFileRecord(filePath, relativeFilePath, target.Language); - var workspaceFileSnapshots = csharpWorkspaceFileSnapshots; - if (target.Language == "csharp" - && workspaceFileSnapshots != null - && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - target.FilePath, - target.IndexPath, - target.DisplayRelativePath, - record.Size, - record.Modified, - workspaceFileSnapshots, - out var changedPath, - extractionCancellationToken)) - { - extractionResults.Add( - FullScanFileWorkItem.Failure( - fileIndex, - filePath, - displayRelativePath, - "csharp_workspace_validation", - new CSharpWorkspaceSnapshotDriftException( - FormatCSharpWorkspaceSnapshotPath(projectRoot, changedPath))), - extractionCancellationToken); - continue; - } - var issue = new FileIssue - { - Path = ex.RelativePath, - Kind = "file_too_large", - Line = 0, - Message = sanitizedMessage, - }; - extractionResults.Add( - FullScanFileWorkItem.Precomputed(fileIndex, filePath, displayRelativePath, record, sanitizedMessage, [], [], [], [issue]), - extractionCancellationToken); - } - catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) - { - var item = target.Language == "csharp" && csharpWorkspaceFileSnapshots != null - ? FullScanFileWorkItem.Failure( - fileIndex, - filePath, - displayRelativePath, - "csharp_workspace_validation", - new CSharpWorkspaceSnapshotDriftException(target.DisplayRelativePath)) - : FullScanFileWorkItem.Skipped( - fileIndex, - filePath, - displayRelativePath, - $"{displayRelativePath}: skipped because it was deleted during indexing."); - extractionResults.Add(item, extractionCancellationToken); - } - catch (Exception ex) - { - var failedPhase = Volatile.Read(ref activeExtractionPhases[workerIndex])?.Phase ?? "unknown"; - extractionResults.Add(FullScanFileWorkItem.Failure(fileIndex, filePath, displayRelativePath, failedPhase, ex), extractionCancellationToken); - } - finally - { - Volatile.Write(ref activeExtractionPhases[workerIndex], null); - } - } - }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default)) - .ToArray(); + var workers = StartFullScanExtractionWorkers(new FullScanExtractionWorkerContext + { + Indexer = indexer, + Options = options, + ProjectRoot = projectRoot, + FileTargets = fileTargets, + ExtractionFileIndexes = extractionFileIndexes, + ExtractionWorkItemCount = extractionWorkItemCount, + ExtractionWorkerCount = extractionWorkerCount, + ParallelizeExtraction = parallelizeExtraction, + CSharpWorkspace = csharpWorkspace, + CSharpWorkspaceFileSnapshots = csharpWorkspaceFileSnapshots, + PostExtractionHooks = postExtractionHooks, + ActiveExtractionPhases = activeExtractionPhases, + ExtractionResults = extractionResults, + ExtractionCancellationToken = extractionCancellationToken, + CancellationToken = cancellationToken, + }); _ = Task.WhenAll(workers).ContinueWith( task => @@ -1870,236 +1573,46 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis if (record.Lang == "typescript") RequireTypeScriptAugmentationRefresh(); - var fileFtsMutated = false; - using var txn = writer.BeginTransaction(cancellationToken, "full scan file"); - if (!startedWithNoIndexedFiles) - { - var stalePurged = deferCSharpMutationsForIncompleteScan - ? 0 - : writer.PurgeStaleFilesSharingChecksum( - projectRoot, - record.Path, - record.Checksum); - if (stalePurged > 0) - { - fileFtsMutated = true; - csharpMetadataTargetsNeedRefresh = true; - if (!options.SymbolsOnly) - mutualRecursionRefreshNeeded = true; - } - } - var referenceIdentityChanged = false; - var fileId = startedWithNoIndexedFiles - ? writer.InsertNewFile(record) - : writer.UpsertFile(record, out referenceIdentityChanged); - if (!options.SymbolsOnly && referenceIdentityChanged) - mutualRecursionRefreshNeeded = true; - fileFtsMutated = true; - currentJsonIndexFile = FormatIndexPhasePath(record.Path, "chunking"); - indexFilePhase = "chunking"; - var chunks = item.Chunks == null - ? ChunkSplitter.SplitNormalized( - fileId, - item.Content!, - item.HasOversizeLine ?? ChunkSplitter.HasOversizeLine(item.Content!), - record.Lines) - : ReassignChunkFileIds(item.Chunks, fileId); - itemChunksExtracted = chunks.Count; - if (generatedSuppressionIssue != null) - { - writer.InsertChunks(chunks, cancellationToken); - writer.InsertSymbols([], cancellationToken); - writer.InsertReferencesInAtomicFileScope([], cancellationToken); - var generatedIssues = AppendIssueIfMissing( - RequireWorkItemIssues(item), - generatedSuppressionIssue); - InsertIssuesForIndexedFile(fileId, generatedIssues); - if (options.Verbose) - indexProgress.WriteVerbose($" [OK ] {record.Path} ({chunks.Count} chunks, generated-code extraction skipped)"); - currentJsonIndexFile = FormatIndexPhasePath(record.Path, "committing"); - WriteProjectRootOnce(); - txn.Commit(); - ftsMutated |= fileFtsMutated; - if (!string.IsNullOrWhiteSpace(record.Lang)) - indexedSymbolExtractorLanguages.Add(record.Lang); - CountFreshInsertedRows(chunkCount: chunks.Count); - - processed++; - if (!options.Json && !options.Quiet) - { - indexProgress.Pause(); - ConsoleUi.PrintProgress(processed, files.Count); - indexProgress.Resume(); - } - ReportJsonIndexProgressIfNeeded(); - currentJsonIndexFile = null; - continue; - } - currentJsonIndexFile = FormatIndexPhasePath(record.Path, "symbols"); - indexFilePhase = "symbols"; - FullScanFilePhaseForTesting?.Invoke(record.Path, "symbols"); - SymbolExtractionResult? symbolExtraction = null; - var symbols = item.Symbols == null - ? (symbolExtraction = ExtractSymbolsWithStallTimeout( - fileId, - record.Lang, - item.Content!, - item.FilePath, - projectRoot, - record.Path, - currentJsonIndexFile, - true, - item.HasOversizeLine, - item.ConflictMarkerLine, - mainSymbolExtractionWorker.Value, - cancellationToken)).Symbols - : ReassignSymbolFileIds(item.Symbols, fileId); - itemSymbolsExtracted = symbols.Count; - var symbolRegexTimeoutIssue = symbolExtraction?.RegexTimeoutIssue; - var fileContext = new FileContext(projectRoot, record.Path, item.FilePath, record.Lang); - postExtractionHooks.ObserveCSharpStaticInterfaceSourceSymbols(fileContext, symbols); - if (symbols.Count > options.MaxSymbolsPerFile) + var persistence = PersistFullScanFile(new FullScanFilePersistenceContext { - var issue = BuildSymbolCountExceededIssue(record.Path, symbols.Count, options.MaxSymbolsPerFile); - IReadOnlyList capIssues = symbolRegexTimeoutIssue == null - ? [issue] - : AppendIssue([symbolRegexTimeoutIssue], issue); - writer.InsertSymbols([], cancellationToken); - writer.InsertReferencesInAtomicFileScope([], cancellationToken); - InsertIssuesForIndexedFile(fileId, capIssues); - if (options.Verbose) - indexProgress.WriteVerbose($" [SKIP] {record.Path} ({issue.Message})"); - txn.Commit(); - ftsMutated |= fileFtsMutated; - CountFreshInsertedRows(); - processed++; - if (!options.Json && !options.Quiet) + Writer = writer, + Indexer = indexer, + Options = options, + ProjectRoot = projectRoot, + Item = item, + Record = record, + GeneratedSuppressionIssue = generatedSuppressionIssue, + StartedWithNoIndexedFiles = startedWithNoIndexedFiles, + DeferCSharpMutationsForIncompleteScan = deferCSharpMutationsForIncompleteScan, + CSharpWorkspace = csharpWorkspace, + PostExtractionHooks = postExtractionHooks, + SymbolExtractionWorker = mainSymbolExtractionWorker.Value, + CancellationToken = cancellationToken, + InsertIssuesForIndexedFile = InsertIssuesForIndexedFile, + WriteProjectRootOnce = WriteProjectRootOnce, + SetPhase = (path, phase) => { - indexProgress.Pause(); - ConsoleUi.PrintProgress(processed, files.Count); - indexProgress.Resume(); - } - ReportJsonIndexProgressIfNeeded(); - currentJsonIndexFile = null; - continue; - } - if (item.Symbols == null) - SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(item.FilePath, record.Lang)); - var mutableSymbols = symbols as IList ?? symbols.ToList(); - postExtractionHooks.OnSymbolsExtractedAfterSourceObservation(fileContext, mutableSymbols); - symbolsDroppedByKindFilter += options.SymbolKindFilter.Apply(mutableSymbols); - symbols = (IReadOnlyList)mutableSymbols; - if (symbols.Count > options.MaxSymbolsPerFile) + currentJsonIndexFile = path; + indexFilePhase = phase; + }, + }); + itemChunksExtracted = persistence.ExtractedChunks; + itemSymbolsExtracted = persistence.ExtractedSymbols; + itemReferencesExtracted = persistence.ExtractedReferences; + symbolsDroppedByKindFilter += persistence.SymbolsDroppedByKindFilter; + mutualRecursionRefreshNeeded |= persistence.MutualRecursionRefreshNeeded; + csharpMetadataTargetsNeedRefresh |= persistence.CSharpMetadataTargetsNeedRefresh; + ftsMutated = true; + if (persistence.StampSymbolExtractorLanguage + && !string.IsNullOrWhiteSpace(record.Lang)) { - var issue = BuildSymbolCountExceededIssue(record.Path, symbols.Count, options.MaxSymbolsPerFile); - IReadOnlyList capIssues = symbolRegexTimeoutIssue == null - ? [issue] - : AppendIssue([symbolRegexTimeoutIssue], issue); - writer.InsertSymbols([], cancellationToken); - writer.InsertReferencesInAtomicFileScope([], cancellationToken); - writer.InsertIssues(fileId, capIssues); - if (options.Verbose) - indexProgress.WriteVerbose($" [SKIP] {record.Path} ({issue.Message})"); - txn.Commit(); - ftsMutated |= fileFtsMutated; - CountFreshInsertedRows(); - processed++; - if (!options.Json && !options.Quiet) - { - indexProgress.Pause(); - ConsoleUi.PrintProgress(processed, files.Count); - indexProgress.Resume(); - } - ReportJsonIndexProgressIfNeeded(); - currentJsonIndexFile = null; - continue; - } - writer.InsertChunks(chunks, cancellationToken); - FileIndexer.ValidateSymbolLineRanges(record, symbols); - writer.InsertSymbols(symbols, cancellationToken); - if (symbolRegexTimeoutIssue != null) - { - var baseIssues = RequireWorkItemIssues(item); - item = item with { Issues = AppendIssue(baseIssues, symbolRegexTimeoutIssue) }; - } - currentJsonIndexFile = FormatIndexPhasePath(record.Path, "references"); - indexFilePhase = "references"; - FullScanFilePhaseForTesting?.Invoke(record.Path, "references"); - IReadOnlyList references; - if (options.SymbolsOnly) - { - references = []; - } - else - { - FileIssue? regexTimeoutIssue = null; - ReferenceExtractionResult? referenceExtraction = null; - if (item.References == null) - { - using var regexTimeouts = BoundedRegex.CaptureTimeouts(record.Lang, "reference_extraction"); - referenceExtraction = ReferenceExtractor.ExtractDetailedNormalized( - fileId, - record.Lang, - item.Content!, - item.HasOversizeLine ?? ChunkSplitter.HasOversizeLine(item.Content!), - symbols, - record.Path, - record.Lang == "csharp" ? csharpWorkspace.Symbols : null, - cancellationToken, - maxReferenceCount: options.MaxReferencesPerFile + 1, - conflictMarkerLine: item.ConflictMarkerLine, - workspaceRoot: projectRoot, - csharpStaticInterfaceMemberLookups: csharpWorkspace.StaticInterfaceMemberLookups); - references = referenceExtraction.References; - regexTimeoutIssue = BuildRegexTimeoutIssue(record.Path, regexTimeouts); - } - else - { - references = ReassignReferenceFileIds(item.References, fileId); - } - itemReferencesExtracted = references.Count; - postExtractionHooks.OnReferencesExtracted(fileContext, AsMutableList(references)); - if (regexTimeoutIssue != null) - { - var baseIssues = RequireWorkItemIssues(item); - item = item with { Issues = AppendIssue(baseIssues, regexTimeoutIssue) }; - } - if (referenceExtraction != null) - { - var baseIssues = RequireWorkItemIssues(item); - item = item with - { - Issues = AppendReferenceExtractionDiagnosticIssues(baseIssues, record.Path, referenceExtraction.Diagnostics), - }; - } - if (references.Count > options.MaxReferencesPerFile) - { - var issue = BuildReferenceCountExceededIssue(record.Path, references.Count, options.MaxReferencesPerFile); - references = []; - var baseIssues = RequireWorkItemIssues(item); - item = item with { Issues = AppendIssue(baseIssues, issue) }; - } - } - if (startedWithNoIndexedFiles) - writer.InsertReferencesForNewFilesInAtomicFileScope(references, refreshMutualRecursionFlags: false, cancellationToken); - else - writer.InsertReferencesInAtomicFileScope(references, refreshMutualRecursionFlags: false, cancellationToken); - if (!options.SymbolsOnly && (symbols.Count > 0 || references.Count > 0)) - mutualRecursionRefreshNeeded = true; - currentJsonIndexFile = FormatIndexPhasePath(record.Path, "validating"); - indexFilePhase = "validating"; - var issues = RequireWorkItemIssues(item); - InsertIssuesForIndexedFile(fileId, issues); - currentJsonIndexFile = FormatIndexPhasePath(record.Path, "committing"); - indexFilePhase = "committing"; - WriteProjectRootOnce(); - txn.Commit(); - ftsMutated |= fileFtsMutated; - if (!string.IsNullOrWhiteSpace(record.Lang)) indexedSymbolExtractorLanguages.Add(record.Lang); - CountFreshInsertedRows(chunks.Count, symbols.Count, references.Count); - - indexProgress.WriteVerbose($" [OK ] {record.Path} ({chunks.Count} chunks, {symbols.Count} symbols, {references.Count} refs)"); + } + CountFreshInsertedRows( + persistence.PersistedChunks, + persistence.PersistedSymbols, + persistence.PersistedReferences); + indexProgress.WriteVerbose(persistence.VerboseMessage); } catch (IndexExtractionStalledException) { From 9dc5577a34bf389fafaf305c20a0fe5864e34b93 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 23:34:36 +0900 Subject: [PATCH 085/101] Unify skipped update file persistence --- ...dexCommandRunner.Update.FilePersistence.cs | 88 +++++++++ .../Cli/IndexCommandRunner.Update.cs | 186 +++++++----------- 2 files changed, 163 insertions(+), 111 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.FilePersistence.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.FilePersistence.cs index 4dc216ec8..f84717c26 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.FilePersistence.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.FilePersistence.cs @@ -37,6 +37,31 @@ private sealed record UpdateFilePersistenceResult( bool MutualRecursionRefreshNeeded, string VerboseMessage); + private sealed class SkippedUpdateFilePersistenceContext + { + internal required DbWriter Writer { get; init; } + internal required FileIndexer Indexer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required string AbsolutePath { get; init; } + internal required string RelativePath { get; init; } + internal string? KnownLanguage { get; init; } + internal required bool ProjectRootWritten { get; init; } + internal required string TransactionName { get; init; } + internal required string WorkspaceChangedMessage { get; init; } + internal required FileIssue Issue { get; init; } + internal required int TargetIndex { get; init; } + internal required ReadableFileByteTracker ReadableFileBytes { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required Func ValidateSkippedRecord { get; init; } + internal required Func PurgeStaleUpdateCleanupPaths { get; init; } + internal required Action RequireTypeScriptAugmentationRefresh { get; init; } + internal required Action WriteProjectRootOnce { get; init; } + internal required Action RecordDynamicGraphFileRefresh { get; init; } + } + + private sealed record SkippedUpdateFilePersistenceResult( + bool MutualRecursionRefreshNeeded); + private static UpdateFilePersistenceResult PersistUpdateFile( UpdateFilePersistenceContext context) { @@ -283,4 +308,67 @@ private static UpdateFilePersistenceResult PersistUpdateFile( mutualRecursionRefreshNeeded, $" [OK ] {context.RelativePath} ({chunks.Count} chunks, {symbols.Count} symbols, {references.Count} refs)"); } + + private static SkippedUpdateFilePersistenceResult PersistSkippedUpdateFile( + SkippedUpdateFilePersistenceContext context) + { + var writer = context.Writer; + var cancellationToken = context.CancellationToken; + var mutualRecursionRefreshNeeded = false; + + writer.MarkBatchInProgress(); + var batchMarkerOwned = true; + try + { + using var txn = writer.BeginTransaction( + cancellationToken, + context.TransactionName); + var skippedRecord = context.Indexer.BuildSkippedFileRecord( + context.AbsolutePath, + context.RelativePath, + context.KnownLanguage); + UpdateSkippedFileRecordBuiltForTesting?.Invoke(context.RelativePath); + if (!context.ValidateSkippedRecord(skippedRecord)) + { + throw new CSharpWorkspaceChangedException( + context.WorkspaceChangedMessage); + } + context.ReadableFileBytes.Remember( + context.TargetIndex, + skippedRecord.Size); + var stalePurged = context.PurgeStaleUpdateCleanupPaths( + skippedRecord.Path, + skippedRecord.Checksum, + context.ProjectRootWritten); + if (skippedRecord.Lang == "typescript" || stalePurged > 0) + context.RequireTypeScriptAugmentationRefresh(); + if (!context.Options.SymbolsOnly && stalePurged > 0) + mutualRecursionRefreshNeeded = true; + context.WriteProjectRootOnce(); + var fileId = writer.UpsertFile( + skippedRecord, + out var referenceIdentityChanged); + if (!context.Options.SymbolsOnly && referenceIdentityChanged) + mutualRecursionRefreshNeeded = true; + writer.InsertChunks([], cancellationToken); + writer.InsertSymbols([], cancellationToken); + writer.InsertReferencesInAtomicFileScope( + [], + refreshMutualRecursionFlags: false, + cancellationToken); + writer.InsertIssues(fileId, [context.Issue]); + writer.ClearBatchInProgress(); + txn.Commit(); + context.RecordDynamicGraphFileRefresh(skippedRecord.Lang); + batchMarkerOwned = false; + } + finally + { + if (batchMarkerOwned) + writer.ClearBatchInProgress(); + } + + return new SkippedUpdateFilePersistenceResult( + mutualRecursionRefreshNeeded); + } } diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index 4c8cbd618..f8132b44e 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -1427,49 +1427,43 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) DemoteReadinessOnce(); currentUpdatePhase = "writing"; - writer.MarkBatchInProgress(); - var skippedBinaryBatchMarkerOwned = true; try { - using var txn = writer.BeginTransaction(cancellationToken, "update skipped binary"); - var skippedRecord = indexer.BuildSkippedFileRecord(absPath, relPath, knownLanguage); - UpdateSkippedFileRecordBuiltForTesting?.Invoke(relPath); - if (hasCSharpWorkspaceSnapshot - && (skippedRecord.Lang != "csharp" - || !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - absPath, - dbPath, - relPath, - skippedRecord.Size, - skippedRecord.Modified, - csharpWorkspaceSnapshots!, - out _, - cancellationToken))) - { - throw new CSharpWorkspaceChangedException( - "The C# file changed while recording its binary skip state."); - } - readableFileBytes.Remember(targetIndex, skippedRecord.Size); - var stalePurged = PurgeStaleUpdateCleanupPaths( - skippedRecord.Path, - skippedRecord.Checksum, - includeDirectoryAndStem: projectRootWritten); - if (skippedRecord.Lang == "typescript" || stalePurged > 0) - RequireTypeScriptAugmentationRefresh(); - if (!options.SymbolsOnly && stalePurged > 0) - mutualRecursionRefreshNeeded = true; - WriteProjectRootOnce(); - var fileId = writer.UpsertFile(skippedRecord, out var referenceIdentityChanged); - if (!options.SymbolsOnly && referenceIdentityChanged) - mutualRecursionRefreshNeeded = true; - writer.InsertChunks([], cancellationToken); - writer.InsertSymbols([], cancellationToken); - writer.InsertReferencesInAtomicFileScope([], refreshMutualRecursionFlags: false, cancellationToken); - writer.InsertIssues(fileId, [BuildNullByteIssue(binaryFile)]); - writer.ClearBatchInProgress(); - txn.Commit(); - RecordDynamicGraphFileRefresh(skippedRecord.Lang); - skippedBinaryBatchMarkerOwned = false; + var skippedPersistence = PersistSkippedUpdateFile( + new SkippedUpdateFilePersistenceContext + { + Writer = writer, + Indexer = indexer, + Options = options, + AbsolutePath = absPath, + RelativePath = relPath, + KnownLanguage = knownLanguage, + ProjectRootWritten = projectRootWritten, + TransactionName = "update skipped binary", + WorkspaceChangedMessage = "The C# file changed while recording its binary skip state.", + Issue = BuildNullByteIssue(binaryFile), + TargetIndex = targetIndex, + ReadableFileBytes = readableFileBytes, + CancellationToken = cancellationToken, + ValidateSkippedRecord = skippedRecord => + !hasCSharpWorkspaceSnapshot + || (skippedRecord.Lang == "csharp" + && CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + absPath, + dbPath, + relPath, + skippedRecord.Size, + skippedRecord.Modified, + csharpWorkspaceSnapshots!, + out _, + cancellationToken)), + PurgeStaleUpdateCleanupPaths = PurgeStaleUpdateCleanupPaths, + RequireTypeScriptAugmentationRefresh = RequireTypeScriptAugmentationRefresh, + WriteProjectRootOnce = WriteProjectRootOnce, + RecordDynamicGraphFileRefresh = RecordDynamicGraphFileRefresh, + }); + mutualRecursionRefreshNeeded |= + skippedPersistence.MutualRecursionRefreshNeeded; } catch (CSharpWorkspaceChangedException workspaceChanged) { @@ -1489,18 +1483,6 @@ or IndexInterruptedException RecordUpdateFileFailure(relPath, currentUpdatePhase, skippedWriteException); continue; } - finally - { - // MarkBatchInProgress is committed before the nested file - // transaction. Any in-process unwind must clear it only after that - // transaction has committed or disposed/rolled back. A process crash - // intentionally leaves the durable marker for startup repair. - // marker は file transaction の外で先に永続化されるため、正常 commit - // または rollback/dispose 後の全 unwind で durable に解除する。 - if (skippedBinaryBatchMarkerOwned) - writer.ClearBatchInProgress(); - } - updated++; ftsMutated = true; continue; @@ -1531,58 +1513,49 @@ or IndexInterruptedException DemoteReadinessOnce(); currentUpdatePhase = "writing"; - writer.MarkBatchInProgress(); - var skippedOversizedBatchMarkerOwned = true; try { - using var txn = writer.BeginTransaction(cancellationToken, "update skipped oversized file"); - var skippedRecord = indexer.BuildSkippedFileRecord(absPath, relPath, knownLanguage); - UpdateSkippedFileRecordBuiltForTesting?.Invoke(relPath); - if (hasCSharpWorkspaceSnapshot - && (skippedRecord.Lang != "csharp" - || !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - absPath, - dbPath, - relPath, - skippedRecord.Size, - skippedRecord.Modified, - csharpWorkspaceSnapshots!, - out _, - cancellationToken))) - { - throw new CSharpWorkspaceChangedException( - "The C# file changed while recording its oversized skip state."); - } - readableFileBytes.Remember(targetIndex, skippedRecord.Size); - var stalePurged = PurgeStaleUpdateCleanupPaths( - skippedRecord.Path, - skippedRecord.Checksum, - includeDirectoryAndStem: projectRootWritten); - if (skippedRecord.Lang == "typescript" || stalePurged > 0) - RequireTypeScriptAugmentationRefresh(); - if (!options.SymbolsOnly && stalePurged > 0) - mutualRecursionRefreshNeeded = true; - WriteProjectRootOnce(); - var fileId = writer.UpsertFile(skippedRecord, out var referenceIdentityChanged); - if (!options.SymbolsOnly && referenceIdentityChanged) - mutualRecursionRefreshNeeded = true; - writer.InsertChunks([], cancellationToken); - writer.InsertSymbols([], cancellationToken); - writer.InsertReferencesInAtomicFileScope([], refreshMutualRecursionFlags: false, cancellationToken); - writer.InsertIssues(fileId, - [ - new FileIssue + var skippedPersistence = PersistSkippedUpdateFile( + new SkippedUpdateFilePersistenceContext { - Path = fileTooLarge.RelativePath, - Kind = "file_too_large", - Line = 0, - Message = fileTooLarge.Message, - }, - ]); - writer.ClearBatchInProgress(); - txn.Commit(); - RecordDynamicGraphFileRefresh(skippedRecord.Lang); - skippedOversizedBatchMarkerOwned = false; + Writer = writer, + Indexer = indexer, + Options = options, + AbsolutePath = absPath, + RelativePath = relPath, + KnownLanguage = knownLanguage, + ProjectRootWritten = projectRootWritten, + TransactionName = "update skipped oversized file", + WorkspaceChangedMessage = "The C# file changed while recording its oversized skip state.", + Issue = new FileIssue + { + Path = fileTooLarge.RelativePath, + Kind = "file_too_large", + Line = 0, + Message = fileTooLarge.Message, + }, + TargetIndex = targetIndex, + ReadableFileBytes = readableFileBytes, + CancellationToken = cancellationToken, + ValidateSkippedRecord = skippedRecord => + !hasCSharpWorkspaceSnapshot + || (skippedRecord.Lang == "csharp" + && CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + absPath, + dbPath, + relPath, + skippedRecord.Size, + skippedRecord.Modified, + csharpWorkspaceSnapshots!, + out _, + cancellationToken)), + PurgeStaleUpdateCleanupPaths = PurgeStaleUpdateCleanupPaths, + RequireTypeScriptAugmentationRefresh = RequireTypeScriptAugmentationRefresh, + WriteProjectRootOnce = WriteProjectRootOnce, + RecordDynamicGraphFileRefresh = RecordDynamicGraphFileRefresh, + }); + mutualRecursionRefreshNeeded |= + skippedPersistence.MutualRecursionRefreshNeeded; } catch (CSharpWorkspaceChangedException workspaceChanged) { @@ -1602,15 +1575,6 @@ or IndexInterruptedException RecordUpdateFileFailure(relPath, currentUpdatePhase, skippedWriteException); continue; } - finally - { - // Match the binary path: rollback preserves the prior row and the - // independently committed marker is cleared after transaction disposal. - // binary 経路と同様、旧 row を rollback で保持し、marker は dispose 後に解除する。 - if (skippedOversizedBatchMarkerOwned) - writer.ClearBatchInProgress(); - } - updated++; ftsMutated = true; continue; From eaad1b4eeb6b4f33fad723614e6f6bd9ac741819 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 23:43:07 +0900 Subject: [PATCH 086/101] Separate update target processing loop --- .../Cli/IndexCommandRunner.Update.FileLoop.cs | 894 ++++++++++++++++++ .../Cli/IndexCommandRunner.Update.cs | 784 +-------------- 2 files changed, 943 insertions(+), 735 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.Update.FileLoop.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.FileLoop.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.FileLoop.cs new file mode 100644 index 000000000..48515ca9d --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.FileLoop.cs @@ -0,0 +1,894 @@ +using System.Diagnostics; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class UpdateFileLoopContext + { + internal required DbWriter Writer { get; init; } + internal required FileIndexer Indexer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required Stopwatch Stopwatch { get; init; } + internal required string ProjectRoot { get; init; } + internal List? IndexRunDiagnostics { get; init; } + internal required IReadOnlyCollection TargetPaths { get; init; } + internal required IndexProgressReporter UpdateProgress { get; init; } + internal required List MemorySamples { get; init; } + internal required int Updated { get; init; } + internal required int Removed { get; init; } + internal required int Skipped { get; init; } + internal required bool FtsMutated { get; init; } + internal required bool MutualRecursionRefreshNeeded { get; init; } + internal required bool CSharpMetadataTargetsNeedRefresh { get; init; } + internal required int SymbolsDroppedByKindFilter { get; init; } + internal required CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace { get; init; } + internal Dictionary? CSharpWorkspaceSnapshots { get; init; } + internal IReadOnlyDictionary? ScannedUpdateLanguages { get; init; } + internal required bool SymbolKindFilterMatchesPrior { get; init; } + internal required bool CSharpSymbolNameContractMatchesCurrent { get; init; } + internal required bool SqlGraphContractMatchesCurrent { get; init; } + internal required bool HdlGraphContractMatchesCurrent { get; init; } + internal required LazyDisposable PostExtractionHooks { get; init; } + internal required HashSet VisitedFileIdentities { get; init; } + internal required List ErrorList { get; init; } + internal required List FileErrorList { get; init; } + internal required List WarningList { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required Action, string> RecordScanErrors { get; init; } + internal required Action RecordCSharpWorkspaceDrift { get; init; } + internal required Action DemoteReadinessOnce { get; init; } + internal required Action WriteProjectRootOnce { get; init; } + internal required Action RequireTypeScriptAugmentationRefresh { get; init; } + internal required Func PurgeStaleUpdateCleanupPaths { get; init; } + internal required Action RecordDynamicGraphFileRefresh { get; init; } + internal required Action RecordUpdateFileFailure { get; init; } + internal required Func IsProjectRootWritten { get; init; } + } + + private sealed record UpdateFileLoopResult( + int Updated, + int Removed, + int Skipped, + int Warnings, + int Errors, + bool FtsMutated, + bool MutualRecursionRefreshNeeded, + bool CSharpMetadataTargetsNeedRefresh, + int SymbolsDroppedByKindFilter, + ReadableFileByteTracker ReadableFileBytes); + + private static UpdateFileLoopResult RunUpdateFileLoop(UpdateFileLoopContext context) + { + var writer = context.Writer; + var indexer = context.Indexer; + var options = context.Options; + var stopwatch = context.Stopwatch; + var projectRoot = context.ProjectRoot; + var indexRunDiagnostics = context.IndexRunDiagnostics; + var targetPaths = context.TargetPaths; + var updateProgress = context.UpdateProgress; + var memorySamples = context.MemorySamples; + var updated = context.Updated; + var removed = context.Removed; + var skipped = context.Skipped; + var warnings = 0; + var errors = 0; + var ftsMutated = context.FtsMutated; + var mutualRecursionRefreshNeeded = context.MutualRecursionRefreshNeeded; + var csharpMetadataTargetsNeedRefresh = context.CSharpMetadataTargetsNeedRefresh; + var symbolsDroppedByKindFilter = context.SymbolsDroppedByKindFilter; + var csharpWorkspace = context.CSharpWorkspace; + var csharpWorkspaceSnapshots = context.CSharpWorkspaceSnapshots; + var scannedUpdateLanguages = context.ScannedUpdateLanguages; + var symbolKindFilterMatchesPrior = context.SymbolKindFilterMatchesPrior; + var csharpSymbolNameContractMatchesCurrent = + context.CSharpSymbolNameContractMatchesCurrent; + var sqlGraphContractMatchesCurrent = context.SqlGraphContractMatchesCurrent; + var hdlGraphContractMatchesCurrent = context.HdlGraphContractMatchesCurrent; + var postExtractionHooks = context.PostExtractionHooks; + var visitedFileIdentities = context.VisitedFileIdentities; + var errorList = context.ErrorList; + var fileErrorList = context.FileErrorList; + var warningList = context.WarningList; + var cancellationToken = context.CancellationToken; + + void RecordScanErrors( + IEnumerable scanErrors, + string fatalPhase = "discovery") + => context.RecordScanErrors(scanErrors, fatalPhase); + + void RecordCSharpWorkspaceDrift( + string relativePath, + string detail, + string fatalPhase = "reading") + => context.RecordCSharpWorkspaceDrift(relativePath, detail, fatalPhase); + + void DemoteReadinessOnce() => context.DemoteReadinessOnce(); + void WriteProjectRootOnce() => context.WriteProjectRootOnce(); + void RequireTypeScriptAugmentationRefresh() + => context.RequireTypeScriptAugmentationRefresh(); + + int PurgeStaleUpdateCleanupPaths( + string retainedRelativePath, + string? checksum, + bool includeDirectoryAndStem) + => context.PurgeStaleUpdateCleanupPaths( + retainedRelativePath, + checksum, + includeDirectoryAndStem); + + void RecordDynamicGraphFileRefresh(string? language) + => context.RecordDynamicGraphFileRefresh(language); + + void RecordUpdateFileFailure( + string relativePath, + string phase, + Exception exception) + => context.RecordUpdateFileFailure(relativePath, phase, exception); + + void ThrowIfUpdateCancelled() + { + if (!cancellationToken.IsCancellationRequested) + return; + + updateProgress.Pause(); + throw new IndexInterruptedException(updated + removed, targetPaths.Count); + } + + updateProgress.Start(); + + var updateTargets = new UpdateFileTarget[targetPaths.Count]; + var updateTargetIndex = 0; + foreach (var targetPath in targetPaths) + updateTargets[updateTargetIndex++] = UpdateFileTarget.Create(projectRoot, targetPath); + var readableFileBytes = new ReadableFileByteTracker( + updateTargets.Length, + targetIndex => updateTargets[targetIndex].FilePath, + projectRoot, + indexRunDiagnostics); + + WriteIndexJsonLiveness(options, $"updating {ConsoleUi.Counted(targetPaths.Count, "file")}..."); + string? currentUpdatePath = null; + var currentUpdatePhase = "preparing"; + var updateHeartbeat = StartIndexJsonPhaseHeartbeat( + options, + "updating index", + () => currentUpdatePath == null + ? $"{updated + removed + skipped:N0}/{targetPaths.Count:N0} files processed" + : $"{updated + removed + skipped:N0}/{targetPaths.Count:N0} files processed, current {currentUpdatePath}"); + using var symbolExtractionWorker = new LazyDisposable(() => + { + UpdateExtractionWorkStartedForTesting?.Invoke(); + return new SymbolExtractionWorkerClient(options.MaxFileSizeBytes); + }); + try + { + for (var targetIndex = 0; targetIndex < updateTargets.Length; targetIndex++) + { + var target = updateTargets[targetIndex]; + ThrowIfUpdateCancelled(); + updateProgress.Start(); + var relPath = target.RelativePath; + currentUpdatePath = relPath; + currentUpdatePhase = "preparing"; + var absPath = target.FilePath; + var dbPath = target.IndexPath; + var fileBatchMarked = false; + string? knownLanguage = null; + CSharpStaticInterfacePrepass.FileStatSnapshot csharpWorkspaceSnapshot = default; + var hasCSharpWorkspaceSnapshot = csharpWorkspaceSnapshots != null + && csharpWorkspaceSnapshots.TryGetValue(dbPath, out csharpWorkspaceSnapshot); + try + { + if (hasCSharpWorkspaceSnapshot + && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + absPath, + dbPath, + relPath, + csharpWorkspaceSnapshot.Size, + csharpWorkspaceSnapshot.ModifiedUtc, + csharpWorkspaceSnapshots!, + out _, + cancellationToken)) + { + RecordCSharpWorkspaceDrift( + relPath, + "The C# file changed before its authoritative update pass."); + skipped++; + continue; + } + + if (!File.Exists(LongPath.EnsureWindowsPrefix(absPath))) + { + if (hasCSharpWorkspaceSnapshot) + { + RecordCSharpWorkspaceDrift( + relPath, + "The C# file disappeared after contract preflight."); + skipped++; + continue; + } + + using var deleteTxn = writer.BeginTransaction(cancellationToken, "update delete missing target"); + if (writer.DeleteFileByPath(dbPath)) + { + DemoteReadinessOnce(); + WriteProjectRootOnce(); + RequireTypeScriptAugmentationRefresh(); + deleteTxn.Commit(); + removed++; + ftsMutated = true; + mutualRecursionRefreshNeeded = true; + updateProgress.WriteVerbose($" [DEL ] {relPath}"); + } + else + { + skipped++; + updateProgress.WriteVerbose($" [SKIP] {relPath} (not in DB)"); + } + continue; + } + + var pathFilter = indexer.EvaluatePathFilter(absPath); + RecordScanErrors(pathFilter.Errors); + if (pathFilter.ShouldSkip) + { + if (!pathFilter.ShouldDeleteExisting) + { + skipped++; + if (options.Verbose && !options.Json && !options.Quiet) + { + updateProgress.Pause(); + CommandOutputWriter.WriteLine($" [SKIP] {relPath} ({DescribePathFilter(pathFilter.FilterKind)})"); + updateProgress.Resume(); + } + continue; + } + + using var deleteTxn = writer.BeginTransaction(cancellationToken, "update delete skipped path"); + if (writer.DeleteFileByPath(dbPath)) + { + DemoteReadinessOnce(); + WriteProjectRootOnce(); + RequireTypeScriptAugmentationRefresh(); + deleteTxn.Commit(); + removed++; + ftsMutated = true; + mutualRecursionRefreshNeeded = true; + if (options.Verbose && !options.Json && !options.Quiet) + { + updateProgress.Pause(); + CommandOutputWriter.WriteLine($" [DEL ] {relPath} ({DescribePathFilter(pathFilter.FilterKind)})"); + updateProgress.Resume(); + } + } + else + { + skipped++; + if (options.Verbose && !options.Json) + { + updateProgress.Pause(); + CommandOutputWriter.WriteLine($" [SKIP] {relPath} ({DescribePathFilter(pathFilter.FilterKind)})"); + updateProgress.Resume(); + } + } + continue; + } + + var indexability = indexer.GetFileIndexabilityForIndexing(absPath); + var detection = indexer.TryDetectLanguageForIndexing(absPath, knownIndexability: indexability); + if (hasCSharpWorkspaceSnapshot + && (indexability != FileIndexer.FileProbeStatus.Supported + || detection.Status != FileIndexer.FileProbeStatus.Supported + || detection.Language != "csharp")) + { + RecordCSharpWorkspaceDrift( + relPath, + "The C# file changed language or indexability after contract preflight."); + skipped++; + continue; + } + if (!hasCSharpWorkspaceSnapshot + && csharpWorkspaceSnapshots != null + && indexability == FileIndexer.FileProbeStatus.Supported + && detection.Status == FileIndexer.FileProbeStatus.Supported + && detection.Language == "csharp") + { + RecordCSharpWorkspaceDrift( + relPath, + "A C# target appeared after the authoritative workspace target set was captured."); + skipped++; + continue; + } + if (indexability == FileIndexer.FileProbeStatus.Missing || detection.Status == FileIndexer.FileProbeStatus.Missing) + { + var message = $"{relPath}: skipped because it was deleted during indexing."; + warnings++; + warningList.Add(new CliJsonMessage(relPath, message)); + if (!options.Json && !options.Quiet) + { + updateProgress.Pause(); + ConsoleUi.PrintWarning(message); + updateProgress.Resume(); + } + + using var deleteTxn = writer.BeginTransaction(cancellationToken, "update delete missing during probe"); + if (writer.DeleteFileByPath(dbPath)) + { + DemoteReadinessOnce(); + WriteProjectRootOnce(); + RequireTypeScriptAugmentationRefresh(); + deleteTxn.Commit(); + removed++; + ftsMutated = true; + mutualRecursionRefreshNeeded = true; + } + else + { + skipped++; + } + continue; + } + + if (indexability == FileIndexer.FileProbeStatus.ProbeFailed || detection.Status == FileIndexer.FileProbeStatus.ProbeFailed) + { + DemoteReadinessOnce(); + + errors++; + errorList.Add(new CliJsonMessage(relPath, "Could not probe file for indexability/language.")); + if (fileErrorList.Count < PartialIndexFileErrorLimit) + { + fileErrorList.Add(new StatusIndexFileError + { + File = FileIndexer.NormalizePathSeparators(relPath), + Category = "file_read_error", + Phase = "reading", + Detail = "Could not probe file for indexability/language.", + }); + } + if (!options.Json) + { + updateProgress.Pause(); + if (options.Verbose) + CommandErrorWriter.WriteStderr($" [ERR ] {relPath}: Could not probe file for indexability/language."); + else + CommandErrorWriter.WriteStderr($" [ERR ] {relPath}: Could not probe file for indexability/language."); + updateProgress.Resume(); + } + continue; + } + + if (indexability != FileIndexer.FileProbeStatus.Supported || detection.Status != FileIndexer.FileProbeStatus.Supported) + { + if (!writer.HasFileAtPath(dbPath)) + { + using var purgeTxn = writer.BeginTransaction(cancellationToken, "update purge unsupported renamed target"); + var purged = PurgeStaleUpdateCleanupPaths( + dbPath, + checksum: null, + includeDirectoryAndStem: context.IsProjectRootWritten()); + if (purged > 0) + { + DemoteReadinessOnce(); + WriteProjectRootOnce(); + RequireTypeScriptAugmentationRefresh(); + purgeTxn.Commit(); + removed += purged; + ftsMutated = true; + mutualRecursionRefreshNeeded = true; + if (options.Verbose && !options.Json && !options.Quiet) + { + updateProgress.Pause(); + CommandOutputWriter.WriteLine($" [DEL ] {relPath} (unsupported renamed target)"); + updateProgress.Resume(); + } + } + else + { + skipped++; + if (options.Verbose && !options.Json && !options.Quiet) + { + updateProgress.Pause(); + CommandOutputWriter.WriteLine($" [SKIP] {relPath} (unsupported type)"); + updateProgress.Resume(); + } + } + continue; + } + + DemoteReadinessOnce(); + using var deleteTxn = writer.BeginTransaction(cancellationToken, "update delete unsupported target"); + if (writer.DeleteFileByPath(dbPath)) + { + WriteProjectRootOnce(); + RequireTypeScriptAugmentationRefresh(); + deleteTxn.Commit(); + removed++; + ftsMutated = true; + mutualRecursionRefreshNeeded = true; + if (options.Verbose && !options.Json && !options.Quiet) + { + updateProgress.Pause(); + CommandOutputWriter.WriteLine($" [DEL ] {relPath} (no longer indexable)"); + updateProgress.Resume(); + } + } + else + { + skipped++; + if (options.Verbose && !options.Json) + { + updateProgress.Pause(); + CommandOutputWriter.WriteLine($" [SKIP] {relPath} (unsupported type)"); + updateProgress.Resume(); + } + } + continue; + } + + if (FileIndexer.TryGetFileIdentity(absPath, out var identity, out var linkCount) + && linkCount > 1 + && !visitedFileIdentities.Add(identity)) + { + var message = "Skipped hardlinked file because the same file content was already indexed from another path."; + warnings++; + warningList.Add(new CliJsonMessage(relPath, message)); + if (!options.Json && !options.Quiet) + { + updateProgress.Pause(); + ConsoleUi.PrintWarning($"{relPath}: {message}"); + updateProgress.Resume(); + } + + using var deleteTxn = writer.BeginTransaction(); + if (writer.DeleteFileByPath(dbPath)) + { + DemoteReadinessOnce(); + WriteProjectRootOnce(); + RequireTypeScriptAugmentationRefresh(); + deleteTxn.Commit(); + removed++; + ftsMutated = true; + mutualRecursionRefreshNeeded = true; + } + else + { + skipped++; + } + continue; + } + + var statReusableLanguage = GetStatReusableLanguage(absPath, detection); + var generatedExtractionSuppressed = indexer.IsGeneratedCodeExtractionSuppressed(dbPath); + var statMatchedFile = IndexedFileStatReuse.TryGetReusableUnchangedFile( + writer, + absPath, + dbPath, + statReusableLanguage, + options.MaxSymbolsPerFile, + options.MaxReferencesPerFile, + generatedExtractionSuppressed, + allowReuse: symbolKindFilterMatchesPrior + && (statReusableLanguage != "csharp" || csharpSymbolNameContractMatchesCurrent) + && (statReusableLanguage != "csharp" || !csharpWorkspace.HasStaticInterfaceContracts) + && (statReusableLanguage != "sql" || sqlGraphContractMatchesCurrent) + && (statReusableLanguage is not ("verilog" or "systemverilog" or "vhdl") || hdlGraphContractMatchesCurrent)); + if (statMatchedFile != null) + { + skipped++; + readableFileBytes.Remember(targetIndex, statMatchedFile.Value.Size); + if (options.Verbose && !options.Json && !options.Quiet) + { + updateProgress.Pause(); + CommandOutputWriter.WriteLine($" [SKIP] {relPath} (unchanged)"); + updateProgress.Resume(); + } + continue; + } + + knownLanguage = scannedUpdateLanguages == null + ? statReusableLanguage + : FileIndexer.GetReusableDetectedLanguage(absPath, scannedUpdateLanguages); + + currentUpdatePhase = "reading"; + UpdateFileContentLoadForTesting?.Invoke(relPath); + var loaded = indexer.BuildLoadedRecordWithRawBytes( + absPath, + relPath, + knownLanguage, + cancellationToken); + var record = loaded.Record; + if (hasCSharpWorkspaceSnapshot + && (record.Lang != "csharp" + || !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + absPath, + dbPath, + relPath, + record.Size, + record.Modified, + csharpWorkspaceSnapshots!, + out _, + cancellationToken))) + { + RecordCSharpWorkspaceDrift( + relPath, + "The C# file changed while the authoritative update pass was reading it."); + skipped++; + continue; + } + readableFileBytes.Remember(targetIndex, record.Size); + var warning = loaded.Warning; + var generatedSuppressionIssue = generatedExtractionSuppressed + ? indexer.BuildGeneratedCodeExtractionSkippedIssue(record.Path) + : null; + + if (warning != null && !options.Json && !options.Quiet) + { + updateProgress.Pause(); + ConsoleUi.PrintWarning(warning); + updateProgress.Resume(); + } + + var existingId = writer.GetReusableUnchangedFileId( + record.Path, + record.Modified, + record.Checksum, + size: record.Size, + lines: record.Lines, + language: record.Lang, + generated: record.Generated, + maxSymbolsPerFile: options.MaxSymbolsPerFile, + maxReferencesPerFile: options.MaxReferencesPerFile, + generatedExtractionSuppressed: generatedExtractionSuppressed, + allowReuse: symbolKindFilterMatchesPrior + && (record.Lang != "csharp" || csharpSymbolNameContractMatchesCurrent) + && (record.Lang != "csharp" || !csharpWorkspace.HasStaticInterfaceContracts) + && (record.Lang != "sql" || sqlGraphContractMatchesCurrent) + && (record.Lang is not ("verilog" or "systemverilog" or "vhdl") || hdlGraphContractMatchesCurrent)); + if (existingId != null) + { + using var purgeTxn = writer.BeginTransaction(cancellationToken, "update purge unchanged stale paths"); + var purged = PurgeStaleUpdateCleanupPaths( + record.Path, + record.Checksum, + includeDirectoryAndStem: context.IsProjectRootWritten()); + if (purged > 0) + { + DemoteReadinessOnce(); + WriteProjectRootOnce(); + RequireTypeScriptAugmentationRefresh(); + purgeTxn.Commit(); + removed += purged; + ftsMutated = true; + mutualRecursionRefreshNeeded = true; + } + skipped++; + if (options.Verbose && !options.Json && !options.Quiet) + { + updateProgress.Pause(); + CommandOutputWriter.WriteLine(purged > 0 + ? $" [SKIP] {relPath} (unchanged; purged {purged:N0} stale renamed path(s))" + : $" [SKIP] {relPath} (unchanged)"); + updateProgress.Resume(); + } + continue; + } + + DemoteReadinessOnce(); + if (record.Lang == "csharp") + csharpMetadataTargetsNeedRefresh = true; + var persistence = PersistUpdateFile(new UpdateFilePersistenceContext + { + Writer = writer, + Indexer = indexer, + Options = options, + ProjectRoot = projectRoot, + RelativePath = relPath, + AbsolutePath = absPath, + Record = record, + Loaded = loaded, + GeneratedSuppressionIssue = generatedSuppressionIssue, + CSharpWorkspace = csharpWorkspace, + PostExtractionHooks = postExtractionHooks.Value, + SymbolExtractionWorker = symbolExtractionWorker.Value, + ProjectRootWritten = context.IsProjectRootWritten(), + CancellationToken = cancellationToken, + RequireTypeScriptAugmentationRefresh = RequireTypeScriptAugmentationRefresh, + PurgeStaleUpdateCleanupPaths = PurgeStaleUpdateCleanupPaths, + WriteProjectRootOnce = WriteProjectRootOnce, + RecordDynamicGraphFileRefresh = RecordDynamicGraphFileRefresh, + SetBatchMarkerOwned = owned => fileBatchMarked = owned, + SetPhase = (path, phase) => + { + currentUpdatePath = path; + currentUpdatePhase = phase; + }, + }); + symbolsDroppedByKindFilter += persistence.SymbolsDroppedByKindFilter; + mutualRecursionRefreshNeeded |= persistence.MutualRecursionRefreshNeeded; + updated++; + ftsMutated = true; + UpdateFileCommittedForTesting?.Invoke(updated + removed, targetPaths.Count); + ThrowIfUpdateCancelled(); + updateProgress.WriteVerbose(persistence.VerboseMessage); + } + catch (IndexExtractionStalledException) + { + throw; + } + catch (Exception ex) + { + if (ex is CSharpWorkspaceChangedException) + { + if (fileBatchMarked) + writer.ClearBatchInProgress(); + RecordCSharpWorkspaceDrift(relPath, ex.Message); + skipped++; + continue; + } + + if (ex is FileIndexer.BinaryFileSkippedException binaryFile) + { + if (fileBatchMarked) + writer.ClearBatchInProgress(); + + if (hasCSharpWorkspaceSnapshot + && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + absPath, + dbPath, + relPath, + csharpWorkspaceSnapshot.Size, + csharpWorkspaceSnapshot.ModifiedUtc, + csharpWorkspaceSnapshots!, + out _, + cancellationToken)) + { + RecordCSharpWorkspaceDrift( + relPath, + "The C# file changed to binary content after contract preflight."); + skipped++; + continue; + } + + warnings++; + var sanitizedMessage = CommandErrorWriter.FormatSanitizedExceptionMessage(ex); + warningList.Add(new CliJsonMessage(relPath, sanitizedMessage)); + if (!options.Json && !options.Quiet) + { + updateProgress.Pause(); + ConsoleUi.PrintWarning(sanitizedMessage); + updateProgress.Resume(); + } + + DemoteReadinessOnce(); + currentUpdatePhase = "writing"; + try + { + var skippedPersistence = PersistSkippedUpdateFile( + new SkippedUpdateFilePersistenceContext + { + Writer = writer, + Indexer = indexer, + Options = options, + AbsolutePath = absPath, + RelativePath = relPath, + KnownLanguage = knownLanguage, + ProjectRootWritten = context.IsProjectRootWritten(), + TransactionName = "update skipped binary", + WorkspaceChangedMessage = "The C# file changed while recording its binary skip state.", + Issue = BuildNullByteIssue(binaryFile), + TargetIndex = targetIndex, + ReadableFileBytes = readableFileBytes, + CancellationToken = cancellationToken, + ValidateSkippedRecord = skippedRecord => + !hasCSharpWorkspaceSnapshot + || (skippedRecord.Lang == "csharp" + && CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + absPath, + dbPath, + relPath, + skippedRecord.Size, + skippedRecord.Modified, + csharpWorkspaceSnapshots!, + out _, + cancellationToken)), + PurgeStaleUpdateCleanupPaths = PurgeStaleUpdateCleanupPaths, + RequireTypeScriptAugmentationRefresh = RequireTypeScriptAugmentationRefresh, + WriteProjectRootOnce = WriteProjectRootOnce, + RecordDynamicGraphFileRefresh = RecordDynamicGraphFileRefresh, + }); + mutualRecursionRefreshNeeded |= + skippedPersistence.MutualRecursionRefreshNeeded; + } + catch (CSharpWorkspaceChangedException workspaceChanged) + { + RecordCSharpWorkspaceDrift(relPath, workspaceChanged.Message); + skipped++; + continue; + } + catch (Exception skippedWriteException) + { + if (skippedWriteException is IndexExtractionStalledException + or IndexInterruptedException + or OperationCanceledException) + { + throw; + } + + RecordUpdateFileFailure(relPath, currentUpdatePhase, skippedWriteException); + continue; + } + updated++; + ftsMutated = true; + continue; + } + + if (ex is FileIndexer.FileTooLargeSkippedException fileTooLarge) + { + if (fileBatchMarked) + writer.ClearBatchInProgress(); + + if (hasCSharpWorkspaceSnapshot + && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + absPath, + dbPath, + relPath, + csharpWorkspaceSnapshot.Size, + csharpWorkspaceSnapshot.ModifiedUtc, + csharpWorkspaceSnapshots!, + out _, + cancellationToken)) + { + RecordCSharpWorkspaceDrift( + relPath, + "The C# file changed size or timestamp after contract preflight."); + skipped++; + continue; + } + + DemoteReadinessOnce(); + currentUpdatePhase = "writing"; + try + { + var skippedPersistence = PersistSkippedUpdateFile( + new SkippedUpdateFilePersistenceContext + { + Writer = writer, + Indexer = indexer, + Options = options, + AbsolutePath = absPath, + RelativePath = relPath, + KnownLanguage = knownLanguage, + ProjectRootWritten = context.IsProjectRootWritten(), + TransactionName = "update skipped oversized file", + WorkspaceChangedMessage = "The C# file changed while recording its oversized skip state.", + Issue = new FileIssue + { + Path = fileTooLarge.RelativePath, + Kind = "file_too_large", + Line = 0, + Message = fileTooLarge.Message, + }, + TargetIndex = targetIndex, + ReadableFileBytes = readableFileBytes, + CancellationToken = cancellationToken, + ValidateSkippedRecord = skippedRecord => + !hasCSharpWorkspaceSnapshot + || (skippedRecord.Lang == "csharp" + && CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + absPath, + dbPath, + relPath, + skippedRecord.Size, + skippedRecord.Modified, + csharpWorkspaceSnapshots!, + out _, + cancellationToken)), + PurgeStaleUpdateCleanupPaths = PurgeStaleUpdateCleanupPaths, + RequireTypeScriptAugmentationRefresh = RequireTypeScriptAugmentationRefresh, + WriteProjectRootOnce = WriteProjectRootOnce, + RecordDynamicGraphFileRefresh = RecordDynamicGraphFileRefresh, + }); + mutualRecursionRefreshNeeded |= + skippedPersistence.MutualRecursionRefreshNeeded; + } + catch (CSharpWorkspaceChangedException workspaceChanged) + { + RecordCSharpWorkspaceDrift(relPath, workspaceChanged.Message); + skipped++; + continue; + } + catch (Exception skippedWriteException) + { + if (skippedWriteException is IndexExtractionStalledException + or IndexInterruptedException + or OperationCanceledException) + { + throw; + } + + RecordUpdateFileFailure(relPath, currentUpdatePhase, skippedWriteException); + continue; + } + updated++; + ftsMutated = true; + continue; + } + + if (ex is FileNotFoundException or DirectoryNotFoundException) + { + if (fileBatchMarked) + writer.ClearBatchInProgress(); + + if (hasCSharpWorkspaceSnapshot) + { + RecordCSharpWorkspaceDrift( + relPath, + "The C# file disappeared during its authoritative update pass."); + skipped++; + continue; + } + + var message = $"{relPath}: skipped because it was deleted during indexing."; + warnings++; + warningList.Add(new CliJsonMessage(relPath, message)); + if (!options.Json && !options.Quiet) + { + updateProgress.Pause(); + ConsoleUi.PrintWarning(message); + updateProgress.Resume(); + } + + if (writer.HasFileAtPath(dbPath)) + { + DemoteReadinessOnce(); + using var deleteTxn = writer.BeginTransaction(cancellationToken, "update delete missing during write"); + if (writer.DeleteFileByPath(dbPath)) + { + WriteProjectRootOnce(); + RequireTypeScriptAugmentationRefresh(); + deleteTxn.Commit(); + removed++; + ftsMutated = true; + mutualRecursionRefreshNeeded = true; + } + } + else + { + skipped++; + } + continue; + } + + if (fileBatchMarked) + writer.ClearBatchInProgress(); + RecordUpdateFileFailure(relPath, currentUpdatePhase, ex); + } + } + } + finally + { + StopIndexJsonPhaseHeartbeat(updateHeartbeat); + } + if (options.MemoryTrace) + memorySamples.Add(CaptureMemorySample("extraction", stopwatch)); + return new UpdateFileLoopResult( + updated, + removed, + skipped, + warnings, + errors, + ftsMutated, + mutualRecursionRefreshNeeded, + csharpMetadataTargetsNeedRefresh, + symbolsDroppedByKindFilter, + readableFileBytes); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index f8132b44e..fc12698a3 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -900,743 +900,57 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) purgeTxn.Commit(); } - updateProgress.Start(); - - var updateTargets = new UpdateFileTarget[targetPaths.Count]; - var updateTargetIndex = 0; - foreach (var targetPath in targetPaths) - updateTargets[updateTargetIndex++] = UpdateFileTarget.Create(projectRoot, targetPath); - var readableFileBytes = new ReadableFileByteTracker( - updateTargets.Length, - targetIndex => updateTargets[targetIndex].FilePath, - projectRoot, - indexRunDiagnostics); - - WriteIndexJsonLiveness(options, $"updating {ConsoleUi.Counted(targetPaths.Count, "file")}..."); - string? currentUpdatePath = null; - var currentUpdatePhase = "preparing"; - var updateHeartbeat = StartIndexJsonPhaseHeartbeat( - options, - "updating index", - () => currentUpdatePath == null - ? $"{updated + removed + skipped:N0}/{targetPaths.Count:N0} files processed" - : $"{updated + removed + skipped:N0}/{targetPaths.Count:N0} files processed, current {currentUpdatePath}"); - using var symbolExtractionWorker = new LazyDisposable(() => + var updateLoop = RunUpdateFileLoop(new UpdateFileLoopContext { - UpdateExtractionWorkStartedForTesting?.Invoke(); - return new SymbolExtractionWorkerClient(options.MaxFileSizeBytes); + Writer = writer, + Indexer = indexer, + Options = options, + Stopwatch = stopwatch, + ProjectRoot = projectRoot, + IndexRunDiagnostics = indexRunDiagnostics, + TargetPaths = targetPaths, + UpdateProgress = updateProgress, + MemorySamples = memorySamples, + Updated = updated, + Removed = removed, + Skipped = skipped, + FtsMutated = ftsMutated, + MutualRecursionRefreshNeeded = mutualRecursionRefreshNeeded, + CSharpMetadataTargetsNeedRefresh = csharpMetadataTargetsNeedRefresh, + SymbolsDroppedByKindFilter = symbolsDroppedByKindFilter, + CSharpWorkspace = csharpWorkspace, + CSharpWorkspaceSnapshots = csharpWorkspaceSnapshots, + ScannedUpdateLanguages = scannedUpdateLanguages, + SymbolKindFilterMatchesPrior = symbolKindFilterMatchesPrior, + CSharpSymbolNameContractMatchesCurrent = csharpSymbolNameContractMatchesCurrent, + SqlGraphContractMatchesCurrent = sqlGraphContractMatchesCurrent, + HdlGraphContractMatchesCurrent = hdlGraphContractMatchesCurrent, + PostExtractionHooks = postExtractionHooks, + VisitedFileIdentities = visitedFileIdentities, + ErrorList = errorList, + FileErrorList = fileErrorList, + WarningList = warningList, + CancellationToken = cancellationToken, + RecordScanErrors = RecordScanErrors, + RecordCSharpWorkspaceDrift = RecordCSharpWorkspaceDrift, + DemoteReadinessOnce = DemoteReadinessOnce, + WriteProjectRootOnce = WriteProjectRootOnce, + RequireTypeScriptAugmentationRefresh = RequireTypeScriptAugmentationRefresh, + PurgeStaleUpdateCleanupPaths = PurgeStaleUpdateCleanupPaths, + RecordDynamicGraphFileRefresh = RecordDynamicGraphFileRefresh, + RecordUpdateFileFailure = RecordUpdateFileFailure, + IsProjectRootWritten = () => projectRootWritten, }); - try - { - for (var targetIndex = 0; targetIndex < updateTargets.Length; targetIndex++) - { - var target = updateTargets[targetIndex]; - ThrowIfUpdateCancelled(); - updateProgress.Start(); - var relPath = target.RelativePath; - currentUpdatePath = relPath; - currentUpdatePhase = "preparing"; - var absPath = target.FilePath; - var dbPath = target.IndexPath; - var fileBatchMarked = false; - string? knownLanguage = null; - CSharpStaticInterfacePrepass.FileStatSnapshot csharpWorkspaceSnapshot = default; - var hasCSharpWorkspaceSnapshot = csharpWorkspaceSnapshots != null - && csharpWorkspaceSnapshots.TryGetValue(dbPath, out csharpWorkspaceSnapshot); - try - { - if (hasCSharpWorkspaceSnapshot - && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - absPath, - dbPath, - relPath, - csharpWorkspaceSnapshot.Size, - csharpWorkspaceSnapshot.ModifiedUtc, - csharpWorkspaceSnapshots!, - out _, - cancellationToken)) - { - RecordCSharpWorkspaceDrift( - relPath, - "The C# file changed before its authoritative update pass."); - skipped++; - continue; - } - - if (!File.Exists(LongPath.EnsureWindowsPrefix(absPath))) - { - if (hasCSharpWorkspaceSnapshot) - { - RecordCSharpWorkspaceDrift( - relPath, - "The C# file disappeared after contract preflight."); - skipped++; - continue; - } - - using var deleteTxn = writer.BeginTransaction(cancellationToken, "update delete missing target"); - if (writer.DeleteFileByPath(dbPath)) - { - DemoteReadinessOnce(); - WriteProjectRootOnce(); - RequireTypeScriptAugmentationRefresh(); - deleteTxn.Commit(); - removed++; - ftsMutated = true; - mutualRecursionRefreshNeeded = true; - updateProgress.WriteVerbose($" [DEL ] {relPath}"); - } - else - { - skipped++; - updateProgress.WriteVerbose($" [SKIP] {relPath} (not in DB)"); - } - continue; - } - - var pathFilter = indexer.EvaluatePathFilter(absPath); - RecordScanErrors(pathFilter.Errors); - if (pathFilter.ShouldSkip) - { - if (!pathFilter.ShouldDeleteExisting) - { - skipped++; - if (options.Verbose && !options.Json && !options.Quiet) - { - updateProgress.Pause(); - CommandOutputWriter.WriteLine($" [SKIP] {relPath} ({DescribePathFilter(pathFilter.FilterKind)})"); - updateProgress.Resume(); - } - continue; - } - - using var deleteTxn = writer.BeginTransaction(cancellationToken, "update delete skipped path"); - if (writer.DeleteFileByPath(dbPath)) - { - DemoteReadinessOnce(); - WriteProjectRootOnce(); - RequireTypeScriptAugmentationRefresh(); - deleteTxn.Commit(); - removed++; - ftsMutated = true; - mutualRecursionRefreshNeeded = true; - if (options.Verbose && !options.Json && !options.Quiet) - { - updateProgress.Pause(); - CommandOutputWriter.WriteLine($" [DEL ] {relPath} ({DescribePathFilter(pathFilter.FilterKind)})"); - updateProgress.Resume(); - } - } - else - { - skipped++; - if (options.Verbose && !options.Json) - { - updateProgress.Pause(); - CommandOutputWriter.WriteLine($" [SKIP] {relPath} ({DescribePathFilter(pathFilter.FilterKind)})"); - updateProgress.Resume(); - } - } - continue; - } - - var indexability = indexer.GetFileIndexabilityForIndexing(absPath); - var detection = indexer.TryDetectLanguageForIndexing(absPath, knownIndexability: indexability); - if (hasCSharpWorkspaceSnapshot - && (indexability != FileIndexer.FileProbeStatus.Supported - || detection.Status != FileIndexer.FileProbeStatus.Supported - || detection.Language != "csharp")) - { - RecordCSharpWorkspaceDrift( - relPath, - "The C# file changed language or indexability after contract preflight."); - skipped++; - continue; - } - if (!hasCSharpWorkspaceSnapshot - && csharpWorkspaceSnapshots != null - && indexability == FileIndexer.FileProbeStatus.Supported - && detection.Status == FileIndexer.FileProbeStatus.Supported - && detection.Language == "csharp") - { - RecordCSharpWorkspaceDrift( - relPath, - "A C# target appeared after the authoritative workspace target set was captured."); - skipped++; - continue; - } - if (indexability == FileIndexer.FileProbeStatus.Missing || detection.Status == FileIndexer.FileProbeStatus.Missing) - { - var message = $"{relPath}: skipped because it was deleted during indexing."; - warnings++; - warningList.Add(new CliJsonMessage(relPath, message)); - if (!options.Json && !options.Quiet) - { - updateProgress.Pause(); - ConsoleUi.PrintWarning(message); - updateProgress.Resume(); - } - - using var deleteTxn = writer.BeginTransaction(cancellationToken, "update delete missing during probe"); - if (writer.DeleteFileByPath(dbPath)) - { - DemoteReadinessOnce(); - WriteProjectRootOnce(); - RequireTypeScriptAugmentationRefresh(); - deleteTxn.Commit(); - removed++; - ftsMutated = true; - mutualRecursionRefreshNeeded = true; - } - else - { - skipped++; - } - continue; - } - - if (indexability == FileIndexer.FileProbeStatus.ProbeFailed || detection.Status == FileIndexer.FileProbeStatus.ProbeFailed) - { - DemoteReadinessOnce(); - - errors++; - errorList.Add(new CliJsonMessage(relPath, "Could not probe file for indexability/language.")); - if (fileErrorList.Count < PartialIndexFileErrorLimit) - { - fileErrorList.Add(new StatusIndexFileError - { - File = FileIndexer.NormalizePathSeparators(relPath), - Category = "file_read_error", - Phase = "reading", - Detail = "Could not probe file for indexability/language.", - }); - } - if (!options.Json) - { - updateProgress.Pause(); - if (options.Verbose) - CommandErrorWriter.WriteStderr($" [ERR ] {relPath}: Could not probe file for indexability/language."); - else - CommandErrorWriter.WriteStderr($" [ERR ] {relPath}: Could not probe file for indexability/language."); - updateProgress.Resume(); - } - continue; - } - - if (indexability != FileIndexer.FileProbeStatus.Supported || detection.Status != FileIndexer.FileProbeStatus.Supported) - { - if (!writer.HasFileAtPath(dbPath)) - { - using var purgeTxn = writer.BeginTransaction(cancellationToken, "update purge unsupported renamed target"); - var purged = PurgeStaleUpdateCleanupPaths( - dbPath, - checksum: null, - includeDirectoryAndStem: projectRootWritten); - if (purged > 0) - { - DemoteReadinessOnce(); - WriteProjectRootOnce(); - RequireTypeScriptAugmentationRefresh(); - purgeTxn.Commit(); - removed += purged; - ftsMutated = true; - mutualRecursionRefreshNeeded = true; - if (options.Verbose && !options.Json && !options.Quiet) - { - updateProgress.Pause(); - CommandOutputWriter.WriteLine($" [DEL ] {relPath} (unsupported renamed target)"); - updateProgress.Resume(); - } - } - else - { - skipped++; - if (options.Verbose && !options.Json && !options.Quiet) - { - updateProgress.Pause(); - CommandOutputWriter.WriteLine($" [SKIP] {relPath} (unsupported type)"); - updateProgress.Resume(); - } - } - continue; - } - - DemoteReadinessOnce(); - using var deleteTxn = writer.BeginTransaction(cancellationToken, "update delete unsupported target"); - if (writer.DeleteFileByPath(dbPath)) - { - WriteProjectRootOnce(); - RequireTypeScriptAugmentationRefresh(); - deleteTxn.Commit(); - removed++; - ftsMutated = true; - mutualRecursionRefreshNeeded = true; - if (options.Verbose && !options.Json && !options.Quiet) - { - updateProgress.Pause(); - CommandOutputWriter.WriteLine($" [DEL ] {relPath} (no longer indexable)"); - updateProgress.Resume(); - } - } - else - { - skipped++; - if (options.Verbose && !options.Json) - { - updateProgress.Pause(); - CommandOutputWriter.WriteLine($" [SKIP] {relPath} (unsupported type)"); - updateProgress.Resume(); - } - } - continue; - } - - if (FileIndexer.TryGetFileIdentity(absPath, out var identity, out var linkCount) - && linkCount > 1 - && !visitedFileIdentities.Add(identity)) - { - var message = "Skipped hardlinked file because the same file content was already indexed from another path."; - warnings++; - warningList.Add(new CliJsonMessage(relPath, message)); - if (!options.Json && !options.Quiet) - { - updateProgress.Pause(); - ConsoleUi.PrintWarning($"{relPath}: {message}"); - updateProgress.Resume(); - } - - using var deleteTxn = writer.BeginTransaction(); - if (writer.DeleteFileByPath(dbPath)) - { - DemoteReadinessOnce(); - WriteProjectRootOnce(); - RequireTypeScriptAugmentationRefresh(); - deleteTxn.Commit(); - removed++; - ftsMutated = true; - mutualRecursionRefreshNeeded = true; - } - else - { - skipped++; - } - continue; - } - - var statReusableLanguage = GetStatReusableLanguage(absPath, detection); - var generatedExtractionSuppressed = indexer.IsGeneratedCodeExtractionSuppressed(dbPath); - var statMatchedFile = IndexedFileStatReuse.TryGetReusableUnchangedFile( - writer, - absPath, - dbPath, - statReusableLanguage, - options.MaxSymbolsPerFile, - options.MaxReferencesPerFile, - generatedExtractionSuppressed, - allowReuse: symbolKindFilterMatchesPrior - && (statReusableLanguage != "csharp" || csharpSymbolNameContractMatchesCurrent) - && (statReusableLanguage != "csharp" || !csharpWorkspace.HasStaticInterfaceContracts) - && (statReusableLanguage != "sql" || sqlGraphContractMatchesCurrent) - && (statReusableLanguage is not ("verilog" or "systemverilog" or "vhdl") || hdlGraphContractMatchesCurrent)); - if (statMatchedFile != null) - { - skipped++; - readableFileBytes.Remember(targetIndex, statMatchedFile.Value.Size); - if (options.Verbose && !options.Json && !options.Quiet) - { - updateProgress.Pause(); - CommandOutputWriter.WriteLine($" [SKIP] {relPath} (unchanged)"); - updateProgress.Resume(); - } - continue; - } - - knownLanguage = scannedUpdateLanguages == null - ? statReusableLanguage - : FileIndexer.GetReusableDetectedLanguage(absPath, scannedUpdateLanguages); - - currentUpdatePhase = "reading"; - UpdateFileContentLoadForTesting?.Invoke(relPath); - var loaded = indexer.BuildLoadedRecordWithRawBytes( - absPath, - relPath, - knownLanguage, - cancellationToken); - var record = loaded.Record; - if (hasCSharpWorkspaceSnapshot - && (record.Lang != "csharp" - || !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - absPath, - dbPath, - relPath, - record.Size, - record.Modified, - csharpWorkspaceSnapshots!, - out _, - cancellationToken))) - { - RecordCSharpWorkspaceDrift( - relPath, - "The C# file changed while the authoritative update pass was reading it."); - skipped++; - continue; - } - readableFileBytes.Remember(targetIndex, record.Size); - var warning = loaded.Warning; - var generatedSuppressionIssue = generatedExtractionSuppressed - ? indexer.BuildGeneratedCodeExtractionSkippedIssue(record.Path) - : null; - - if (warning != null && !options.Json && !options.Quiet) - { - updateProgress.Pause(); - ConsoleUi.PrintWarning(warning); - updateProgress.Resume(); - } - - var existingId = writer.GetReusableUnchangedFileId( - record.Path, - record.Modified, - record.Checksum, - size: record.Size, - lines: record.Lines, - language: record.Lang, - generated: record.Generated, - maxSymbolsPerFile: options.MaxSymbolsPerFile, - maxReferencesPerFile: options.MaxReferencesPerFile, - generatedExtractionSuppressed: generatedExtractionSuppressed, - allowReuse: symbolKindFilterMatchesPrior - && (record.Lang != "csharp" || csharpSymbolNameContractMatchesCurrent) - && (record.Lang != "csharp" || !csharpWorkspace.HasStaticInterfaceContracts) - && (record.Lang != "sql" || sqlGraphContractMatchesCurrent) - && (record.Lang is not ("verilog" or "systemverilog" or "vhdl") || hdlGraphContractMatchesCurrent)); - if (existingId != null) - { - using var purgeTxn = writer.BeginTransaction(cancellationToken, "update purge unchanged stale paths"); - var purged = PurgeStaleUpdateCleanupPaths( - record.Path, - record.Checksum, - includeDirectoryAndStem: projectRootWritten); - if (purged > 0) - { - DemoteReadinessOnce(); - WriteProjectRootOnce(); - RequireTypeScriptAugmentationRefresh(); - purgeTxn.Commit(); - removed += purged; - ftsMutated = true; - mutualRecursionRefreshNeeded = true; - } - skipped++; - if (options.Verbose && !options.Json && !options.Quiet) - { - updateProgress.Pause(); - CommandOutputWriter.WriteLine(purged > 0 - ? $" [SKIP] {relPath} (unchanged; purged {purged:N0} stale renamed path(s))" - : $" [SKIP] {relPath} (unchanged)"); - updateProgress.Resume(); - } - continue; - } - - DemoteReadinessOnce(); - if (record.Lang == "csharp") - csharpMetadataTargetsNeedRefresh = true; - var persistence = PersistUpdateFile(new UpdateFilePersistenceContext - { - Writer = writer, - Indexer = indexer, - Options = options, - ProjectRoot = projectRoot, - RelativePath = relPath, - AbsolutePath = absPath, - Record = record, - Loaded = loaded, - GeneratedSuppressionIssue = generatedSuppressionIssue, - CSharpWorkspace = csharpWorkspace, - PostExtractionHooks = postExtractionHooks.Value, - SymbolExtractionWorker = symbolExtractionWorker.Value, - ProjectRootWritten = projectRootWritten, - CancellationToken = cancellationToken, - RequireTypeScriptAugmentationRefresh = RequireTypeScriptAugmentationRefresh, - PurgeStaleUpdateCleanupPaths = PurgeStaleUpdateCleanupPaths, - WriteProjectRootOnce = WriteProjectRootOnce, - RecordDynamicGraphFileRefresh = RecordDynamicGraphFileRefresh, - SetBatchMarkerOwned = owned => fileBatchMarked = owned, - SetPhase = (path, phase) => - { - currentUpdatePath = path; - currentUpdatePhase = phase; - }, - }); - symbolsDroppedByKindFilter += persistence.SymbolsDroppedByKindFilter; - mutualRecursionRefreshNeeded |= persistence.MutualRecursionRefreshNeeded; - updated++; - ftsMutated = true; - UpdateFileCommittedForTesting?.Invoke(updated + removed, targetPaths.Count); - ThrowIfUpdateCancelled(); - updateProgress.WriteVerbose(persistence.VerboseMessage); - } - catch (IndexExtractionStalledException) - { - throw; - } - catch (Exception ex) - { - if (ex is CSharpWorkspaceChangedException) - { - if (fileBatchMarked) - writer.ClearBatchInProgress(); - RecordCSharpWorkspaceDrift(relPath, ex.Message); - skipped++; - continue; - } - - if (ex is FileIndexer.BinaryFileSkippedException binaryFile) - { - if (fileBatchMarked) - writer.ClearBatchInProgress(); - - if (hasCSharpWorkspaceSnapshot - && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - absPath, - dbPath, - relPath, - csharpWorkspaceSnapshot.Size, - csharpWorkspaceSnapshot.ModifiedUtc, - csharpWorkspaceSnapshots!, - out _, - cancellationToken)) - { - RecordCSharpWorkspaceDrift( - relPath, - "The C# file changed to binary content after contract preflight."); - skipped++; - continue; - } - - warnings++; - var sanitizedMessage = CommandErrorWriter.FormatSanitizedExceptionMessage(ex); - warningList.Add(new CliJsonMessage(relPath, sanitizedMessage)); - if (!options.Json && !options.Quiet) - { - updateProgress.Pause(); - ConsoleUi.PrintWarning(sanitizedMessage); - updateProgress.Resume(); - } - - DemoteReadinessOnce(); - currentUpdatePhase = "writing"; - try - { - var skippedPersistence = PersistSkippedUpdateFile( - new SkippedUpdateFilePersistenceContext - { - Writer = writer, - Indexer = indexer, - Options = options, - AbsolutePath = absPath, - RelativePath = relPath, - KnownLanguage = knownLanguage, - ProjectRootWritten = projectRootWritten, - TransactionName = "update skipped binary", - WorkspaceChangedMessage = "The C# file changed while recording its binary skip state.", - Issue = BuildNullByteIssue(binaryFile), - TargetIndex = targetIndex, - ReadableFileBytes = readableFileBytes, - CancellationToken = cancellationToken, - ValidateSkippedRecord = skippedRecord => - !hasCSharpWorkspaceSnapshot - || (skippedRecord.Lang == "csharp" - && CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - absPath, - dbPath, - relPath, - skippedRecord.Size, - skippedRecord.Modified, - csharpWorkspaceSnapshots!, - out _, - cancellationToken)), - PurgeStaleUpdateCleanupPaths = PurgeStaleUpdateCleanupPaths, - RequireTypeScriptAugmentationRefresh = RequireTypeScriptAugmentationRefresh, - WriteProjectRootOnce = WriteProjectRootOnce, - RecordDynamicGraphFileRefresh = RecordDynamicGraphFileRefresh, - }); - mutualRecursionRefreshNeeded |= - skippedPersistence.MutualRecursionRefreshNeeded; - } - catch (CSharpWorkspaceChangedException workspaceChanged) - { - RecordCSharpWorkspaceDrift(relPath, workspaceChanged.Message); - skipped++; - continue; - } - catch (Exception skippedWriteException) - { - if (skippedWriteException is IndexExtractionStalledException - or IndexInterruptedException - or OperationCanceledException) - { - throw; - } - - RecordUpdateFileFailure(relPath, currentUpdatePhase, skippedWriteException); - continue; - } - updated++; - ftsMutated = true; - continue; - } - - if (ex is FileIndexer.FileTooLargeSkippedException fileTooLarge) - { - if (fileBatchMarked) - writer.ClearBatchInProgress(); - - if (hasCSharpWorkspaceSnapshot - && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - absPath, - dbPath, - relPath, - csharpWorkspaceSnapshot.Size, - csharpWorkspaceSnapshot.ModifiedUtc, - csharpWorkspaceSnapshots!, - out _, - cancellationToken)) - { - RecordCSharpWorkspaceDrift( - relPath, - "The C# file changed size or timestamp after contract preflight."); - skipped++; - continue; - } - - DemoteReadinessOnce(); - currentUpdatePhase = "writing"; - try - { - var skippedPersistence = PersistSkippedUpdateFile( - new SkippedUpdateFilePersistenceContext - { - Writer = writer, - Indexer = indexer, - Options = options, - AbsolutePath = absPath, - RelativePath = relPath, - KnownLanguage = knownLanguage, - ProjectRootWritten = projectRootWritten, - TransactionName = "update skipped oversized file", - WorkspaceChangedMessage = "The C# file changed while recording its oversized skip state.", - Issue = new FileIssue - { - Path = fileTooLarge.RelativePath, - Kind = "file_too_large", - Line = 0, - Message = fileTooLarge.Message, - }, - TargetIndex = targetIndex, - ReadableFileBytes = readableFileBytes, - CancellationToken = cancellationToken, - ValidateSkippedRecord = skippedRecord => - !hasCSharpWorkspaceSnapshot - || (skippedRecord.Lang == "csharp" - && CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - absPath, - dbPath, - relPath, - skippedRecord.Size, - skippedRecord.Modified, - csharpWorkspaceSnapshots!, - out _, - cancellationToken)), - PurgeStaleUpdateCleanupPaths = PurgeStaleUpdateCleanupPaths, - RequireTypeScriptAugmentationRefresh = RequireTypeScriptAugmentationRefresh, - WriteProjectRootOnce = WriteProjectRootOnce, - RecordDynamicGraphFileRefresh = RecordDynamicGraphFileRefresh, - }); - mutualRecursionRefreshNeeded |= - skippedPersistence.MutualRecursionRefreshNeeded; - } - catch (CSharpWorkspaceChangedException workspaceChanged) - { - RecordCSharpWorkspaceDrift(relPath, workspaceChanged.Message); - skipped++; - continue; - } - catch (Exception skippedWriteException) - { - if (skippedWriteException is IndexExtractionStalledException - or IndexInterruptedException - or OperationCanceledException) - { - throw; - } - - RecordUpdateFileFailure(relPath, currentUpdatePhase, skippedWriteException); - continue; - } - updated++; - ftsMutated = true; - continue; - } - - if (ex is FileNotFoundException or DirectoryNotFoundException) - { - if (fileBatchMarked) - writer.ClearBatchInProgress(); - - if (hasCSharpWorkspaceSnapshot) - { - RecordCSharpWorkspaceDrift( - relPath, - "The C# file disappeared during its authoritative update pass."); - skipped++; - continue; - } - - var message = $"{relPath}: skipped because it was deleted during indexing."; - warnings++; - warningList.Add(new CliJsonMessage(relPath, message)); - if (!options.Json && !options.Quiet) - { - updateProgress.Pause(); - ConsoleUi.PrintWarning(message); - updateProgress.Resume(); - } - - if (writer.HasFileAtPath(dbPath)) - { - DemoteReadinessOnce(); - using var deleteTxn = writer.BeginTransaction(cancellationToken, "update delete missing during write"); - if (writer.DeleteFileByPath(dbPath)) - { - WriteProjectRootOnce(); - RequireTypeScriptAugmentationRefresh(); - deleteTxn.Commit(); - removed++; - ftsMutated = true; - mutualRecursionRefreshNeeded = true; - } - } - else - { - skipped++; - } - continue; - } - - if (fileBatchMarked) - writer.ClearBatchInProgress(); - RecordUpdateFileFailure(relPath, currentUpdatePhase, ex); - } - } - } - finally - { - StopIndexJsonPhaseHeartbeat(updateHeartbeat); - } - if (options.MemoryTrace) - memorySamples.Add(CaptureMemorySample("extraction", stopwatch)); + updated = updateLoop.Updated; + removed = updateLoop.Removed; + skipped = updateLoop.Skipped; + warnings += updateLoop.Warnings; + errors += updateLoop.Errors; + ftsMutated = updateLoop.FtsMutated; + mutualRecursionRefreshNeeded = updateLoop.MutualRecursionRefreshNeeded; + csharpMetadataTargetsNeedRefresh = updateLoop.CSharpMetadataTargetsNeedRefresh; + symbolsDroppedByKindFilter = updateLoop.SymbolsDroppedByKindFilter; + var readableFileBytes = updateLoop.ReadableFileBytes; if (options.ChangedBetweenSpecified && priorCSharpStaticInterfaceSourceEvidence == false) From 52341521c8357b55df1e858d0dadef1fb09fbc53 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 23:58:13 +0900 Subject: [PATCH 087/101] Separate full scan result consumption --- ...exCommandRunner.FullScan.ResultConsumer.cs | 420 ++++++++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 381 ++++------------ 2 files changed, 514 insertions(+), 287 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.ResultConsumer.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.ResultConsumer.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.ResultConsumer.cs new file mode 100644 index 000000000..bb3ed8dca --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.ResultConsumer.cs @@ -0,0 +1,420 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class FullScanExtractionConsumerContext + { + internal required DbWriter Writer { get; init; } + internal required FileIndexer Indexer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required string ProjectRoot { get; init; } + internal required FullScanFileTarget[] FileTargets { get; init; } + internal required int FilesCount { get; init; } + internal required int ProcessedBeforeExtraction { get; init; } + internal required bool ForceExtractorRefresh { get; init; } + internal required bool StartedWithNoIndexedFiles { get; init; } + internal required bool PriorSymbolsOnlyGraphOmitted { get; init; } + internal required bool SymbolKindFilterMatchesPrior { get; init; } + internal required bool CSharpIndexedProjectRootCompatible { get; init; } + internal required bool CSharpSymbolNameContractMatchesCurrent { get; init; } + internal required bool SqlGraphContractMatchesCurrent { get; init; } + internal required bool HdlGraphContractMatchesCurrent { get; init; } + internal required ReadableFileByteTracker ReadableFileBytes { get; init; } + internal required PostExtractionHookRunner PostExtractionHooks { get; init; } + internal required SymbolExtractionWorkerClient SymbolExtractionWorker { get; init; } + internal required IndexProgressReporter IndexProgress { get; init; } + internal required BlockingCollection ExtractionResults { get; init; } + internal required Task[] Workers { get; init; } + internal required TimeSpan ExtractionStallTimeout { get; init; } + internal required ActiveExtractionPhase?[] ActiveExtractionPhases { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required Action CancelExtraction { get; init; } + internal required Action EnsureIndexingActivityVisible { get; init; } + internal required Action ReportJsonIndexProgressIfNeeded { get; init; } + internal required Action ThrowIfFullScanCancelled { get; init; } + internal required Action PublishProcessedCount { get; init; } + internal required Action SetCurrentJsonIndexFile { get; init; } + internal required Func GetCurrentJsonIndexFile { get; init; } + internal required Func GetDeferCSharpMutationsForIncompleteScan { get; init; } + internal required Func GetFtsMutated { get; init; } + internal required Func GetCSharpWorkspace { get; init; } + internal required Func?> GetCSharpWorkspaceFileSnapshots { get; init; } + internal required Action DeferCSharpMutationsForLoadedSnapshotDrift { get; init; } + internal required Func TargetRequiresJavaScriptTypeScriptRefresh { get; init; } + internal required Func AllowReuseWithCurrentHotspotFamilyTrust { get; init; } + internal required Action RequireTypeScriptAugmentationRefresh { get; init; } + internal required Action WriteProjectRootOnce { get; init; } + internal required Action> InsertIssuesForIndexedFile { get; init; } + internal required Action CountFreshInsertedRows { get; init; } + internal required FullScanExtractionConsumerState State { get; init; } + } + + private sealed class FullScanExtractionConsumerState + { + internal int Processed { get; set; } + internal int Skipped { get; set; } + internal int Warnings { get; set; } + internal int ErrorsAdded { get; set; } + internal bool FtsMutated { get; set; } + internal bool MutualRecursionRefreshNeeded { get; set; } + internal bool CSharpMetadataTargetsNeedRefresh { get; set; } + internal int SymbolsDroppedByKindFilter { get; set; } + internal long ExtractedFiles { get; set; } + internal long ExtractedChunks { get; set; } + internal long ExtractedSymbols { get; set; } + internal long ExtractedReferences { get; set; } + internal HashSet? ReusedHotspotFamilyLanguages { get; set; } + internal HashSet? SkippedSymbolExtractorLanguages { get; set; } + internal required HashSet IndexedSymbolExtractorLanguages { get; init; } + internal required List ErrorList { get; init; } + internal required List FileErrorList { get; init; } + internal required List WarningList { get; init; } + } + + private static FullScanExtractionConsumerState ConsumeFullScanExtractionResults( + FullScanExtractionConsumerContext context) + { + var lastExtractionProgressAt = Stopwatch.GetTimestamp(); + while (!context.ExtractionResults.IsCompleted) + { + var processed = context.ProcessedBeforeExtraction + context.State.Processed; + context.ThrowIfFullScanCancelled(processed, context.FilesCount); + if (!context.ExtractionResults.TryTake(out var item, millisecondsTimeout: 100)) + { + ThrowIfFullScanExtractionStalled( + processed, + context.FilesCount, + context.ExtractionStallTimeout, + lastExtractionProgressAt, + context.GetCurrentJsonIndexFile(), + context.ActiveExtractionPhases, + context.CancelExtraction); + continue; + } + + lastExtractionProgressAt = Stopwatch.GetTimestamp(); + context.SetCurrentJsonIndexFile(item.RelativePath); + ProcessFullScanExtractionItem(context, item); + CompleteFullScanExtractionItem(context); + } + + Task.WaitAll(context.Workers, context.CancellationToken); + return context.State; + } + + private static void ProcessFullScanExtractionItem( + FullScanExtractionConsumerContext context, + FullScanFileWorkItem item) + { + var state = context.State; + var options = context.Options; + var writer = context.Writer; + var indexFilePhase = item.FailurePhase ?? "preparing"; + var itemFileExtracted = item.Record == null ? 0L : 1L; + var itemChunksExtracted = item.Chunks?.Count ?? 0L; + var itemSymbolsExtracted = item.Symbols?.Count ?? 0L; + var itemReferencesExtracted = item.References?.Count ?? 0L; + context.EnsureIndexingActivityVisible(); + if (item.Exception is IndexExtractionStalledException stalledException) + RethrowPreservingStackTrace(stalledException); + + try + { + if (ShouldDeferFullScanCSharpItem(context, item)) + { + state.Skipped++; + return; + } + + if (item.Exception != null) + RethrowPreservingStackTrace(item.Exception); + + if (item.Record == null) + { + RecordSkippedFullScanExtractionItem(context, item); + return; + } + + var record = item.Record; + context.ReadableFileBytes.Remember(item.FileIndex, record.Size); + if (item.Warning != null && !options.Json && !options.Quiet) + { + context.IndexProgress.Pause(); + ConsoleUi.PrintWarning(item.Warning); + context.IndexProgress.Resume(); + } + + var generatedSuppressionIssue = item.GeneratedSuppressionChecked + ? item.GeneratedSuppressionIssue + : context.Indexer.BuildGeneratedCodeExtractionSkippedIssue(record.Path); + var existingId = GetReusableFullScanFileId(context, record, generatedSuppressionIssue); + if (existingId != null) + { + RecordReusedFullScanExtractionItem(context, record); + return; + } + + if (record.Lang == "csharp") + state.CSharpMetadataTargetsNeedRefresh = true; + if (record.Lang == "typescript") + context.RequireTypeScriptAugmentationRefresh(); + + var persistence = PersistFullScanFile(new FullScanFilePersistenceContext + { + Writer = writer, + Indexer = context.Indexer, + Options = options, + ProjectRoot = context.ProjectRoot, + Item = item, + Record = record, + GeneratedSuppressionIssue = generatedSuppressionIssue, + StartedWithNoIndexedFiles = context.StartedWithNoIndexedFiles, + DeferCSharpMutationsForIncompleteScan = + context.GetDeferCSharpMutationsForIncompleteScan(), + CSharpWorkspace = context.GetCSharpWorkspace(), + PostExtractionHooks = context.PostExtractionHooks, + SymbolExtractionWorker = context.SymbolExtractionWorker, + CancellationToken = context.CancellationToken, + InsertIssuesForIndexedFile = context.InsertIssuesForIndexedFile, + WriteProjectRootOnce = context.WriteProjectRootOnce, + SetPhase = (path, phase) => + { + context.SetCurrentJsonIndexFile(path); + indexFilePhase = phase; + }, + }); + itemChunksExtracted = persistence.ExtractedChunks; + itemSymbolsExtracted = persistence.ExtractedSymbols; + itemReferencesExtracted = persistence.ExtractedReferences; + state.SymbolsDroppedByKindFilter += persistence.SymbolsDroppedByKindFilter; + state.MutualRecursionRefreshNeeded |= persistence.MutualRecursionRefreshNeeded; + state.CSharpMetadataTargetsNeedRefresh |= persistence.CSharpMetadataTargetsNeedRefresh; + state.FtsMutated = true; + if (persistence.StampSymbolExtractorLanguage + && !string.IsNullOrWhiteSpace(record.Lang)) + { + state.IndexedSymbolExtractorLanguages.Add(record.Lang); + } + context.CountFreshInsertedRows( + persistence.PersistedChunks, + persistence.PersistedSymbols, + persistence.PersistedReferences); + context.IndexProgress.WriteVerbose(persistence.VerboseMessage); + } + catch (IndexExtractionStalledException) + { + throw; + } + catch (Exception ex) + { + LogIndexFileFailure("index_file_failed", item.FilePath, indexFilePhase, ex); + state.ErrorsAdded++; + var errorMessage = FormatIndexFileException(ex); + state.ErrorList.Add(new CliJsonMessage(item.FilePath, errorMessage)); + if (state.FileErrorList.Count < PartialIndexFileErrorLimit) + state.FileErrorList.Add(BuildIndexFileError(item.RelativePath, indexFilePhase, ex)); + if (!options.Json) + { + context.IndexProgress.Pause(); + ConsoleUi.ClearProgressLine(); + ConsoleUi.TryWriteErrorLine( + FormatPerFileErrorLine("ERR ", item.FilePath, ex, errorMessage)); + context.IndexProgress.Resume(); + } + } + finally + { + state.ExtractedFiles += itemFileExtracted; + state.ExtractedChunks += itemChunksExtracted; + state.ExtractedSymbols += itemSymbolsExtracted; + state.ExtractedReferences += itemReferencesExtracted; + } + } + + private static bool ShouldDeferFullScanCSharpItem( + FullScanExtractionConsumerContext context, + FullScanFileWorkItem item) + { + if (item.FileIndex < 0 + || context.FileTargets[item.FileIndex].Language != "csharp") + { + return false; + } + + var deferCurrentItem = context.GetDeferCSharpMutationsForIncompleteScan(); + if (!deferCurrentItem + && item.Exception is CSharpWorkspaceSnapshotDriftException driftException) + { + context.DeferCSharpMutationsForLoadedSnapshotDrift(driftException.Path); + context.State.FtsMutated = context.GetFtsMutated(); + return true; + } + + var workspaceFileSnapshots = context.GetCSharpWorkspaceFileSnapshots(); + if (!deferCurrentItem + && item.Record != null + && workspaceFileSnapshots != null + && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + item.FilePath, + context.FileTargets[item.FileIndex].IndexPath, + context.FileTargets[item.FileIndex].DisplayRelativePath, + item.Record.Size, + item.Record.Modified, + workspaceFileSnapshots, + out var changedPath, + context.CancellationToken)) + { + context.DeferCSharpMutationsForLoadedSnapshotDrift( + changedPath ?? context.FileTargets[item.FileIndex].DisplayRelativePath); + context.State.FtsMutated = context.GetFtsMutated(); + return true; + } + + return deferCurrentItem; + } + + private static void RecordSkippedFullScanExtractionItem( + FullScanExtractionConsumerContext context, + FullScanFileWorkItem item) + { + var state = context.State; + var path = item.RelativePath; + state.Warnings++; + state.WarningList.Add(new CliJsonMessage(path, item.Warning ?? "File skipped")); + if (!context.Options.Json + && !context.Options.Quiet + && item.Warning != null) + { + context.IndexProgress.Pause(); + ConsoleUi.PrintWarning(item.Warning); + context.IndexProgress.Resume(); + } + + if (!context.Writer.HasFileAtPath(path)) + { + state.Skipped++; + return; + } + + using var deleteTxn = context.Writer.BeginTransaction( + context.CancellationToken, + "full scan delete skipped file"); + if (!context.Writer.DeleteFileByPath(path)) + return; + + state.CSharpMetadataTargetsNeedRefresh = true; + context.RequireTypeScriptAugmentationRefresh(); + context.WriteProjectRootOnce(); + deleteTxn.Commit(); + state.FtsMutated = true; + } + + private static long? GetReusableFullScanFileId( + FullScanExtractionConsumerContext context, + FileRecord record, + FileIssue? generatedSuppressionIssue) + { + var options = context.Options; + if (context.ForceExtractorRefresh + || options.Rebuild + || context.StartedWithNoIndexedFiles + || options.SymbolsOnly) + { + return null; + } + + var targetRequiresRefresh = + context.TargetRequiresJavaScriptTypeScriptRefresh(record.Lang, record.Path); + return context.Writer.GetReusableUnchangedFileId( + record.Path, + record.Modified, + record.Checksum, + size: record.Size, + lines: record.Lines, + language: record.Lang, + generated: record.Generated, + maxSymbolsPerFile: options.MaxSymbolsPerFile, + maxReferencesPerFile: options.MaxReferencesPerFile, + generatedExtractionSuppressed: generatedSuppressionIssue != null, + allowReuse: context.SymbolKindFilterMatchesPrior + && !targetRequiresRefresh + && !context.PriorSymbolsOnlyGraphOmitted + && (record.Lang != "csharp" || context.CSharpIndexedProjectRootCompatible) + && (record.Lang != "csharp" || context.CSharpSymbolNameContractMatchesCurrent) + && (record.Lang != "csharp" || !context.GetCSharpWorkspace().HasStaticInterfaceContracts) + && (record.Lang != "sql" || context.SqlGraphContractMatchesCurrent) + && (record.Lang is not ("verilog" or "systemverilog" or "vhdl") + || context.HdlGraphContractMatchesCurrent) + && context.AllowReuseWithCurrentHotspotFamilyTrust(record.Lang)); + } + + private static void RecordReusedFullScanExtractionItem( + FullScanExtractionConsumerContext context, + FileRecord record) + { + var state = context.State; + var stalePurged = context.GetDeferCSharpMutationsForIncompleteScan() + ? 0 + : context.Writer.PurgeStaleFilesSharingChecksum( + context.ProjectRoot, + record.Path, + record.Checksum); + if (stalePurged > 0) + { + state.FtsMutated = true; + state.CSharpMetadataTargetsNeedRefresh = true; + context.RequireTypeScriptAugmentationRefresh(); + if (!context.Options.SymbolsOnly) + state.MutualRecursionRefreshNeeded = true; + } + + state.Skipped++; + if (!string.IsNullOrWhiteSpace(record.Lang)) + { + state.SkippedSymbolExtractorLanguages ??= + new HashSet(StringComparer.Ordinal); + state.SkippedSymbolExtractorLanguages.Add(record.Lang); + } + if (FileIndexer.SupportsHotspotFamilyMarkerLanguage(record.Lang) + && record.Lang != null) + { + state.ReusedHotspotFamilyLanguages ??= + new HashSet(StringComparer.Ordinal); + state.ReusedHotspotFamilyLanguages.Add(record.Lang); + } + if (context.Options.Verbose + && !context.Options.Json + && !context.Options.Quiet) + { + context.IndexProgress.Pause(); + ConsoleUi.ClearProgressLine(); + CommandOutputWriter.WriteLine($" [SKIP] {record.Path}"); + context.IndexProgress.Resume(); + } + } + + private static void CompleteFullScanExtractionItem( + FullScanExtractionConsumerContext context) + { + context.State.Processed++; + var processed = context.ProcessedBeforeExtraction + context.State.Processed; + context.PublishProcessedCount(processed); + context.SetCurrentJsonIndexFile(null); + context.ThrowIfFullScanCancelled(processed, context.FilesCount); + context.ReportJsonIndexProgressIfNeeded(); + if (context.Options.Json || context.Options.Quiet) + return; + + context.IndexProgress.Pause(); + ConsoleUi.PrintProgress(processed, context.FilesCount); + context.IndexProgress.Resume(); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 560283b92..8857a3395 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -1365,295 +1365,102 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - var extractionStallTimeout = IndexExtractionStallTimeoutForTesting?.Invoke() ?? IndexExtractionStallTimeout; - var lastExtractionProgressAt = Stopwatch.GetTimestamp(); - while (!extractionResults.IsCompleted) - { - ThrowIfFullScanCancelled(processed, files.Count); - if (!extractionResults.TryTake(out var item, millisecondsTimeout: 100)) - { - ThrowIfFullScanExtractionStalled( - processed, - files.Count, - extractionStallTimeout, - lastExtractionProgressAt, - currentJsonIndexFile, - activeExtractionPhases, - extractionStallCts.Cancel); - continue; - } - - lastExtractionProgressAt = Stopwatch.GetTimestamp(); - currentJsonIndexFile = item.RelativePath; - var indexFilePhase = item.FailurePhase ?? "preparing"; - var itemFileExtracted = item.Record == null ? 0L : 1L; - var itemChunksExtracted = item.Chunks?.Count ?? 0L; - var itemSymbolsExtracted = item.Symbols?.Count ?? 0L; - var itemReferencesExtracted = item.References?.Count ?? 0L; - EnsureIndexingActivityVisible(); - if (item.Exception is IndexExtractionStalledException stalledException) - RethrowPreservingStackTrace(stalledException); - - try + var processedBeforeExtraction = processed; + var extractionState = ConsumeFullScanExtractionResults( + new FullScanExtractionConsumerContext { - var itemTargetsCSharp = item.FileIndex >= 0 - && fileTargets[item.FileIndex].Language == "csharp"; - if (itemTargetsCSharp) - { - var deferCurrentItem = deferCSharpMutationsForIncompleteScan; - if (!deferCurrentItem - && item.Exception is CSharpWorkspaceSnapshotDriftException driftException) - { - DeferCSharpMutationsForLoadedSnapshotDrift(driftException.Path); - deferCurrentItem = true; - } - else if (!deferCurrentItem - && item.Record != null - && csharpWorkspaceFileSnapshots is { } workspaceFileSnapshots - && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - item.FilePath, - fileTargets[item.FileIndex].IndexPath, - fileTargets[item.FileIndex].DisplayRelativePath, - item.Record.Size, - item.Record.Modified, - workspaceFileSnapshots, - out var changedPath, - cancellationToken)) - { - DeferCSharpMutationsForLoadedSnapshotDrift( - changedPath ?? fileTargets[item.FileIndex].DisplayRelativePath); - deferCurrentItem = true; - } - - if (deferCurrentItem) - { - skipped++; - processed++; - currentJsonIndexFile = null; - ThrowIfFullScanCancelled(processed, files.Count); - ReportJsonIndexProgressIfNeeded(); - if (!options.Json && !options.Quiet) - { - indexProgress.Pause(); - ConsoleUi.PrintProgress(processed, files.Count); - indexProgress.Resume(); - } - continue; - } - } - - if (item.Exception != null) - RethrowPreservingStackTrace(item.Exception); - - if (item.Record == null) - { - warnings++; - warningList.Add(new CliJsonMessage(currentJsonIndexFile, item.Warning ?? "File skipped")); - if (!options.Json && !options.Quiet && item.Warning != null) - { - indexProgress.Pause(); - ConsoleUi.PrintWarning(item.Warning); - indexProgress.Resume(); - } - - if (writer.HasFileAtPath(currentJsonIndexFile)) - { - using var deleteTxn = writer.BeginTransaction(cancellationToken, "full scan delete skipped file"); - if (writer.DeleteFileByPath(currentJsonIndexFile)) - { - csharpMetadataTargetsNeedRefresh = true; - RequireTypeScriptAugmentationRefresh(); - WriteProjectRootOnce(); - deleteTxn.Commit(); - ftsMutated = true; - } - } - else - { - skipped++; - } - processed++; - currentJsonIndexFile = null; - ThrowIfFullScanCancelled(processed, files.Count); - ReportJsonIndexProgressIfNeeded(); - if (!options.Json && !options.Quiet) - { - indexProgress.Pause(); - ConsoleUi.PrintProgress(processed, files.Count); - indexProgress.Resume(); - } - continue; - } - - var record = item.Record!; - readableFileBytes.Remember(item.FileIndex, record.Size); - if (item.Warning != null && !options.Json && !options.Quiet) - { - indexProgress.Pause(); - ConsoleUi.PrintWarning(item.Warning); - indexProgress.Resume(); - } - - var generatedSuppressionIssue = item.GeneratedSuppressionChecked - ? item.GeneratedSuppressionIssue - : indexer.BuildGeneratedCodeExtractionSkippedIssue(record.Path); - long? existingId = null; - if (!forceExtractorRefresh && !options.Rebuild && !startedWithNoIndexedFiles && !options.SymbolsOnly) - { - var targetRequiresRefresh = TargetRequiresJavaScriptTypeScriptRefresh(record.Lang, record.Path); - existingId = writer.GetReusableUnchangedFileId( - record.Path, - record.Modified, - record.Checksum, - size: record.Size, - lines: record.Lines, - language: record.Lang, - generated: record.Generated, - maxSymbolsPerFile: options.MaxSymbolsPerFile, - maxReferencesPerFile: options.MaxReferencesPerFile, - generatedExtractionSuppressed: generatedSuppressionIssue != null, - allowReuse: symbolKindFilterMatchesPrior - && !targetRequiresRefresh - && !priorSymbolsOnlyGraphOmitted - && (record.Lang != "csharp" || csharpIndexedProjectRootCompatible) - && (record.Lang != "csharp" || csharpSymbolNameContractMatchesCurrent) - && (record.Lang != "csharp" || !csharpWorkspace.HasStaticInterfaceContracts) - && (record.Lang != "sql" || sqlGraphContractMatchesCurrent) - && (record.Lang is not ("verilog" or "systemverilog" or "vhdl") || hdlGraphContractMatchesCurrent) - && AllowReuseWithCurrentHotspotFamilyTrust(record.Lang, hotspotFamilyTrustMatchesCurrent)); - } - if (existingId != null) - { - var stalePurged = deferCSharpMutationsForIncompleteScan - ? 0 - : writer.PurgeStaleFilesSharingChecksum( - projectRoot, - record.Path, - record.Checksum); - if (stalePurged > 0) - { - ftsMutated = true; - csharpMetadataTargetsNeedRefresh = true; - RequireTypeScriptAugmentationRefresh(); - if (!options.SymbolsOnly) - mutualRecursionRefreshNeeded = true; - } - skipped++; - processed++; - if (!string.IsNullOrWhiteSpace(record.Lang)) - { - skippedSymbolExtractorLanguages ??= new HashSet(StringComparer.Ordinal); - skippedSymbolExtractorLanguages.Add(record.Lang); - } - if (FileIndexer.SupportsHotspotFamilyMarkerLanguage(record.Lang) && record.Lang != null) - { - reusedHotspotFamilyLanguages ??= new HashSet(StringComparer.Ordinal); - reusedHotspotFamilyLanguages.Add(record.Lang); - } - if (options.Verbose && !options.Json && !options.Quiet) - { - indexProgress.Pause(); - ConsoleUi.ClearProgressLine(); - CommandOutputWriter.WriteLine($" [SKIP] {record.Path}"); - indexProgress.Resume(); - } - if (!options.Json && !options.Quiet) - { - indexProgress.Pause(); - ConsoleUi.PrintProgress(processed, files.Count); - indexProgress.Resume(); - } - ReportJsonIndexProgressIfNeeded(); - currentJsonIndexFile = null; - continue; - } - - if (record.Lang == "csharp") - csharpMetadataTargetsNeedRefresh = true; - if (record.Lang == "typescript") - RequireTypeScriptAugmentationRefresh(); - - var persistence = PersistFullScanFile(new FullScanFilePersistenceContext - { - Writer = writer, - Indexer = indexer, - Options = options, - ProjectRoot = projectRoot, - Item = item, - Record = record, - GeneratedSuppressionIssue = generatedSuppressionIssue, - StartedWithNoIndexedFiles = startedWithNoIndexedFiles, - DeferCSharpMutationsForIncompleteScan = deferCSharpMutationsForIncompleteScan, - CSharpWorkspace = csharpWorkspace, - PostExtractionHooks = postExtractionHooks, - SymbolExtractionWorker = mainSymbolExtractionWorker.Value, - CancellationToken = cancellationToken, - InsertIssuesForIndexedFile = InsertIssuesForIndexedFile, - WriteProjectRootOnce = WriteProjectRootOnce, - SetPhase = (path, phase) => - { - currentJsonIndexFile = path; - indexFilePhase = phase; - }, - }); - itemChunksExtracted = persistence.ExtractedChunks; - itemSymbolsExtracted = persistence.ExtractedSymbols; - itemReferencesExtracted = persistence.ExtractedReferences; - symbolsDroppedByKindFilter += persistence.SymbolsDroppedByKindFilter; - mutualRecursionRefreshNeeded |= persistence.MutualRecursionRefreshNeeded; - csharpMetadataTargetsNeedRefresh |= persistence.CSharpMetadataTargetsNeedRefresh; - ftsMutated = true; - if (persistence.StampSymbolExtractorLanguage - && !string.IsNullOrWhiteSpace(record.Lang)) - { - indexedSymbolExtractorLanguages.Add(record.Lang); - } - CountFreshInsertedRows( - persistence.PersistedChunks, - persistence.PersistedSymbols, - persistence.PersistedReferences); - indexProgress.WriteVerbose(persistence.VerboseMessage); - } - catch (IndexExtractionStalledException) - { - throw; - } - catch (Exception ex) - { - LogIndexFileFailure("index_file_failed", item.FilePath, indexFilePhase, ex); - errors++; - var errorMessage = FormatIndexFileException(ex); - errorList.Add(new CliJsonMessage(item.FilePath, errorMessage)); - if (fileErrorList.Count < PartialIndexFileErrorLimit) - fileErrorList.Add(BuildIndexFileError(item.RelativePath, indexFilePhase, ex)); - if (!options.Json) + Writer = writer, + Indexer = indexer, + Options = options, + ProjectRoot = projectRoot, + FileTargets = fileTargets, + FilesCount = files.Count, + ProcessedBeforeExtraction = processedBeforeExtraction, + ForceExtractorRefresh = forceExtractorRefresh, + StartedWithNoIndexedFiles = startedWithNoIndexedFiles, + PriorSymbolsOnlyGraphOmitted = priorSymbolsOnlyGraphOmitted, + SymbolKindFilterMatchesPrior = symbolKindFilterMatchesPrior, + CSharpIndexedProjectRootCompatible = csharpIndexedProjectRootCompatible, + CSharpSymbolNameContractMatchesCurrent = + csharpSymbolNameContractMatchesCurrent, + SqlGraphContractMatchesCurrent = sqlGraphContractMatchesCurrent, + HdlGraphContractMatchesCurrent = hdlGraphContractMatchesCurrent, + ReadableFileBytes = readableFileBytes, + PostExtractionHooks = postExtractionHooks, + SymbolExtractionWorker = mainSymbolExtractionWorker.Value, + IndexProgress = indexProgress, + ExtractionResults = extractionResults, + Workers = workers, + ExtractionStallTimeout = + IndexExtractionStallTimeoutForTesting?.Invoke() + ?? IndexExtractionStallTimeout, + ActiveExtractionPhases = activeExtractionPhases, + CancellationToken = cancellationToken, + CancelExtraction = extractionStallCts.Cancel, + EnsureIndexingActivityVisible = EnsureIndexingActivityVisible, + ReportJsonIndexProgressIfNeeded = ReportJsonIndexProgressIfNeeded, + ThrowIfFullScanCancelled = ThrowIfFullScanCancelled, + PublishProcessedCount = value => processed = value, + SetCurrentJsonIndexFile = path => currentJsonIndexFile = path, + GetCurrentJsonIndexFile = () => currentJsonIndexFile, + GetDeferCSharpMutationsForIncompleteScan = + () => deferCSharpMutationsForIncompleteScan, + GetFtsMutated = () => ftsMutated, + GetCSharpWorkspace = () => csharpWorkspace, + GetCSharpWorkspaceFileSnapshots = + () => csharpWorkspaceFileSnapshots, + DeferCSharpMutationsForLoadedSnapshotDrift = + DeferCSharpMutationsForLoadedSnapshotDrift, + TargetRequiresJavaScriptTypeScriptRefresh = + TargetRequiresJavaScriptTypeScriptRefresh, + AllowReuseWithCurrentHotspotFamilyTrust = language => + AllowReuseWithCurrentHotspotFamilyTrust( + language, + hotspotFamilyTrustMatchesCurrent), + RequireTypeScriptAugmentationRefresh = + RequireTypeScriptAugmentationRefresh, + WriteProjectRootOnce = WriteProjectRootOnce, + InsertIssuesForIndexedFile = InsertIssuesForIndexedFile, + CountFreshInsertedRows = CountFreshInsertedRows, + State = new FullScanExtractionConsumerState { - indexProgress.Pause(); - ConsoleUi.ClearProgressLine(); - ConsoleUi.TryWriteErrorLine(FormatPerFileErrorLine("ERR ", item.FilePath, ex, errorMessage)); - indexProgress.Resume(); - } - } - finally - { - extractedFiles += itemFileExtracted; - extractedChunks += itemChunksExtracted; - extractedSymbols += itemSymbolsExtracted; - extractedReferences += itemReferencesExtracted; - } - - processed++; - currentJsonIndexFile = null; - ThrowIfFullScanCancelled(processed, files.Count); - ReportJsonIndexProgressIfNeeded(); - if (!options.Json && !options.Quiet) - { - indexProgress.Pause(); - ConsoleUi.PrintProgress(processed, files.Count); - indexProgress.Resume(); - } - } - Task.WaitAll(workers, cancellationToken); + FtsMutated = ftsMutated, + MutualRecursionRefreshNeeded = + mutualRecursionRefreshNeeded, + CSharpMetadataTargetsNeedRefresh = + csharpMetadataTargetsNeedRefresh, + SymbolsDroppedByKindFilter = + symbolsDroppedByKindFilter, + ReusedHotspotFamilyLanguages = + reusedHotspotFamilyLanguages, + SkippedSymbolExtractorLanguages = + skippedSymbolExtractorLanguages, + IndexedSymbolExtractorLanguages = + indexedSymbolExtractorLanguages, + ErrorList = errorList, + FileErrorList = fileErrorList, + WarningList = warningList, + }, + }); + processed = processedBeforeExtraction + extractionState.Processed; + skipped += extractionState.Skipped; + warnings += extractionState.Warnings; + errors += extractionState.ErrorsAdded; + ftsMutated = extractionState.FtsMutated; + mutualRecursionRefreshNeeded = + extractionState.MutualRecursionRefreshNeeded; + csharpMetadataTargetsNeedRefresh = + extractionState.CSharpMetadataTargetsNeedRefresh; + symbolsDroppedByKindFilter = + extractionState.SymbolsDroppedByKindFilter; + extractedFiles += extractionState.ExtractedFiles; + extractedChunks += extractionState.ExtractedChunks; + extractedSymbols += extractionState.ExtractedSymbols; + extractedReferences += extractionState.ExtractedReferences; + reusedHotspotFamilyLanguages = + extractionState.ReusedHotspotFamilyLanguages; + skippedSymbolExtractorLanguages = + extractionState.SkippedSymbolExtractorLanguages; } finally { From e25b13a71ce7e094ef8a5001cba5af9997f0d14f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 00:07:25 +0900 Subject: [PATCH 088/101] Unify skipped update file handling --- .../Cli/IndexCommandRunner.Update.FileLoop.cs | 224 ++++-------------- ...ommandRunner.Update.SkippedFileHandling.cs | 188 +++++++++++++++ 2 files changed, 234 insertions(+), 178 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.Update.SkippedFileHandling.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.FileLoop.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.FileLoop.cs index 48515ca9d..2b799c8fa 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.FileLoop.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.FileLoop.cs @@ -634,191 +634,59 @@ void ThrowIfUpdateCancelled() continue; } - if (ex is FileIndexer.BinaryFileSkippedException binaryFile) + if (ex is FileIndexer.BinaryFileSkippedException + or FileIndexer.FileTooLargeSkippedException) { if (fileBatchMarked) writer.ClearBatchInProgress(); - if (hasCSharpWorkspaceSnapshot - && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - absPath, - dbPath, - relPath, - csharpWorkspaceSnapshot.Size, - csharpWorkspaceSnapshot.ModifiedUtc, - csharpWorkspaceSnapshots!, - out _, - cancellationToken)) - { - RecordCSharpWorkspaceDrift( - relPath, - "The C# file changed to binary content after contract preflight."); - skipped++; - continue; - } - - warnings++; - var sanitizedMessage = CommandErrorWriter.FormatSanitizedExceptionMessage(ex); - warningList.Add(new CliJsonMessage(relPath, sanitizedMessage)); - if (!options.Json && !options.Quiet) - { - updateProgress.Pause(); - ConsoleUi.PrintWarning(sanitizedMessage); - updateProgress.Resume(); - } - - DemoteReadinessOnce(); - currentUpdatePhase = "writing"; - try - { - var skippedPersistence = PersistSkippedUpdateFile( - new SkippedUpdateFilePersistenceContext - { - Writer = writer, - Indexer = indexer, - Options = options, - AbsolutePath = absPath, - RelativePath = relPath, - KnownLanguage = knownLanguage, - ProjectRootWritten = context.IsProjectRootWritten(), - TransactionName = "update skipped binary", - WorkspaceChangedMessage = "The C# file changed while recording its binary skip state.", - Issue = BuildNullByteIssue(binaryFile), - TargetIndex = targetIndex, - ReadableFileBytes = readableFileBytes, - CancellationToken = cancellationToken, - ValidateSkippedRecord = skippedRecord => - !hasCSharpWorkspaceSnapshot - || (skippedRecord.Lang == "csharp" - && CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - absPath, - dbPath, - relPath, - skippedRecord.Size, - skippedRecord.Modified, - csharpWorkspaceSnapshots!, - out _, - cancellationToken)), - PurgeStaleUpdateCleanupPaths = PurgeStaleUpdateCleanupPaths, - RequireTypeScriptAugmentationRefresh = RequireTypeScriptAugmentationRefresh, - WriteProjectRootOnce = WriteProjectRootOnce, - RecordDynamicGraphFileRefresh = RecordDynamicGraphFileRefresh, - }); - mutualRecursionRefreshNeeded |= - skippedPersistence.MutualRecursionRefreshNeeded; - } - catch (CSharpWorkspaceChangedException workspaceChanged) - { - RecordCSharpWorkspaceDrift(relPath, workspaceChanged.Message); - skipped++; - continue; - } - catch (Exception skippedWriteException) - { - if (skippedWriteException is IndexExtractionStalledException - or IndexInterruptedException - or OperationCanceledException) + var skippedFile = HandleSkippedUpdateFile( + new SkippedUpdateFileHandlingContext { - throw; - } - - RecordUpdateFileFailure(relPath, currentUpdatePhase, skippedWriteException); - continue; - } - updated++; - ftsMutated = true; - continue; - } - - if (ex is FileIndexer.FileTooLargeSkippedException fileTooLarge) - { - if (fileBatchMarked) - writer.ClearBatchInProgress(); - - if (hasCSharpWorkspaceSnapshot - && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - absPath, - dbPath, - relPath, - csharpWorkspaceSnapshot.Size, - csharpWorkspaceSnapshot.ModifiedUtc, - csharpWorkspaceSnapshots!, - out _, - cancellationToken)) - { - RecordCSharpWorkspaceDrift( - relPath, - "The C# file changed size or timestamp after contract preflight."); - skipped++; - continue; - } - - DemoteReadinessOnce(); - currentUpdatePhase = "writing"; - try - { - var skippedPersistence = PersistSkippedUpdateFile( - new SkippedUpdateFilePersistenceContext - { - Writer = writer, - Indexer = indexer, - Options = options, - AbsolutePath = absPath, - RelativePath = relPath, - KnownLanguage = knownLanguage, - ProjectRootWritten = context.IsProjectRootWritten(), - TransactionName = "update skipped oversized file", - WorkspaceChangedMessage = "The C# file changed while recording its oversized skip state.", - Issue = new FileIssue - { - Path = fileTooLarge.RelativePath, - Kind = "file_too_large", - Line = 0, - Message = fileTooLarge.Message, - }, - TargetIndex = targetIndex, - ReadableFileBytes = readableFileBytes, - CancellationToken = cancellationToken, - ValidateSkippedRecord = skippedRecord => - !hasCSharpWorkspaceSnapshot - || (skippedRecord.Lang == "csharp" - && CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( - absPath, - dbPath, - relPath, - skippedRecord.Size, - skippedRecord.Modified, - csharpWorkspaceSnapshots!, - out _, - cancellationToken)), - PurgeStaleUpdateCleanupPaths = PurgeStaleUpdateCleanupPaths, - RequireTypeScriptAugmentationRefresh = RequireTypeScriptAugmentationRefresh, - WriteProjectRootOnce = WriteProjectRootOnce, - RecordDynamicGraphFileRefresh = RecordDynamicGraphFileRefresh, - }); - mutualRecursionRefreshNeeded |= - skippedPersistence.MutualRecursionRefreshNeeded; - } - catch (CSharpWorkspaceChangedException workspaceChanged) - { - RecordCSharpWorkspaceDrift(relPath, workspaceChanged.Message); - skipped++; - continue; - } - catch (Exception skippedWriteException) + Writer = writer, + Indexer = indexer, + Options = options, + AbsolutePath = absPath, + RelativePath = relPath, + IndexPath = dbPath, + KnownLanguage = knownLanguage, + ProjectRootWritten = context.IsProjectRootWritten(), + TargetIndex = targetIndex, + ReadableFileBytes = readableFileBytes, + HasCSharpWorkspaceSnapshot = + hasCSharpWorkspaceSnapshot, + CSharpWorkspaceSnapshot = + csharpWorkspaceSnapshot, + CSharpWorkspaceSnapshots = + csharpWorkspaceSnapshots, + WarningList = warningList, + UpdateProgress = updateProgress, + CancellationToken = cancellationToken, + DemoteReadinessOnce = DemoteReadinessOnce, + SetCurrentUpdatePhase = + phase => currentUpdatePhase = phase, + RecordCSharpWorkspaceDrift = + RecordCSharpWorkspaceDrift, + RecordUpdateFileFailure = + RecordUpdateFileFailure, + PurgeStaleUpdateCleanupPaths = + PurgeStaleUpdateCleanupPaths, + RequireTypeScriptAugmentationRefresh = + RequireTypeScriptAugmentationRefresh, + WriteProjectRootOnce = WriteProjectRootOnce, + RecordDynamicGraphFileRefresh = + RecordDynamicGraphFileRefresh, + }, + ex); + updated += skippedFile.Updated; + skipped += skippedFile.Skipped; + warnings += skippedFile.Warnings; + mutualRecursionRefreshNeeded |= + skippedFile.MutualRecursionRefreshNeeded; + if (skippedFile.Updated > 0) { - if (skippedWriteException is IndexExtractionStalledException - or IndexInterruptedException - or OperationCanceledException) - { - throw; - } - - RecordUpdateFileFailure(relPath, currentUpdatePhase, skippedWriteException); - continue; + ftsMutated = true; } - updated++; - ftsMutated = true; continue; } diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.SkippedFileHandling.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.SkippedFileHandling.cs new file mode 100644 index 000000000..c7a89070f --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.SkippedFileHandling.cs @@ -0,0 +1,188 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class SkippedUpdateFileHandlingContext + { + internal required DbWriter Writer { get; init; } + internal required FileIndexer Indexer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required string AbsolutePath { get; init; } + internal required string RelativePath { get; init; } + internal required string IndexPath { get; init; } + internal string? KnownLanguage { get; init; } + internal required bool ProjectRootWritten { get; init; } + internal required int TargetIndex { get; init; } + internal required ReadableFileByteTracker ReadableFileBytes { get; init; } + internal required bool HasCSharpWorkspaceSnapshot { get; init; } + internal required CSharpStaticInterfacePrepass.FileStatSnapshot CSharpWorkspaceSnapshot { get; init; } + internal Dictionary? CSharpWorkspaceSnapshots { get; init; } + internal required List WarningList { get; init; } + internal required IndexProgressReporter UpdateProgress { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required Action DemoteReadinessOnce { get; init; } + internal required Action SetCurrentUpdatePhase { get; init; } + internal required Action RecordCSharpWorkspaceDrift { get; init; } + internal required Action RecordUpdateFileFailure { get; init; } + internal required Func PurgeStaleUpdateCleanupPaths { get; init; } + internal required Action RequireTypeScriptAugmentationRefresh { get; init; } + internal required Action WriteProjectRootOnce { get; init; } + internal required Action RecordDynamicGraphFileRefresh { get; init; } + } + + private sealed record SkippedUpdateFileHandlingResult( + int Updated, + int Skipped, + int Warnings, + bool MutualRecursionRefreshNeeded); + + private static SkippedUpdateFileHandlingResult HandleSkippedUpdateFile( + SkippedUpdateFileHandlingContext context, + Exception exception) + { + var descriptor = DescribeSkippedUpdateFile(exception); + if (context.HasCSharpWorkspaceSnapshot + && !CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + context.AbsolutePath, + context.IndexPath, + context.RelativePath, + context.CSharpWorkspaceSnapshot.Size, + context.CSharpWorkspaceSnapshot.ModifiedUtc, + context.CSharpWorkspaceSnapshots!, + out _, + context.CancellationToken)) + { + context.RecordCSharpWorkspaceDrift( + context.RelativePath, + descriptor.PreflightDriftMessage, + "reading"); + return new SkippedUpdateFileHandlingResult(0, 1, 0, false); + } + + var warnings = 0; + if (descriptor.PrintWarning) + { + warnings++; + var sanitizedMessage = + CommandErrorWriter.FormatSanitizedExceptionMessage(exception); + context.WarningList.Add( + new CliJsonMessage(context.RelativePath, sanitizedMessage)); + if (!context.Options.Json && !context.Options.Quiet) + { + context.UpdateProgress.Pause(); + ConsoleUi.PrintWarning(sanitizedMessage); + context.UpdateProgress.Resume(); + } + } + + context.DemoteReadinessOnce(); + context.SetCurrentUpdatePhase("writing"); + try + { + var persistence = PersistSkippedUpdateFile( + new SkippedUpdateFilePersistenceContext + { + Writer = context.Writer, + Indexer = context.Indexer, + Options = context.Options, + AbsolutePath = context.AbsolutePath, + RelativePath = context.RelativePath, + KnownLanguage = context.KnownLanguage, + ProjectRootWritten = context.ProjectRootWritten, + TransactionName = descriptor.TransactionName, + WorkspaceChangedMessage = descriptor.WorkspaceChangedMessage, + Issue = descriptor.Issue, + TargetIndex = context.TargetIndex, + ReadableFileBytes = context.ReadableFileBytes, + CancellationToken = context.CancellationToken, + ValidateSkippedRecord = skippedRecord => + !context.HasCSharpWorkspaceSnapshot + || (skippedRecord.Lang == "csharp" + && CSharpStaticInterfacePrepass.TryValidateLoadedFileStatSnapshot( + context.AbsolutePath, + context.IndexPath, + context.RelativePath, + skippedRecord.Size, + skippedRecord.Modified, + context.CSharpWorkspaceSnapshots!, + out _, + context.CancellationToken)), + PurgeStaleUpdateCleanupPaths = + context.PurgeStaleUpdateCleanupPaths, + RequireTypeScriptAugmentationRefresh = + context.RequireTypeScriptAugmentationRefresh, + WriteProjectRootOnce = context.WriteProjectRootOnce, + RecordDynamicGraphFileRefresh = + context.RecordDynamicGraphFileRefresh, + }); + return new SkippedUpdateFileHandlingResult( + 1, + 0, + warnings, + persistence.MutualRecursionRefreshNeeded); + } + catch (CSharpWorkspaceChangedException workspaceChanged) + { + context.RecordCSharpWorkspaceDrift( + context.RelativePath, + workspaceChanged.Message, + "reading"); + return new SkippedUpdateFileHandlingResult(0, 1, warnings, false); + } + catch (Exception writeException) + { + if (writeException is IndexExtractionStalledException + or IndexInterruptedException + or OperationCanceledException) + { + throw; + } + + context.RecordUpdateFileFailure( + context.RelativePath, + "writing", + writeException); + return new SkippedUpdateFileHandlingResult(0, 0, warnings, false); + } + } + + private static SkippedUpdateFileDescriptor DescribeSkippedUpdateFile( + Exception exception) + { + if (exception is FileIndexer.BinaryFileSkippedException binaryFile) + { + return new SkippedUpdateFileDescriptor( + "update skipped binary", + "The C# file changed to binary content after contract preflight.", + "The C# file changed while recording its binary skip state.", + BuildNullByteIssue(binaryFile), + PrintWarning: true); + } + + var fileTooLarge = + (FileIndexer.FileTooLargeSkippedException)exception; + return new SkippedUpdateFileDescriptor( + "update skipped oversized file", + "The C# file changed size or timestamp after contract preflight.", + "The C# file changed while recording its oversized skip state.", + new FileIssue + { + Path = fileTooLarge.RelativePath, + Kind = "file_too_large", + Line = 0, + Message = fileTooLarge.Message, + }, + PrintWarning: false); + } + + private sealed record SkippedUpdateFileDescriptor( + string TransactionName, + string PreflightDriftMessage, + string WorkspaceChangedMessage, + FileIssue Issue, + bool PrintWarning); +} From ff64855205cc7ad6681f8a4ae645d3a79ea4e8f2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 00:16:01 +0900 Subject: [PATCH 089/101] Split C and C++ type reference groups --- ...eferenceExtractionSupport.CppTypeGroups.cs | 639 ++++++++++++++ ...uageReferenceExtractionSupport.CppTypes.cs | 781 +----------------- 2 files changed, 653 insertions(+), 767 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.CppTypeGroups.cs diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.CppTypeGroups.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.CppTypeGroups.cs new file mode 100644 index 000000000..be746f74e --- /dev/null +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.CppTypeGroups.cs @@ -0,0 +1,639 @@ +using System.Text.RegularExpressions; +using Regex = CodeIndex.Indexer.BoundedRegex; +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +internal static partial class LanguageReferenceExtractionSupport +{ + private sealed class CppTypeReferenceLineContext( + string language, + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string sourceContext, + int lineNumber, + Func resolveContainerForColumn) + { + internal string Language { get; } = language; + internal string PreparedLine { get; } = preparedLine; + internal string OriginalLine { get; } = originalLine; + + internal void EmitTypeExpressions(Regex regex) + { + foreach (Match match in regex.Matches(PreparedLine)) + AddTypeExpression(match.Groups["type"]); + } + + internal void AddTypeExpression(Group group) + => AddTypeExpression(group.Value, group.Index); + + internal void AddTypeExpression(string expression, int start) + => ReferenceExtractor.AddTypeExpressionSegments( + references, + seen, + fileId, + expression, + start, + sourceContext, + lineNumber, + resolveContainerForColumn(start), + Language); + + internal void AddReference(Group group, string kind) + => ReferenceExtractor.AddReference( + references, + seen, + fileId, + group.Value, + group.Index, + kind, + sourceContext, + lineNumber, + resolveContainerForColumn(group.Index)); + + internal void AddInstantiation(Group group) + { + AddInstantiationReference(group); + AddTypeExpression(group); + } + + internal void AddInstantiationReference(Group group) + { + var typeName = LastCppQualifiedSegment(group.Value); + var typeStart = + group.Index + + group.Value.LastIndexOf(typeName, StringComparison.Ordinal); + ReferenceExtractor.AddReference( + references, + seen, + fileId, + typeName, + typeStart, + "instantiate", + sourceContext, + lineNumber, + resolveContainerForColumn(typeStart)); + } + + internal void EmitCVaArgTypeOperandReferences() + => LanguageReferenceExtractionSupport.EmitCVaArgTypeOperandReferences( + PreparedLine, + references, + seen, + fileId, + sourceContext, + lineNumber, + resolveContainerForColumn, + Language); + } + + private static void EmitCppHeaderConstructionAndCastReferences( + CppTypeReferenceLineContext line) + { + var preparedLine = line.PreparedLine; + var originalLine = line.OriginalLine; + var hasCppIncludeMarker = !string.IsNullOrWhiteSpace(preparedLine) + && (originalLine.IndexOf('#') >= 0 + || originalLine.IndexOf("import", StringComparison.Ordinal) >= 0 + || originalLine.IndexOf("include", StringComparison.Ordinal) >= 0); + var includeMatch = hasCppIncludeMarker + ? CppIncludeRegex.Match(originalLine) + : Match.Empty; + if (includeMatch.Success) + line.AddReference(includeMatch.Groups["name"], "type_reference"); + + var baseMatch = preparedLine.IndexOf(':') >= 0 + && (ContainsOrdinalKeyword(preparedLine, "class") + || ContainsOrdinalKeyword(preparedLine, "struct")) + ? CppBaseListRegex.Match(preparedLine) + : Match.Empty; + if (baseMatch.Success) + { + var group = baseMatch.Groups["bases"]; + foreach (var (segmentStart, segmentLength) in + ReferenceExtractor.SplitTopLevelCommaSpans(group.Value)) + { + var segment = group.Value.Substring(segmentStart, segmentLength); + var expression = StripCppAccessPrefix(segment); + if (expression.Length == 0) + continue; + + var absoluteStart = group.Index + + segmentStart + + segment.IndexOf(expression, StringComparison.Ordinal); + line.AddTypeExpression(expression, absoluteStart); + } + } + + if (ContainsOrdinalKeyword(preparedLine, "new")) + { + foreach (Match match in CppNewTypeRegex.Matches(preparedLine)) + line.AddInstantiation(match.Groups["type"]); + } + + if (preparedLine.IndexOf("_cast", StringComparison.Ordinal) >= 0) + line.EmitTypeExpressions(CppNamedCastTypeRegex); + if (preparedLine.IndexOf('(') >= 0) + line.EmitTypeExpressions(CppCStyleCastTypeRegex); + } + + private static void EmitCTypeReferences(CppTypeReferenceLineContext line) + { + var preparedLine = line.PreparedLine; + var hasParen = preparedLine.IndexOf('(') >= 0; + var hasTypedef = preparedLine.IndexOf("_t", StringComparison.Ordinal) >= 0; + var hasTagged = ContainsOrdinalKeyword(preparedLine, "struct") + || preparedLine.IndexOf("enum", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("union", StringComparison.Ordinal) >= 0; + + EmitCTypePair( + line, + hasParen, + hasTypedef, + CTypedefCastTypeRegex, + hasTagged: false, + taggedRegex: null); + + var hasSizeof = hasParen + && preparedLine.IndexOf("sizeof", StringComparison.Ordinal) >= 0; + EmitCTypePair( + line, + hasSizeof, + hasTypedef, + CTypedefSizeofTypeRegex, + hasTagged, + CTaggedSizeofTypeRegex); + + var hasAlignof = hasParen + && (preparedLine.IndexOf("alignof", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("_Alignof", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("__alignof", StringComparison.Ordinal) >= 0); + EmitCTypePair( + line, + hasAlignof, + hasTypedef, + CTypedefAlignofTypeRegex, + hasTagged, + CTaggedAlignofTypeRegex); + + EmitCDeclarationTypeReferences(line, hasParen, hasTypedef, hasTagged); + EmitCExtensionTypeReferences(line, hasParen, hasTypedef, hasTagged); + EmitCFunctionPointerAndBuiltinOperandTypeReferences( + line, + hasParen, + hasTypedef, + hasTagged); + } + + private static void EmitCDeclarationTypeReferences( + CppTypeReferenceLineContext line, + bool hasParen, + bool hasTypedef, + bool hasTagged) + { + var preparedLine = line.PreparedLine; + var hasDeclarationTerminator = preparedLine.IndexOf('=') >= 0 + || preparedLine.IndexOf(',') >= 0 + || preparedLine.IndexOf(';') >= 0 + || preparedLine.IndexOf('[') >= 0; + EmitCTypePair( + line, + hasDeclarationTerminator, + hasTypedef, + CTypedefDeclarationTypeRegex, + hasTagged, + CTaggedDeclarationTypeRegex); + EmitCTypePair( + line, + hasParen, + hasTypedef, + CTypedefFunctionReturnTypeRegex, + hasTagged, + CTaggedFunctionReturnTypeRegex); + + var hasParameterDelimiter = hasParen + || preparedLine.IndexOf(',') >= 0; + EmitCTypePair( + line, + hasParameterDelimiter, + hasTypedef, + CTypedefParameterTypeRegex, + hasTagged, + CTaggedParameterTypeRegex); + + var hasCompoundLiteral = hasParen + && preparedLine.IndexOf('{') >= 0; + EmitCTypePair( + line, + hasCompoundLiteral, + hasTypedef, + CTypedefCompoundLiteralTypeRegex, + hasTagged, + CTaggedCompoundLiteralTypeRegex); + } + + private static void EmitCExtensionTypeReferences( + CppTypeReferenceLineContext line, + bool hasParen, + bool hasTypedef, + bool hasTagged) + { + var preparedLine = line.PreparedLine; + var hasTypeof = hasParen + && preparedLine.IndexOf("typeof", StringComparison.Ordinal) >= 0; + EmitCTypePair( + line, + hasTypeof, + hasTypedef, + CTypedefTypeofTypeRegex, + hasTagged, + CTaggedTypeofTypeRegex); + EmitCTypePair( + line, + hasTypeof, + hasTypedef, + CTypedefTypeofUnqualTypeRegex, + hasTagged, + CTaggedTypeofUnqualTypeRegex); + + var hasBuiltinTypesCompatible = hasParen + && preparedLine.IndexOf( + "__builtin_types_compatible_p", + StringComparison.Ordinal) >= 0; + EmitCTypePair( + line, + hasBuiltinTypesCompatible, + hasTypedef, + CTypedefBuiltinTypesCompatibleFirstTypeRegex, + hasTagged, + CTaggedBuiltinTypesCompatibleFirstTypeRegex); + EmitCTypePair( + line, + hasBuiltinTypesCompatible, + hasTypedef, + CTypedefBuiltinTypesCompatibleSecondTypeRegex, + hasTagged, + CTaggedBuiltinTypesCompatibleSecondTypeRegex); + + var hasGenericAssociation = preparedLine.IndexOf(':') >= 0 + && (preparedLine.IndexOf("_Generic", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf(',') >= 0); + EmitCTypePair( + line, + hasGenericAssociation, + hasTypedef, + CTypedefGenericAssociationTypeRegex, + hasTagged, + CTaggedGenericAssociationTypeRegex); + + var hasAtomic = hasParen + && preparedLine.IndexOf("_Atomic", StringComparison.Ordinal) >= 0; + EmitCTypePair( + line, + hasAtomic, + hasTypedef, + CTypedefAtomicTypeRegex, + hasTagged, + CTaggedAtomicTypeRegex); + + var hasAlignas = hasParen + && (preparedLine.IndexOf("alignas", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("_Alignas", StringComparison.Ordinal) >= 0); + EmitCTypePair( + line, + hasAlignas, + hasTypedef, + CTypedefAlignasTypeRegex, + hasTagged, + CTaggedAlignasTypeRegex); + } + + private static void EmitCFunctionPointerAndBuiltinOperandTypeReferences( + CppTypeReferenceLineContext line, + bool hasParen, + bool hasTypedef, + bool hasTagged) + { + var preparedLine = line.PreparedLine; + var hasFunctionPointer = hasParen + && preparedLine.IndexOf('*') >= 0; + var hasFunctionPointerAlias = hasFunctionPointer + && ContainsOrdinalKeyword(preparedLine, "typedef"); + EmitCTypePair( + line, + hasFunctionPointerAlias, + hasTypedef, + CTypedefFunctionPointerAliasTypeRegex, + hasTagged, + CTaggedFunctionPointerAliasTypeRegex); + EmitCTypePair( + line, + hasFunctionPointer, + hasTypedef, + CTypedefFunctionPointerDeclarationTypeRegex, + hasTagged, + CTaggedFunctionPointerDeclarationTypeRegex); + + var hasPointerArray = hasFunctionPointer + && preparedLine.IndexOf('[') >= 0; + EmitCTypePair( + line, + hasPointerArray, + hasTypedef, + CTypedefPointerArrayDeclarationTypeRegex, + hasTagged, + CTaggedPointerArrayDeclarationTypeRegex); + + var hasOffsetof = hasParen + && preparedLine.IndexOf(',') >= 0 + && (preparedLine.IndexOf("offsetof", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf( + "__builtin_offsetof", + StringComparison.Ordinal) >= 0); + EmitCTypePair( + line, + hasOffsetof, + hasTypedef, + CTypedefOffsetofTypeRegex, + hasTagged, + CTaggedOffsetofTypeRegex); + + var hasVaArg = hasParen + && preparedLine.IndexOf(',') >= 0 + && (preparedLine.IndexOf("va_arg", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf( + "__builtin_va_arg", + StringComparison.Ordinal) >= 0); + EmitCTypePair( + line, + hasVaArg, + hasTypedef, + CTypedefVaArgTypeRegex, + hasTagged, + CTaggedVaArgTypeRegex); + if (hasVaArg) + line.EmitCVaArgTypeOperandReferences(); + } + + private static void EmitCTypePair( + CppTypeReferenceLineContext line, + bool syntaxPresent, + bool hasTypedef, + Regex typedefRegex, + bool hasTagged, + Regex? taggedRegex) + { + if (!syntaxPresent) + return; + if (hasTypedef) + line.EmitTypeExpressions(typedefRegex); + if (hasTagged && taggedRegex != null) + line.EmitTypeExpressions(taggedRegex); + } + + private static void EmitCppOperandConstructionAndAliasReferences( + CppTypeReferenceLineContext line) + { + var preparedLine = line.PreparedLine; + var hasParen = preparedLine.IndexOf('(') >= 0; + var hasTemplateOpen = preparedLine.IndexOf('<') >= 0; + if (hasParen + && (preparedLine.IndexOf("sizeof", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("alignof", StringComparison.Ordinal) >= 0)) + { + line.EmitTypeExpressions(CppTypeOperandOperatorRegex); + } + if (hasParen + && preparedLine.IndexOf("typeid", StringComparison.Ordinal) >= 0) + { + line.EmitTypeExpressions(CppTypeIdRegex); + } + if (hasParen + && preparedLine.IndexOf('{') >= 0 + && preparedLine.IndexOf("decltype", StringComparison.Ordinal) >= 0) + { + line.EmitTypeExpressions(CppDecltypeBraceConstructionRegex); + } + if (hasParen + && hasTemplateOpen + && preparedLine.IndexOf("make_", StringComparison.Ordinal) >= 0) + { + line.EmitTypeExpressions(CppFactoryTemplateArgumentRegex); + } + if (hasTemplateOpen + && preparedLine.IndexOf("is_", StringComparison.Ordinal) >= 0) + { + line.EmitTypeExpressions(CppTypeTraitTemplateArgumentRegex); + } + + var hasBraceConstruction = preparedLine.IndexOf('{') >= 0 + && (preparedLine.IndexOf("return", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("throw", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf('=') >= 0); + if (hasBraceConstruction) + { + foreach (Match match in CppBraceConstructionRegex.Matches(preparedLine)) + line.AddInstantiation(match.Groups["type"]); + } + + var hasScopeSeparator = + preparedLine.IndexOf("::", StringComparison.Ordinal) >= 0; + if (hasBraceConstruction && hasTemplateOpen && hasScopeSeparator) + { + foreach (Match match in + CppQualifiedTemplateBraceConstructionRegex.Matches(preparedLine)) + { + var group = match.Groups["args"]; + line.AddTypeExpression(group); + } + } + + if (preparedLine.IndexOf("using", StringComparison.Ordinal) >= 0 + && preparedLine.IndexOf('=') >= 0 + && preparedLine.IndexOf(';') >= 0) + { + line.EmitTypeExpressions(CppUsingAliasTargetRegex); + } + if (!hasParen + && ContainsOrdinalKeyword(preparedLine, "typedef") + && preparedLine.IndexOf(';') >= 0) + { + line.EmitTypeExpressions(CppTypedefAliasTargetRegex); + } + + if (ContainsOrdinalKeyword(preparedLine, "template") + && preparedLine.IndexOf(';') >= 0 + && (ContainsOrdinalKeyword(preparedLine, "class") + || ContainsOrdinalKeyword(preparedLine, "struct"))) + { + foreach (Match match in + CppExplicitTemplateInstantiationRegex.Matches(preparedLine)) + { + line.AddInstantiation(match.Groups["type"]); + } + } + } + + private static void EmitCppConstraintAndDeclarationReferences( + CppTypeReferenceLineContext line) + { + var preparedLine = line.PreparedLine; + var hasParen = preparedLine.IndexOf('(') >= 0; + var hasTemplateOpen = preparedLine.IndexOf('<') >= 0; + var hasTemplateClose = preparedLine.IndexOf('>') >= 0; + var hasScopeSeparator = + preparedLine.IndexOf("::", StringComparison.Ordinal) >= 0; + + var hasTemplateIdDeclaration = hasTemplateOpen + && hasTemplateClose + && (preparedLine.IndexOf('=') >= 0 + || preparedLine.IndexOf(';') >= 0 + || preparedLine.IndexOf('{') >= 0 + || preparedLine.IndexOf(',') >= 0 + || preparedLine.IndexOf(')') >= 0 + || preparedLine.IndexOf('[') >= 0); + if (hasTemplateIdDeclaration) + { + foreach (Match match in + CppTemplateIdDeclarationRegex.Matches(preparedLine)) + { + if (IsCppTemplateDeclarationOrSpecializationLine( + preparedLine, + match.Index)) + { + continue; + } + + line.AddInstantiationReference(match.Groups["type"]); + var args = match.Groups["args"]; + line.AddTypeExpression(args); + } + } + + if (preparedLine.IndexOf('=') >= 0 + && (preparedLine.IndexOf("typename", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("class", StringComparison.Ordinal) >= 0)) + { + line.EmitTypeExpressions(CppTemplateParameterDefaultTypeRegex); + } + if (hasScopeSeparator) + line.EmitTypeExpressions(CppQualifiedMemberReceiverRegex); + if (hasScopeSeparator && preparedLine.IndexOf('*') >= 0) + line.EmitTypeExpressions(CppPointerToMemberTypeRegex); + if (preparedLine.IndexOf(')') >= 0 + && preparedLine.IndexOf("->", StringComparison.Ordinal) >= 0) + { + line.EmitTypeExpressions(CppTrailingReturnTypeRegex); + } + + EmitCppConceptReferences( + line, + hasParen, + hasTemplateOpen, + hasTemplateClose, + hasScopeSeparator); + + if (preparedLine.IndexOf("friend", StringComparison.Ordinal) >= 0 + && preparedLine.IndexOf(';') >= 0 + && (preparedLine.IndexOf("class", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("struct", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("union", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("typename", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("enum", StringComparison.Ordinal) >= 0)) + { + line.EmitTypeExpressions(CppFriendTypeRegex); + } + if (hasParen + && preparedLine.IndexOf("throw", StringComparison.Ordinal) >= 0) + { + line.EmitTypeExpressions(CppDynamicExceptionSpecRegex); + } + + var hasDeclarationTerminator = preparedLine.IndexOf(',') >= 0 + || preparedLine.IndexOf(';') >= 0 + || preparedLine.IndexOf(')') >= 0 + || preparedLine.IndexOf('=') >= 0; + var hasDeclarationType = hasDeclarationTerminator + && (ContainsAsciiUppercase(preparedLine) + || hasScopeSeparator + || hasTemplateOpen + || preparedLine.IndexOf("const", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("volatile", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("static", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("inline", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("constexpr", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("typename", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("class", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("struct", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("enum", StringComparison.Ordinal) >= 0); + if (!hasDeclarationType) + return; + + foreach (Match match in CppDeclarationTypeRegex.Matches(preparedLine)) + { + var group = match.Groups["type"]; + var expression = StripCppAccessPrefix(group.Value); + if (expression.Length == 0) + continue; + + var start = group.Index + + group.Value.IndexOf(expression, StringComparison.Ordinal); + line.AddTypeExpression(expression, start); + } + } + + private static void EmitCppConceptReferences( + CppTypeReferenceLineContext line, + bool hasParen, + bool hasTemplateOpen, + bool hasTemplateClose, + bool hasScopeSeparator) + { + var preparedLine = line.PreparedLine; + if (preparedLine.Contains("requires", StringComparison.Ordinal) + || preparedLine.Contains("concept", StringComparison.Ordinal)) + { + var hasRequiresConcept = hasTemplateOpen + && preparedLine.IndexOf("requires", StringComparison.Ordinal) >= 0; + if (hasRequiresConcept) + line.EmitTypeExpressions(CppRequiresConceptTypeRegex); + if (hasRequiresConcept && hasParen) + line.EmitTypeExpressions(CppParenthesizedRequiresConceptTypeRegex); + if (hasRequiresConcept && hasScopeSeparator) + { + foreach (Match match in + CppQualifiedRequiresConceptConstraintRegex.Matches( + preparedLine)) + { + line.AddTypeExpression(match.Groups["concept"]); + line.AddTypeExpression(match.Groups["args"]); + } + } + + if (hasTemplateOpen + && (preparedLine.IndexOf('=') >= 0 + || preparedLine.IndexOf("&&", StringComparison.Ordinal) >= 0 + || preparedLine.IndexOf("||", StringComparison.Ordinal) >= 0)) + { + line.EmitTypeExpressions(CppConceptExpressionTypeRegex); + } + } + + if (!hasTemplateOpen + || !hasTemplateClose + || preparedLine.IndexOf("->", StringComparison.Ordinal) < 0) + { + return; + } + + foreach (Match match in + CppCompoundRequirementConceptRegex.Matches(preparedLine)) + { + line.AddTypeExpression(match.Groups["concept"]); + line.AddTypeExpression(match.Groups["args"]); + } + } +} diff --git a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.CppTypes.cs b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.CppTypes.cs index 1dded049e..1207a592a 100644 --- a/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.CppTypes.cs +++ b/src/CodeIndex/Indexer/References/Support/LanguageReferenceExtractionSupport.CppTypes.cs @@ -1,5 +1,3 @@ -using System.Text.RegularExpressions; -using Regex = CodeIndex.Indexer.BoundedRegex; using CodeIndex.Models; namespace CodeIndex.Indexer; @@ -17,771 +15,20 @@ private static void EmitCppTypeReferences( int lineNumber, Func resolveContainerForColumn) { - var hasCppIncludeMarker = !string.IsNullOrWhiteSpace(preparedLine) - && (originalLine.IndexOf('#') >= 0 - || originalLine.IndexOf("import", StringComparison.Ordinal) >= 0 - || originalLine.IndexOf("include", StringComparison.Ordinal) >= 0); - var includeMatch = hasCppIncludeMarker - ? CppIncludeRegex.Match(originalLine) - : Match.Empty; - if (includeMatch.Success) - { - var group = includeMatch.Groups["name"]; - ReferenceExtractor.AddReference(references, seen, fileId, group.Value, group.Index, "type_reference", context, lineNumber, resolveContainerForColumn(group.Index)); - } - - var baseMatch = preparedLine.IndexOf(':') >= 0 - && (ContainsOrdinalKeyword(preparedLine, "class") - || ContainsOrdinalKeyword(preparedLine, "struct")) - ? CppBaseListRegex.Match(preparedLine) - : Match.Empty; - if (baseMatch.Success) - { - var group = baseMatch.Groups["bases"]; - foreach (var (segmentStart, segmentLength) in ReferenceExtractor.SplitTopLevelCommaSpans(group.Value)) - { - var segment = group.Value.Substring(segmentStart, segmentLength); - var expression = StripCppAccessPrefix(segment); - if (expression.Length == 0) - continue; - - var absoluteStart = group.Index + segmentStart + segment.IndexOf(expression, StringComparison.Ordinal); - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, expression, absoluteStart, context, lineNumber, resolveContainerForColumn(absoluteStart), language); - } - } - - if (ContainsOrdinalKeyword(preparedLine, "new")) - { - foreach (Match match in CppNewTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - var typeName = LastCppQualifiedSegment(group.Value); - var typeStart = group.Index + group.Value.LastIndexOf(typeName, StringComparison.Ordinal); - ReferenceExtractor.AddReference(references, seen, fileId, typeName, typeStart, "instantiate", context, lineNumber, resolveContainerForColumn(typeStart)); - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (preparedLine.IndexOf("_cast", StringComparison.Ordinal) >= 0) - { - foreach (Match match in CppNamedCastTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (preparedLine.IndexOf('(') >= 0) - { - foreach (Match match in CppCStyleCastTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - + var line = new CppTypeReferenceLineContext( + language, + preparedLine, + originalLine, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn); + EmitCppHeaderConstructionAndCastReferences(line); if (language == "c") - { - var hasCParen = preparedLine.IndexOf('(') >= 0; - var hasCTypedefTypeMarker = preparedLine.IndexOf("_t", StringComparison.Ordinal) >= 0; - var hasCTaggedTypeMarker = ContainsOrdinalKeyword(preparedLine, "struct") - || preparedLine.IndexOf("enum", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("union", StringComparison.Ordinal) >= 0; - if (hasCParen && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefCastTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCSizeofMarker = hasCParen - && preparedLine.IndexOf("sizeof", StringComparison.Ordinal) >= 0; - if (hasCSizeofMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefSizeofTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCSizeofMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedSizeofTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCAlignofMarker = hasCParen - && (preparedLine.IndexOf("alignof", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("_Alignof", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("__alignof", StringComparison.Ordinal) >= 0); - if (hasCAlignofMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefAlignofTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCAlignofMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedAlignofTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCDeclarationTerminator = preparedLine.IndexOf('=') >= 0 - || preparedLine.IndexOf(',') >= 0 - || preparedLine.IndexOf(';') >= 0 - || preparedLine.IndexOf('[') >= 0; - if (hasCDeclarationTerminator && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefDeclarationTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCDeclarationTerminator && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedDeclarationTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCParen && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefFunctionReturnTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCParen && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedFunctionReturnTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCParameterDelimiter = hasCParen || preparedLine.IndexOf(',') >= 0; - if (hasCParameterDelimiter && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefParameterTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCParameterDelimiter && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedParameterTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCCompoundLiteralMarkers = hasCParen && preparedLine.IndexOf('{') >= 0; - if (hasCCompoundLiteralMarkers && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefCompoundLiteralTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCCompoundLiteralMarkers && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedCompoundLiteralTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCTypeofMarker = hasCParen - && preparedLine.IndexOf("typeof", StringComparison.Ordinal) >= 0; - if (hasCTypeofMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefTypeofTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCTypeofMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedTypeofTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCTypeofMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefTypeofUnqualTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCTypeofMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedTypeofUnqualTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCBuiltinTypesCompatibleMarker = hasCParen - && preparedLine.IndexOf("__builtin_types_compatible_p", StringComparison.Ordinal) >= 0; - if (hasCBuiltinTypesCompatibleMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefBuiltinTypesCompatibleFirstTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCBuiltinTypesCompatibleMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefBuiltinTypesCompatibleSecondTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCBuiltinTypesCompatibleMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedBuiltinTypesCompatibleFirstTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCBuiltinTypesCompatibleMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedBuiltinTypesCompatibleSecondTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCGenericAssociationMarker = preparedLine.IndexOf(':') >= 0 - && (preparedLine.IndexOf("_Generic", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf(',') >= 0); - if (hasCGenericAssociationMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefGenericAssociationTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCGenericAssociationMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedGenericAssociationTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCAtomicMarker = hasCParen - && preparedLine.IndexOf("_Atomic", StringComparison.Ordinal) >= 0; - if (hasCAtomicMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefAtomicTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCAtomicMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedAtomicTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCAlignasMarker = hasCParen - && (preparedLine.IndexOf("alignas", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("_Alignas", StringComparison.Ordinal) >= 0); - if (hasCAlignasMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefAlignasTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCAlignasMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedAlignasTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCFunctionPointerMarker = hasCParen - && preparedLine.IndexOf('*') >= 0; - var hasCFunctionPointerAliasMarker = hasCFunctionPointerMarker - && ContainsOrdinalKeyword(preparedLine, "typedef"); - if (hasCFunctionPointerAliasMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefFunctionPointerAliasTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCFunctionPointerAliasMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedFunctionPointerAliasTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCFunctionPointerMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefFunctionPointerDeclarationTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCFunctionPointerMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedFunctionPointerDeclarationTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCPointerArrayMarker = hasCFunctionPointerMarker - && preparedLine.IndexOf('[') >= 0; - if (hasCPointerArrayMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefPointerArrayDeclarationTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCPointerArrayMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedPointerArrayDeclarationTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCOffsetofMarker = hasCParen - && preparedLine.IndexOf(',') >= 0 - && (preparedLine.IndexOf("offsetof", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("__builtin_offsetof", StringComparison.Ordinal) >= 0); - if (hasCOffsetofMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefOffsetofTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCOffsetofMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedOffsetofTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCVaArgMarker = hasCParen - && preparedLine.IndexOf(',') >= 0 - && (preparedLine.IndexOf("va_arg", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("__builtin_va_arg", StringComparison.Ordinal) >= 0); - if (hasCVaArgMarker && hasCTypedefTypeMarker) - { - foreach (Match match in CTypedefVaArgTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCVaArgMarker && hasCTaggedTypeMarker) - { - foreach (Match match in CTaggedVaArgTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCVaArgMarker) - EmitCVaArgTypeOperandReferences(preparedLine, references, seen, fileId, context, lineNumber, resolveContainerForColumn, language); - } - - var hasCppParen = preparedLine.IndexOf('(') >= 0; - var hasCppTypeOperandOperatorMarker = hasCppParen - && (preparedLine.IndexOf("sizeof", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("alignof", StringComparison.Ordinal) >= 0); - if (hasCppTypeOperandOperatorMarker) - { - foreach (Match match in CppTypeOperandOperatorRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppTypeIdMarker = hasCppParen - && preparedLine.IndexOf("typeid", StringComparison.Ordinal) >= 0; - if (hasCppTypeIdMarker) - { - foreach (Match match in CppTypeIdRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppDecltypeBraceMarker = hasCppParen - && preparedLine.IndexOf('{') >= 0 - && preparedLine.IndexOf("decltype", StringComparison.Ordinal) >= 0; - if (hasCppDecltypeBraceMarker) - { - foreach (Match match in CppDecltypeBraceConstructionRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppTemplateOpen = preparedLine.IndexOf('<') >= 0; - var hasCppFactoryTemplateMarker = hasCppParen - && hasCppTemplateOpen - && preparedLine.IndexOf("make_", StringComparison.Ordinal) >= 0; - if (hasCppFactoryTemplateMarker) - { - foreach (Match match in CppFactoryTemplateArgumentRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppTypeTraitTemplateMarker = hasCppTemplateOpen - && preparedLine.IndexOf("is_", StringComparison.Ordinal) >= 0; - if (hasCppTypeTraitTemplateMarker) - { - foreach (Match match in CppTypeTraitTemplateArgumentRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppBrace = preparedLine.IndexOf('{') >= 0; - var hasCppBraceConstructionMarker = hasCppBrace - && (preparedLine.IndexOf("return", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("throw", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf('=') >= 0); - if (hasCppBraceConstructionMarker) - { - foreach (Match match in CppBraceConstructionRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - var typeName = LastCppQualifiedSegment(group.Value); - var typeStart = group.Index + group.Value.LastIndexOf(typeName, StringComparison.Ordinal); - ReferenceExtractor.AddReference(references, seen, fileId, typeName, typeStart, "instantiate", context, lineNumber, resolveContainerForColumn(typeStart)); - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppScopeSeparator = preparedLine.IndexOf("::", StringComparison.Ordinal) >= 0; - var hasCppQualifiedTemplateBraceMarker = hasCppBraceConstructionMarker - && hasCppTemplateOpen - && hasCppScopeSeparator; - if (hasCppQualifiedTemplateBraceMarker) - { - foreach (Match match in CppQualifiedTemplateBraceConstructionRegex.Matches(preparedLine)) - { - var group = match.Groups["args"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppUsingAliasMarker = preparedLine.IndexOf("using", StringComparison.Ordinal) >= 0 - && preparedLine.IndexOf('=') >= 0 - && preparedLine.IndexOf(';') >= 0; - if (hasCppUsingAliasMarker) - { - foreach (Match match in CppUsingAliasTargetRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppTypedefAliasMarker = !hasCppParen - && ContainsOrdinalKeyword(preparedLine, "typedef") - && preparedLine.IndexOf(';') >= 0; - if (hasCppTypedefAliasMarker) - { - foreach (Match match in CppTypedefAliasTargetRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppExplicitTemplateInstantiationMarker = ContainsOrdinalKeyword(preparedLine, "template") - && preparedLine.IndexOf(';') >= 0 - && (ContainsOrdinalKeyword(preparedLine, "class") - || ContainsOrdinalKeyword(preparedLine, "struct")); - if (hasCppExplicitTemplateInstantiationMarker) - { - foreach (Match match in CppExplicitTemplateInstantiationRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - var typeName = LastCppQualifiedSegment(group.Value); - var typeStart = group.Index + group.Value.LastIndexOf(typeName, StringComparison.Ordinal); - ReferenceExtractor.AddReference(references, seen, fileId, typeName, typeStart, "instantiate", context, lineNumber, resolveContainerForColumn(typeStart)); - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppTemplateClose = preparedLine.IndexOf('>') >= 0; - var hasCppTemplateIdDeclarationMarker = hasCppTemplateOpen - && hasCppTemplateClose - && (preparedLine.IndexOf('=') >= 0 - || preparedLine.IndexOf(';') >= 0 - || preparedLine.IndexOf('{') >= 0 - || preparedLine.IndexOf(',') >= 0 - || preparedLine.IndexOf(')') >= 0 - || preparedLine.IndexOf('[') >= 0); - if (hasCppTemplateIdDeclarationMarker) - { - foreach (Match match in CppTemplateIdDeclarationRegex.Matches(preparedLine)) - { - if (IsCppTemplateDeclarationOrSpecializationLine(preparedLine, match.Index)) - continue; - - var group = match.Groups["type"]; - var typeName = LastCppQualifiedSegment(group.Value); - var typeStart = group.Index + group.Value.LastIndexOf(typeName, StringComparison.Ordinal); - ReferenceExtractor.AddReference(references, seen, fileId, typeName, typeStart, "instantiate", context, lineNumber, resolveContainerForColumn(typeStart)); - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, match.Groups["args"].Value, match.Groups["args"].Index, context, lineNumber, resolveContainerForColumn(match.Groups["args"].Index), language); - } - } - - var hasCppTemplateParameterDefaultMarker = preparedLine.IndexOf('=') >= 0 - && (preparedLine.IndexOf("typename", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("class", StringComparison.Ordinal) >= 0); - if (hasCppTemplateParameterDefaultMarker) - { - foreach (Match match in CppTemplateParameterDefaultTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCppScopeSeparator) - { - foreach (Match match in CppQualifiedMemberReceiverRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppPointerToMemberMarker = hasCppScopeSeparator - && preparedLine.IndexOf('*') >= 0; - if (hasCppPointerToMemberMarker) - { - foreach (Match match in CppPointerToMemberTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppTrailingReturnMarker = preparedLine.IndexOf(')') >= 0 - && preparedLine.IndexOf("->", StringComparison.Ordinal) >= 0; - if (hasCppTrailingReturnMarker) - { - foreach (Match match in CppTrailingReturnTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (preparedLine.Contains("requires", StringComparison.Ordinal) || preparedLine.Contains("concept", StringComparison.Ordinal)) - { - var hasCppRequiresConceptTypeMarker = hasCppTemplateOpen - && preparedLine.IndexOf("requires", StringComparison.Ordinal) >= 0; - if (hasCppRequiresConceptTypeMarker) - { - foreach (Match match in CppRequiresConceptTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - if (hasCppRequiresConceptTypeMarker && hasCppParen) - { - foreach (Match match in CppParenthesizedRequiresConceptTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppQualifiedRequiresConceptMarker = hasCppRequiresConceptTypeMarker - && hasCppScopeSeparator; - if (hasCppQualifiedRequiresConceptMarker) - { - foreach (Match match in CppQualifiedRequiresConceptConstraintRegex.Matches(preparedLine)) - { - var conceptGroup = match.Groups["concept"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, conceptGroup.Value, conceptGroup.Index, context, lineNumber, resolveContainerForColumn(conceptGroup.Index), language); - - var argsGroup = match.Groups["args"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, argsGroup.Value, argsGroup.Index, context, lineNumber, resolveContainerForColumn(argsGroup.Index), language); - } - } - - var hasCppConceptExpressionMarker = hasCppTemplateOpen - && (preparedLine.IndexOf('=') >= 0 - || preparedLine.IndexOf("&&", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("||", StringComparison.Ordinal) >= 0); - if (hasCppConceptExpressionMarker) - { - foreach (Match match in CppConceptExpressionTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - } - - var hasCppCompoundRequirementConceptMarker = hasCppTemplateOpen - && hasCppTemplateClose - && preparedLine.IndexOf("->", StringComparison.Ordinal) >= 0; - if (hasCppCompoundRequirementConceptMarker) - { - foreach (Match match in CppCompoundRequirementConceptRegex.Matches(preparedLine)) - { - var conceptGroup = match.Groups["concept"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, conceptGroup.Value, conceptGroup.Index, context, lineNumber, resolveContainerForColumn(conceptGroup.Index), language); - - var argsGroup = match.Groups["args"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, argsGroup.Value, argsGroup.Index, context, lineNumber, resolveContainerForColumn(argsGroup.Index), language); - } - } - - var hasCppFriendTypeMarker = preparedLine.IndexOf("friend", StringComparison.Ordinal) >= 0 - && preparedLine.IndexOf(';') >= 0 - && (preparedLine.IndexOf("class", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("struct", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("union", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("typename", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("enum", StringComparison.Ordinal) >= 0); - if (hasCppFriendTypeMarker) - { - foreach (Match match in CppFriendTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppDynamicExceptionSpecMarker = hasCppParen - && preparedLine.IndexOf("throw", StringComparison.Ordinal) >= 0; - if (hasCppDynamicExceptionSpecMarker) - { - foreach (Match match in CppDynamicExceptionSpecRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, group.Value, group.Index, context, lineNumber, resolveContainerForColumn(group.Index), language); - } - } - - var hasCppDeclarationTerminator = preparedLine.IndexOf(',') >= 0 - || preparedLine.IndexOf(';') >= 0 - || preparedLine.IndexOf(')') >= 0 - || preparedLine.IndexOf('=') >= 0; - var hasCppDeclarationTypeMarker = hasCppDeclarationTerminator - && (ContainsAsciiUppercase(preparedLine) - || hasCppScopeSeparator - || hasCppTemplateOpen - || preparedLine.IndexOf("const", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("volatile", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("static", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("inline", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("constexpr", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("typename", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("class", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("struct", StringComparison.Ordinal) >= 0 - || preparedLine.IndexOf("enum", StringComparison.Ordinal) >= 0); - if (hasCppDeclarationTypeMarker) - { - foreach (Match match in CppDeclarationTypeRegex.Matches(preparedLine)) - { - var group = match.Groups["type"]; - var expression = StripCppAccessPrefix(group.Value); - if (expression.Length == 0) - continue; - - var start = group.Index + group.Value.IndexOf(expression, StringComparison.Ordinal); - ReferenceExtractor.AddTypeExpressionSegments(references, seen, fileId, expression, start, context, lineNumber, resolveContainerForColumn(start), language); - } - } + EmitCTypeReferences(line); + EmitCppOperandConstructionAndAliasReferences(line); + EmitCppConstraintAndDeclarationReferences(line); } - - } From 666c20b74b915792814ceeb3a4be1e5f54557736 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 00:20:11 +0900 Subject: [PATCH 090/101] Separate core call classification --- ...ferenceExtractor.CoreCallClassification.cs | 348 ++++++++++++++++++ .../ReferenceExtractor.CoreCallReferences.cs | 216 +---------- 2 files changed, 353 insertions(+), 211 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs new file mode 100644 index 000000000..30a8e838c --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs @@ -0,0 +1,348 @@ +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private static bool TryAddCoreCallLikeReference( + CoreCallReferenceContext call, + string name, + int callIndex, + string? targetQualifier = null) + { + var line = call.Line; + var normalizedName = + line.Language == "fsharp" + && FSharpReferenceExtractor.IsOperatorCallName(name) + ? $"operator {name}" + : line.Language == "rust" + ? RustReferenceExtractor.NormalizeIdentifier(name) + : NormalizeAtPrefixedIdentifier(name); + + // In tuple-return declarations such as `private static (int Value, string Error) + // Resolve(...)`, CallRegex sees the modifier token as `static(`. It is a C# keyword, + // never a callable identifier, so suppress the phantom edge before graph ingestion. + // `private static (int Value, string Error) Resolve(...)` のような tuple return 宣言では + // CallRegex が modifier を `static(` と誤認する。C# keyword は呼び出し対象にならないため、 + // graph に入る前に phantom edge を除外する。 + if (line.Language == "csharp" && name == "static") + return false; + + if (line.Language == "rust" + && RustReferenceExtractor.IsFunctionDeclarationCallSite( + line.PreparedLine, + callIndex)) + { + return false; + } + if (line.Language == "rust" + && RustReferenceExtractor.IsDeriveAttributeCallSite( + line.PreparedLine, + normalizedName, + callIndex)) + { + return false; + } + if (line.Language == "wgsl" && name.StartsWith('@')) + return false; + if (line.Language == "kotlin" + && KotlinReferenceExtractor.IsInfixFunctionDeclarationSite( + line.PreparedLine, + callIndex)) + { + return false; + } + + // Suppress the same-line Java ctor declarator's self-call. CallRegex matches + // `CtorName(` at the declarator once per same-line ctor, but it is a declaration + // site — not a call — so attributing it to `class:CtorName` produces a phantom + // `CtorName|call|class|CtorName` edge. `line.DefinitionNames` does not cover this + // because same-line ctors do not appear in the symbol table. + // 同一行 ctor の宣言子 `CtorName(` は呼び出しではないため CallRegex の対象から除外する。 + if (call.JavaSameLineCtor != null + && callIndex == call.JavaSameLineCtor.Value.NameIndex + && string.Equals( + normalizedName, + call.JavaSameLineCtor.Value.Synthetic.Name, + StringComparison.Ordinal)) + { + return false; + } + + // C# positional patterns such as `case Point(var x, var y):` are type-pattern + // heads, not calls. `CallRegex` still sees `Point(` and would otherwise emit a + // phantom `call` edge alongside the real `type_reference`. + // C# の positional pattern (`case Point(var x, var y):`) は型パターンの先頭であり、 + // 呼び出しではない。`CallRegex` が `Point(` を拾ってしまうため、そのままだと + // 本物の `type_reference` に加えて phantom な `call` エッジが出る。 + if (line.Language == "csharp" + && CSharpReferenceExtractor.IsPatternHeadCallSite( + line.PreparedLines, + line.LineIndex, + line.PreparedLine, + callIndex)) + { + return false; + } + if (line.Language == "typescript" + && TypeScriptReferenceExtractor.IsSatisfiesTypeOperand( + line.PreparedLine, + callIndex)) + { + return false; + } + if (call.Definitions.ShouldSuppressDefinitionCall( + normalizedName, + name, + callIndex)) + { + return false; + } + + var callContainer = line.ResolveContainerForCall(callIndex); + if (line.Language == "csharp" + && callIndex + name.Length < line.PreparedLine.Length + && line.PreparedLine.AsSpan(callIndex + name.Length) + .TrimStart() + .StartsWith(".", StringComparison.Ordinal)) + { + var receiverLookups = call.Lookups.GetCSharpValueReceiverLookups(); + if (HasCSharpValueReceiverConflict( + normalizedName, + normalizedName, + line.LineNumber, + callIndex, + callContainer, + receiverLookups.ByContainingType, + receiverLookups.ByFunctionStartLine)) + { + var containingType = + GetContainingTypeQualifiedName(callContainer); + if (containingType != null + && receiverLookups.ByContainingType.TryGetValue( + containingType, + out var receiverNames) + && (receiverNames.InstanceNames.Contains(normalizedName) + || receiverNames.StaticNames.Contains(normalizedName)) + && call.Lookups.HasCSharpPrivateProperty( + containingType, + normalizedName)) + { + line.References.RemoveAll(reference => + reference.FileId == line.FileId + && reference.Line == line.LineNumber + && reference.Column == callIndex + 1 + && reference.ReferenceKind == "type_reference" + && string.Equals( + reference.SymbolName, + normalizedName, + StringComparison.Ordinal)); + AddReference( + line.References, + line.Seen, + line.FileId, + $"{containingType}.{normalizedName}", + callIndex, + "reference", + line.Context, + line.LineNumber, + callContainer, + line.Language); + } + + return false; + } + } + if (IsConstructorCallName( + line.Language, + line.PreparedLine, + callIndex)) + { + AddReference( + line.References, + line.Seen, + line.FileId, + normalizedName, + callIndex, + "instantiate", + line.Context, + line.LineNumber, + callContainer, + line.Language, + targetQualifier); + return true; + } + if (line.Language == "rust" + && RustReferenceExtractor.IsLikelyInstantiationCallName( + name, + normalizedName, + line.PreparedLine, + callIndex)) + { + AddReference( + line.References, + line.Seen, + line.FileId, + normalizedName, + callIndex, + "instantiate", + line.Context, + line.LineNumber, + callContainer, + line.Language); + return true; + } + if (line.Language == "python" + && TryGetKnownPythonTypeCall( + call, + normalizedName, + callIndex, + out var pythonTypeName)) + { + AddReference( + line.References, + line.Seen, + line.FileId, + pythonTypeName, + callIndex, + "instantiate", + line.Context, + line.LineNumber, + callContainer, + line.Language); + return true; + } + if (line.Language == "csharp" + && CSharpReferenceExtractor.ShouldSuppressQualifiedCommonMemberCall( + line.PreparedLine, + normalizedName, + callIndex)) + { + return false; + } + if (IsIgnoredCallName(line.Language, name) + && !(line.Language == "scala" + && string.Equals(name, "foreach", StringComparison.Ordinal))) + { + return false; + } + + var insideCSharpAttributeRange = call.CSharpAttributeRanges != null + && IsInsideCSharpAttributeRange( + call.CSharpAttributeRanges, + callIndex); + var metadataKind = TryClassifyMetadataReference( + line.Language, + line.PreparedLine, + callIndex, + insideCSharpAttributeRange); + if (metadataKind != null) + { + AddReference( + line.References, + line.Seen, + line.FileId, + normalizedName, + callIndex, + metadataKind, + line.Context, + line.LineNumber, + callContainer, + line.Language); + if (line.Language == "csharp" + && metadataKind == "attribute" + && CSharpReferenceExtractor.TryGetCallerInfoAttributeTypeName( + name, + line.PreparedLine, + callIndex) is { } callerInfoAttributeTypeName) + { + AddReference( + line.References, + line.Seen, + line.FileId, + callerInfoAttributeTypeName, + callIndex, + "type_reference", + line.Context, + line.LineNumber, + callContainer, + line.Language); + } + return true; + } + + if (line.Language == "kotlin" + && KotlinReferenceExtractor.IsConstructorCallName( + normalizedName, + call.KotlinConstructorTypeNames!)) + { + AddReference( + line.References, + line.Seen, + line.FileId, + normalizedName, + callIndex, + "instantiate", + line.Context, + line.LineNumber, + callContainer); + return true; + } + + if (line.Language is "javascript" or "typescript" + && SymbolExtractor.IsJavaScriptTypeScriptReactHookName( + normalizedName)) + { + AddReference( + line.References, + line.Seen, + line.FileId, + normalizedName, + callIndex, + "consumes_hook", + line.Context, + line.LineNumber, + callContainer); + return true; + } + + AddReference( + line.References, + line.Seen, + line.FileId, + normalizedName, + callIndex, + "call", + line.Context, + line.LineNumber, + callContainer, + ScientificNativeReferenceExtractor.Supports(line.Language) + ? line.Language + : null, + targetQualifier: targetQualifier); + return true; + } + + private static bool TryGetKnownPythonTypeCall( + CoreCallReferenceContext call, + string candidate, + int callIndex, + out string canonicalName) + { + canonicalName = candidate; + var separator = candidate.LastIndexOf('.'); + var leaf = separator >= 0 ? candidate[(separator + 1)..] : candidate; + if (leaf.Length == 0 || !char.IsUpper(leaf, 0)) + return false; + + if (call.Lookups.HasSameFilePythonClass(candidate, leaf)) + return true; + + return PythonImportBindingResolver.TryResolveImportedTypeCall( + candidate, + call.Line.PreparedLine, + callIndex, + call.Lookups.GetPythonImportedTypeCallLookup(), + out canonicalName); + } +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs index 41ba43f1d..ef1129850 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs @@ -78,218 +78,12 @@ void AddPowerShellParameterReference(string name, int callIndex) bool TryAddCallLikeReference( string name, int callIndex, - string? targetQualifier = null) - { - var normalizedName = line.Language == "fsharp" && FSharpReferenceExtractor.IsOperatorCallName(name) - ? $"operator {name}" - : line.Language == "rust" - ? RustReferenceExtractor.NormalizeIdentifier(name) - : NormalizeAtPrefixedIdentifier(name); - - // In tuple-return declarations such as `private static (int Value, string Error) - // Resolve(...)`, CallRegex sees the modifier token as `static(`. It is a C# keyword, - // never a callable identifier, so suppress the phantom edge before graph ingestion. - // `private static (int Value, string Error) Resolve(...)` のような tuple return 宣言では - // CallRegex が modifier を `static(` と誤認する。C# keyword は呼び出し対象にならないため、 - // graph に入る前に phantom edge を除外する。 - if (line.Language == "csharp" && name == "static") - return false; - - if (line.Language == "rust" && RustReferenceExtractor.IsFunctionDeclarationCallSite(line.PreparedLine, callIndex)) - return false; - if (line.Language == "rust" && RustReferenceExtractor.IsDeriveAttributeCallSite(line.PreparedLine, normalizedName, callIndex)) - return false; - if (line.Language == "wgsl" && name.StartsWith('@')) - return false; - if (line.Language == "kotlin" && KotlinReferenceExtractor.IsInfixFunctionDeclarationSite(line.PreparedLine, callIndex)) - return false; - - // Suppress the same-line Java ctor declarator's self-call. CallRegex matches - // `CtorName(` at the declarator once per same-line ctor, but it is a declaration - // site — not a call — so attributing it to `class:CtorName` produces a phantom - // `CtorName|call|class|CtorName` edge. `line.DefinitionNames` does not cover this - // because same-line ctors do not appear in the symbol table. - // 同一行 ctor の宣言子 `CtorName(` は呼び出しではないため CallRegex の対象から除外する。 - if (call.JavaSameLineCtor != null - && callIndex == call.JavaSameLineCtor.Value.NameIndex - && string.Equals(normalizedName, call.JavaSameLineCtor.Value.Synthetic.Name, StringComparison.Ordinal)) - { - return false; - } - - // C# positional patterns such as `case Point(var x, var y):` are type-pattern - // heads, not calls. `CallRegex` still sees `Point(` and would otherwise emit a - // phantom `call` edge alongside the real `type_reference`. - // C# の positional pattern (`case Point(var x, var y):`) は型パターンの先頭であり、 - // 呼び出しではない。`CallRegex` が `Point(` を拾ってしまうため、そのままだと - // 本物の `type_reference` に加えて phantom な `call` エッジが出る。 - var isCSharpPatternHeadCallSite = line.Language == "csharp" - && CSharpReferenceExtractor.IsPatternHeadCallSite(line.PreparedLines, line.LineIndex, line.PreparedLine, callIndex); - if (isCSharpPatternHeadCallSite) - return false; - if (line.Language == "typescript" && TypeScriptReferenceExtractor.IsSatisfiesTypeOperand(line.PreparedLine, callIndex)) - return false; - if (call.Definitions.ShouldSuppressDefinitionCall(normalizedName, name, callIndex)) - return false; - - var callContainer = line.ResolveContainerForCall(callIndex); - if (line.Language == "csharp" - && callIndex + name.Length < line.PreparedLine.Length - && line.PreparedLine.AsSpan(callIndex + name.Length).TrimStart().StartsWith(".", StringComparison.Ordinal)) - { - var receiverLookups = call.Lookups.GetCSharpValueReceiverLookups(); - if (HasCSharpValueReceiverConflict( - normalizedName, - normalizedName, - line.LineNumber, - callIndex, - callContainer, - receiverLookups.ByContainingType, - receiverLookups.ByFunctionStartLine)) - { - var containingType = GetContainingTypeQualifiedName(callContainer); - if (containingType != null - && receiverLookups.ByContainingType.TryGetValue(containingType, out var receiverNames) - && (receiverNames.InstanceNames.Contains(normalizedName) || receiverNames.StaticNames.Contains(normalizedName)) - && call.Lookups.HasCSharpPrivateProperty(containingType, normalizedName)) - { - line.References.RemoveAll(reference => - reference.FileId == line.FileId - && reference.Line == line.LineNumber - && reference.Column == callIndex + 1 - && reference.ReferenceKind == "type_reference" - && string.Equals(reference.SymbolName, normalizedName, StringComparison.Ordinal)); - AddReference( - line.References, - line.Seen, - line.FileId, - $"{containingType}.{normalizedName}", - callIndex, - "reference", - line.Context, - line.LineNumber, - callContainer, - line.Language); - } - - return false; - } - } - if (IsConstructorCallName(line.Language, line.PreparedLine, callIndex)) - { - AddReference( - line.References, - line.Seen, - line.FileId, - normalizedName, - callIndex, - "instantiate", - line.Context, - line.LineNumber, - callContainer, - line.Language, - targetQualifier); - return true; - } - if (line.Language == "rust" - && RustReferenceExtractor.IsLikelyInstantiationCallName(name, normalizedName, line.PreparedLine, callIndex)) - { - AddReference(line.References, line.Seen, line.FileId, normalizedName, callIndex, "instantiate", line.Context, line.LineNumber, callContainer, line.Language); - return true; - } - if (line.Language == "python" && TryGetKnownPythonTypeCall(normalizedName, out var pythonTypeName)) - { - AddReference(line.References, line.Seen, line.FileId, pythonTypeName, callIndex, "instantiate", line.Context, line.LineNumber, callContainer, line.Language); - return true; - } - if (line.Language == "csharp" - && CSharpReferenceExtractor.ShouldSuppressQualifiedCommonMemberCall(line.PreparedLine, normalizedName, callIndex)) - { - return false; - } - if (IsIgnoredCallName(line.Language, name)) - { - if (!(line.Language == "scala" && string.Equals(name, "foreach", StringComparison.Ordinal))) - return false; - } - - // issue #293: reclassify C# attribute / Java/Kotlin/Scala/TypeScript annotation - // usages with arguments so they do not pollute the call-graph as phantom `call` rows. - // issue #293: 引数付きの C# attribute と Java/Kotlin/Scala/TypeScript annotation 使用を - // `call` ではなく専用の種別に分類し、call-graph の phantom エッジを防ぐ。 - var insideCSharpAttributeRange = call.CSharpAttributeRanges != null - && IsInsideCSharpAttributeRange(call.CSharpAttributeRanges, callIndex); - var metadataKind = TryClassifyMetadataReference(line.Language, line.PreparedLine, callIndex, insideCSharpAttributeRange); - if (metadataKind != null) - { - AddReference(line.References, line.Seen, line.FileId, normalizedName, callIndex, metadataKind, line.Context, line.LineNumber, callContainer, line.Language); - if (line.Language == "csharp" - && metadataKind == "attribute" - && CSharpReferenceExtractor.TryGetCallerInfoAttributeTypeName(name, line.PreparedLine, callIndex) is { } callerInfoAttributeTypeName) - { - AddReference( - line.References, - line.Seen, - line.FileId, - callerInfoAttributeTypeName, - callIndex, - "type_reference", - line.Context, - line.LineNumber, - callContainer, - line.Language); - } - return true; - } - - if (line.Language == "kotlin" && KotlinReferenceExtractor.IsConstructorCallName(normalizedName, call.KotlinConstructorTypeNames!)) - { - AddReference(line.References, line.Seen, line.FileId, normalizedName, callIndex, "instantiate", line.Context, line.LineNumber, callContainer); - return true; - } - - if (line.Language is "javascript" or "typescript" - && SymbolExtractor.IsJavaScriptTypeScriptReactHookName(normalizedName)) - { - AddReference(line.References, line.Seen, line.FileId, normalizedName, callIndex, "consumes_hook", line.Context, line.LineNumber, callContainer); - return true; - } - - AddReference( - line.References, - line.Seen, - line.FileId, - normalizedName, + string? targetQualifier = null) => + TryAddCoreCallLikeReference( + call, + name, callIndex, - "call", - line.Context, - line.LineNumber, - callContainer, - ScientificNativeReferenceExtractor.Supports(line.Language) ? line.Language : null, - targetQualifier: targetQualifier); - return true; - - bool TryGetKnownPythonTypeCall(string candidate, out string canonicalName) - { - canonicalName = candidate; - var separator = candidate.LastIndexOf('.'); - var leaf = separator >= 0 ? candidate[(separator + 1)..] : candidate; - if (leaf.Length == 0 || !char.IsUpper(leaf, 0)) - return false; - - if (call.Lookups.HasSameFilePythonClass(candidate, leaf)) - { - return true; - } - - return PythonImportBindingResolver.TryResolveImportedTypeCall( - candidate, - line.PreparedLine, - callIndex, - call.Lookups.GetPythonImportedTypeCallLookup(), - out canonicalName); - } - } + targetQualifier); if (line.Language is "batch") BatchReferenceExtractor.EmitJumpTargetReferences( From edab4ef384e9a6fe109f06a186950351448336d0 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 00:31:58 +0900 Subject: [PATCH 091/101] Separate core reference line processing --- .../ReferenceExtractor.CoreExtraction.cs | 942 +++++------------- .../ReferenceExtractor.CoreReferenceLoop.cs | 716 +++++++++++++ 2 files changed, 964 insertions(+), 694 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/ReferenceExtractor.CoreReferenceLoop.cs diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs index bd7c5a028..0de0673e3 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs @@ -1,13 +1,11 @@ -using System.Text; -using System.Text.RegularExpressions; -using Regex = CodeIndex.Indexer.BoundedRegex; using CodeIndex.Models; namespace CodeIndex.Indexer; public static partial class ReferenceExtractor { - internal static List ExtractCore(ReferenceExtractionContext request) + internal static List ExtractCore( + ReferenceExtractionContext request) { request.CancellationToken.ThrowIfCancellationRequested(); var fileId = request.FileId; @@ -18,155 +16,161 @@ internal static List ExtractCore(ReferenceExtractionContext req var workspaceSymbols = request.WorkspaceSymbols; var requestedLanguage = request.RequestedLanguage; var isJsxFile = IsJsxFilePath(path); - var isRazorFile = IsRazorFilePath(path) || requestedLanguage is "razor" or "blazor" or "cshtml"; + var isRazorFile = IsRazorFilePath(path) + || requestedLanguage is "razor" or "blazor" or "cshtml"; if (language == "ambiguous_m") return ExtractAmbiguousMReferences(request); - if (language is "clojure" or "erlang" or "ocaml" or "raku") return ExtractFunctionalLanguageReferences(request); if (TryExtractStructuralMetadataReferences( - fileId, - language, - content, - symbols, - path, - request.ContentIsNormalized, - request.HasOversizeLine, - request.ConflictMarkerLine, - request.MaxReferenceCount, - request.CancellationToken, - out var structuralMetadataReferences)) + fileId, + language, + content, + symbols, + path, + request.ContentIsNormalized, + request.HasOversizeLine, + request.ConflictMarkerLine, + request.MaxReferenceCount, + request.CancellationToken, + out var structuralMetadataReferences)) + { return structuralMetadataReferences; + } if (!TryPrepareReferenceLines( - language, - content, - isRazorFile, - request.ContentIsNormalized, - request.HasOversizeLine, - request.ConflictMarkerLine, - out var preparedInput)) + language, + content, + isRazorFile, + request.ContentIsNormalized, + request.HasOversizeLine, + request.ConflictMarkerLine, + out var preparedInput)) + { return []; + } request.CancellationToken.ThrowIfCancellationRequested(); content = preparedInput.Content; var lines = preparedInput.Lines; - var xamlReferenceEnabled = language == "xml" && XamlReferenceExtractor.IsXaml(lines); + var xamlReferenceEnabled = language == "xml" + && XamlReferenceExtractor.IsXaml(lines); if (language == "xml" && !xamlReferenceEnabled) return []; var structuralLines = preparedInput.StructuralLines; - var csharpLinesInsideMultilineStringContent = preparedInput.CSharpLinesInsideMultilineStringContent; - var csharpLinesInsideBlockComment = preparedInput.CSharpLinesInsideBlockComment; - var referenceStructuralLines = preparedInput.ReferenceStructuralLines; + var referenceStructuralLines = + preparedInput.ReferenceStructuralLines; var preparedLines = preparedInput.PreparedLines; - var scientificNativeDependencyLimit = ScientificNativeReferenceExtractor.Supports(language) - ? GetSafetyLimits().MaxNamesPerLine - : 0; - var goImportBlockLines = preparedInput.GoImportBlockLines; - var luaReferenceLines = preparedInput.LuaReferenceLines; - var luaPreparedLines = preparedInput.LuaPreparedLines; - var lispReferenceLines = preparedInput.LispReferenceLines; - var razorReferenceLines = preparedInput.RazorReferenceLines; - var razorImplementedTypeNames = preparedInput.RazorImplementedTypeNames; - var typeScriptNamespaceAliases = preparedInput.TypeScriptNamespaceAliases; + var scientificNativeDependencyLimit = + ScientificNativeReferenceExtractor.Supports(language) + ? GetSafetyLimits().MaxNamesPerLine + : 0; var typeScriptTypeAliases = language == "typescript" ? TypeScriptReferenceExtractor.BuildTypeAliasTargets(preparedLines) : null; var swiftTypeAliases = language == "swift" ? SwiftReferenceExtractor.BuildTypeAliasTargets(preparedLines) : null; - var jsTaggedTemplatesByLine = preparedInput.JsTaggedTemplatesByLine; - // Pre-pass C# attribute analysis so cross-line `[\n Foo("x")\n]` and parameter - // attributes `void M([Attr] T x)` are classified consistently with same-line `[Foo]`. - // 行を跨いだ `[\n Foo("x")\n]` やパラメータ属性 `void M([Attr] T x)` も、同一行の `[Foo]` と - // 同じ判定で属性として扱えるように、事前パスで C# 属性セクションの範囲を構築する。 - var csharpAttrTables = language == "csharp" && content.Contains('[', StringComparison.Ordinal) - ? BuildCSharpAttributeRanges(preparedLines) - : (null, null); + + var csharpAttrTables = language == "csharp" + && content.Contains('[', StringComparison.Ordinal) + ? BuildCSharpAttributeRanges(preparedLines) + : (null, null); var csharpAttrRanges = csharpAttrTables.Item1; - // Top-level (paren-depth 0) zones inside attribute sections. Used by the no-arg - // attribute regex so that enum / qualified-constant identifiers appearing inside - // attribute argument lists (e.g. `AllowNumbers` in `[JsonConverter(ConverterStrategy.AllowNumbers)]`) - // are not misclassified as no-arg attribute references. - // 属性セクション内で paren 深さ 0 の top-level ゾーンだけを別テーブルで持つ。複数行 - // `[...]` の引数中に現れる enum / 修飾定数(`ConverterStrategy.AllowNumbers` など)が - // no-arg attribute として誤分類されないよう、no-arg 属性用ゲートに使う。 var csharpAttrTopLevelRanges = csharpAttrTables.Item2; - var definitionNamesComparer = GetDefinitionNamesComparer(language); - var definitionNamesByLine = BuildDefinitionNamesByLine(language, symbols, request.ReportDiagnostic); - var scientificDefinitionNameIndicesByLine = BuildScientificDefinitionNameIndicesByLine( + var definitionNamesComparer = + GetDefinitionNamesComparer(language); + var definitionNamesByLine = BuildDefinitionNamesByLine( language, - lines, symbols, - definitionNamesByLine); + request.ReportDiagnostic); + var scientificDefinitionNameIndicesByLine = + BuildScientificDefinitionNameIndicesByLine( + language, + lines, + symbols, + definitionNamesByLine); var allDefinitionNames = language == "stylus" - ? BuildAllDefinitionNames(language, symbols, request.ReportDiagnostic) + ? BuildAllDefinitionNames( + language, + symbols, + request.ReportDiagnostic) : null; var fileDefinitionNames = isRazorFile ? BuildFileDefinitionNames(symbols) : null; var sqlDefinitionLeafSpansByLine = language == "sql" - ? SqlReferenceExtractor.BuildDefinitionLeafSpansByLine(lines, symbols) + ? SqlReferenceExtractor.BuildDefinitionLeafSpansByLine( + lines, + symbols) : null; var sqlWindowFunctionCallSiteSuppressions = language == "sql" - ? SqlReferenceExtractor.BuildWindowFunctionCallSiteSuppressions(structuralLines) + ? SqlReferenceExtractor + .BuildWindowFunctionCallSiteSuppressions(structuralLines) : null; var cobolCallableSymbols = language == "cobol" ? BuildCobolCallableSymbols(symbols) : null; - // Include 'property' so expression-bodied and block-bodied property accessors - // attribute their calls to the property rather than falling through to the - // enclosing class (see issue #233). - // 式本体・ブロック本体のプロパティアクセサ内の呼び出しを、外側のクラスではなく - // プロパティ自身に帰属させる (issue #233 参照)。 - var containerCandidates = BuildReferenceContainerCandidates(symbols, request.ReportDiagnostic); - var containerResolver = new InnermostContainerResolver(containerCandidates); + var containerCandidates = BuildReferenceContainerCandidates( + symbols, + request.ReportDiagnostic); + var containerResolver = + new InnermostContainerResolver(containerCandidates); if (language == "solidity") - return ExtractSolidityReferences(fileId, lines, preparedLines, containerResolver); + { + return ExtractSolidityReferences( + fileId, + lines, + preparedLines, + containerResolver); + } - // Enclosing-type candidates for constructor-chain rewrites (class/struct/record; namespace excluded). - // Ordered innermost-first via ascending body range. Java enums can declare constructors and - // chain via `this(...)` so `enum` is included; C# enums cannot declare constructors, and - // `CSharpCtorChainRegex` will not match inside them, so including `enum` is a no-op there. - // コンストラクタ連鎖の呼び先解決で使う外側の型候補(class/struct/record/enum。namespace は含めない)。 - // 内側優先で昇順にソート。Java の enum は `this(...)` 連鎖を持てるため `enum` も含める。 - // C# の enum はコンストラクタ自体を持てず `CSharpCtorChainRegex` が一致しないので副作用は無い。 var swiftPropertyDefinitionsByLine = language == "swift" - ? BuildSwiftPropertyDefinitionsByLine(language, symbols, request.ReportDiagnostic) + ? BuildSwiftPropertyDefinitionsByLine( + language, + symbols, + request.ReportDiagnostic) : null; - - // Synthetic function-kind container for C# primary-ctor declarations with a base - // primary-ctor call such as `record Child(int x) : Parent(x)` or C# 12 `class Child(int x) : Parent(x)`. - // The range spans the entire declaration header so multi-line forms where `: Parent(x)` sits on a - // later line are covered. Later lines inside the body keep their real innermost containers. - // C# のプライマリコンストラクタ宣言(record / class / struct)で base primary-ctor を呼んでいる場合、 - // 宣言ヘッダー全体を合成コンテナで上書きする。`{` / `;` 以降の本体行は通常の container に戻す。 var csharpTypeNameSets = language == "csharp" ? BuildCSharpTypeNameSets(language, symbols) - : (KnownTypeNames: EmptyCSharpStringSet, NonEnumTypeNames: EmptyCSharpStringSet); + : ( + KnownTypeNames: EmptyCSharpStringSet, + NonEnumTypeNames: EmptyCSharpStringSet); var csharpKnownTypeNames = csharpTypeNameSets.KnownTypeNames; - var csharpNonEnumTypeNames = csharpTypeNameSets.NonEnumTypeNames; var csharpQualifiedPatternLookups = language == "csharp" - ? BuildCSharpQualifiedPatternLookups(language, symbols, csharpNonEnumTypeNames) + ? BuildCSharpQualifiedPatternLookups( + language, + symbols, + csharpTypeNameSets.NonEnumTypeNames) : ( EnumMemberLookup: EmptyCSharpQualifiedEnumMemberLookup, - ConstantPatternMemberLookup: EmptyCSharpQualifiedPatternLookup, + ConstantPatternMemberLookup: + EmptyCSharpQualifiedPatternLookup, TypePatternLookup: EmptyCSharpQualifiedPatternLookup); - var csharpQualifiedEnumMemberLookup = csharpQualifiedPatternLookups.EnumMemberLookup; - var csharpQualifiedConstantPatternMemberLookup = csharpQualifiedPatternLookups.ConstantPatternMemberLookup; - var csharpQualifiedTypePatternLookup = csharpQualifiedPatternLookups.TypePatternLookup; + var csharpQualifiedEnumMemberLookup = + csharpQualifiedPatternLookups.EnumMemberLookup; + var csharpQualifiedConstantPatternMemberLookup = + csharpQualifiedPatternLookups.ConstantPatternMemberLookup; + var csharpQualifiedTypePatternLookup = + csharpQualifiedPatternLookups.TypePatternLookup; + HashSet? kotlinConstructorTypeNames = null; HashSet? kotlinInfixFunctionNames = null; if (language == "kotlin") { - var kotlinNameSets = KotlinReferenceExtractor.BuildNameSets(language, symbols); - kotlinConstructorTypeNames = kotlinNameSets.ConstructorTypeNames; + var kotlinNameSets = + KotlinReferenceExtractor.BuildNameSets(language, symbols); + kotlinConstructorTypeNames = + kotlinNameSets.ConstructorTypeNames; kotlinInfixFunctionNames = kotlinNameSets.InfixFunctionNames; - KotlinReferenceExtractor.AddDeclaredInfixFunctionNames(lines, kotlinInfixFunctionNames); + KotlinReferenceExtractor.AddDeclaredInfixFunctionNames( + lines, + kotlinInfixFunctionNames); } + var callableDefinitionNames = language == "csharp" ? BuildCallableDefinitionNames(language, symbols) : null; @@ -176,51 +180,59 @@ internal static List ExtractCore(ReferenceExtractionContext req var dockerfileNameSets = language == "dockerfile" ? DockerfileReferenceExtractor.BuildNameSets(language, symbols) : default; - var dockerfileStageNames = dockerfileNameSets.StageNames; - var dockerfileVariableNames = dockerfileNameSets.VariableNames; var shellNameSets = language == "shell" ? ShellReferenceExtractor.BuildNameSets(language, symbols) : default; - var shellCallableNames = shellNameSets.CallableNames; - var shellGlobalAliasNames = shellNameSets.GlobalAliasNames; - var dynamicDeclarativeState = DynamicDeclarativeReferenceExtractor.CreateState( - language, - preparedLines, - referenceStructuralLines, - symbols); - IReadOnlyList<(int StartLine, int EndLine)> csharpNamespaceScopes = language == "csharp" - ? BuildCSharpNamespaceScopes(symbols) - : Array.Empty<(int StartLine, int EndLine)>(); + var dynamicDeclarativeState = + DynamicDeclarativeReferenceExtractor.CreateState( + language, + preparedLines, + referenceStructuralLines, + symbols); + IReadOnlyList<(int StartLine, int EndLine)> csharpNamespaceScopes = + language == "csharp" + ? BuildCSharpNamespaceScopes(symbols) + : Array.Empty<(int StartLine, int EndLine)>(); var csharpUsingImports = language == "csharp" - ? BuildCSharpUsingImports(language, symbols, csharpKnownTypeNames, csharpNamespaceScopes, lines, structuralLines) + ? BuildCSharpUsingImports( + language, + symbols, + csharpKnownTypeNames, + csharpNamespaceScopes, + lines, + structuralLines) : ( Aliases: Array.Empty(), Namespaces: Array.Empty(), Statics: Array.Empty()); var csharpUsingAliases = csharpUsingImports.Aliases; - var csharpUsingNamespaces = csharpUsingImports.Namespaces; var csharpUsingStatics = csharpUsingImports.Statics; var lookups = new CoreExtractionLookups( request, language, symbols, containerCandidates, - csharpLinesInsideMultilineStringContent, + preparedInput.CSharpLinesInsideMultilineStringContent, preparedLines, structuralLines, lines, csharpKnownTypeNames, csharpUsingAliases, - csharpUsingNamespaces); - + csharpUsingImports.Namespaces); - - - var references = CreateReferenceList(request.MaxReferenceCount, EstimateReferenceListInitialCapacity(lines.Length)); + var references = CreateReferenceList( + request.MaxReferenceCount, + EstimateReferenceListInitialCapacity(lines.Length)); var seen = CreateReferenceSeenSet(lines.Length); if (language == "csharp") { - EmitCSharpAsyncIteratorReferences(fileId, lines, structuralLines, symbols, references, seen); + EmitCSharpAsyncIteratorReferences( + fileId, + lines, + structuralLines, + symbols, + references, + seen); EmitCSharpStaticInterfaceMemberImplementationReferences( fileId, lines, @@ -238,554 +250,69 @@ internal static List ExtractCore(ReferenceExtractionContext req references, seen, fileId, - (lineNumber, _) => FindInnermostContainer(containerCandidates, lineNumber)); + (lineNumber, _) => + FindInnermostContainer( + containerCandidates, + lineNumber)); } - var pendingCSharpMultiLineTypePattern = default(CSharpMultiLineTypePatternState); - var pendingCSharpWhereConstraint = language == "csharp" ? new CSharpWhereConstraintState() : null; - var csharpLocalNamesByFunction = language == "csharp" - ? new Dictionary>(StringComparer.Ordinal) - : null; - var sqlState = language == "sql" ? SqlReferenceExtractor.CreateState() : null; - var csharpInDelimitedDocComment = false; - var jvmInDelimitedDocComment = false; - var phpInDocblock = false; - var markupSchemaState = language is "graphql" or "html" or "markdown" - ? new MarkupSchemaReferenceExtractor.MarkupState() - : null; - var xamlInXmlComment = false; - var xamlBindingPropertyElementState = language == "xml" - ? new XamlReferenceExtractor.BindingPropertyElementState() - : null; - var xamlBindingMarkupExtensionState = language == "xml" - ? new XamlReferenceExtractor.BindingMarkupExtensionState() - : null; - SymbolRecord? phpDocblockContainer = null; - HashSet? phpDocblockPropertyNames = null; - var sassPreparedCommentState = language == "sass" - ? new CssReferenceExtractor.SassLoudCommentState() - : null; - var sassOriginalCommentState = language == "sass" - ? new CssReferenceExtractor.SassLoudCommentState() - : null; - var sassStylusPreparedInBlockComment = false; - var sassStylusOriginalInBlockComment = false; - var shaderState = ShaderReferenceExtractor.CreateState( - language, - preparedLines, - symbols, - workspaceSymbols, - request.ReportDiagnostic); - - for (int i = 0; i < lines.Length; i++) - { - if (ReferenceLimitReached(references)) - break; - - if ((i & 0x3f) == 0) - request.CancellationToken.ThrowIfCancellationRequested(); - - var lineNumber = i + 1; - var originalLine = lines[i]; - var preparedLine = luaPreparedLines?[i] ?? lispReferenceLines?[i] ?? preparedLines[i]; - var originalLineForLanguage = originalLine; - if (language == "sass") - { - preparedLine = CssReferenceExtractor.MaskSassBlockCommentLine(preparedLine, sassPreparedCommentState!); - originalLineForLanguage = CssReferenceExtractor.MaskSassBlockCommentLine(originalLine, sassOriginalCommentState!); - } - else if (language == "stylus") - { - preparedLine = CssReferenceExtractor.MaskSassStylusBlockCommentLine( - preparedLine, - ref sassStylusPreparedInBlockComment); - originalLineForLanguage = CssReferenceExtractor.MaskSassStylusBlockCommentLine( - originalLine, - ref sassStylusOriginalInBlockComment); - } - var csharpAttrRangesOnLine = csharpAttrRanges?[i]; - var csharpAttrTopLevelOnLine = csharpAttrTopLevelRanges?[i]; - SymbolRecord? phpLineContainer = null; - var phpLineContainerResolved = false; - - SymbolRecord? GetPhpLineContainer() - { - if (!phpLineContainerResolved) - { - phpLineContainer = containerResolver.Find(lineNumber); - phpLineContainerResolved = true; - } - - return phpLineContainer; - } - - var documentationLine = new CoreDocumentationLineContext( - fileId, - language, - lines, - preparedLines, - structuralLines, - i, - lineNumber, - originalLine, - preparedLine, - references, - seen, - containerCandidates, - containerResolver, - lookups, - csharpLinesInsideMultilineStringContent, - csharpLinesInsideBlockComment, - csharpAttrRangesOnLine, - csharpAttrRanges, - GetPhpLineContainer); - EmitCoreDocumentationReferences( - documentationLine, - ref csharpInDelimitedDocComment, - ref jvmInDelimitedDocComment, - ref phpInDocblock, - ref phpDocblockContainer, - ref phpDocblockPropertyNames); - - var context = originalLine.Trim(); - if (language is "cmake" or "justfile" or "makefile" or "msbuild" - && context.Length > 0) - { - var buildAutomationContainer = containerResolver.Find(lineNumber); - BuildAutomationReferenceExtractor.EmitReferences( - language, - originalLine, - context, - lineNumber, - references, - seen, - fileId, - buildAutomationContainer); - continue; - } - - if (language is "graphql" or "html" or "markdown" - && context.Length > 0) - { - var markupContainer = containerResolver.Find(lineNumber); - MarkupSchemaReferenceExtractor.EmitReferences( - language, - originalLine, - context, - lineNumber, - references, - seen, - fileId, - markupContainer, - markupSchemaState); - continue; - } - if (string.IsNullOrWhiteSpace(preparedLine)) - { - if (language == "csharp" - && (pendingCSharpMultiLineTypePattern.WaitingForHead - || pendingCSharpMultiLineTypePattern.PendingTypeExpression != null)) + var pendingCSharpMultiLineTypePattern = + EmitCoreReferenceLines( + new CoreReferenceLoopContext { - continue; - } - - if (language == "csharp") - CSharpReferenceExtractor.FlushPendingMultiLineTypePatternReference( - ref pendingCSharpMultiLineTypePattern, + Request = request, + Preparation = preparedInput, + IsJsxFile = isJsxFile, + IsRazorFile = isRazorFile, + XamlReferenceEnabled = xamlReferenceEnabled, + ScientificNativeDependencyLimit = + scientificNativeDependencyLimit, + CSharpAttributeRanges = csharpAttrRanges, + CSharpAttributeTopLevelRanges = + csharpAttrTopLevelRanges, + DefinitionNamesComparer = definitionNamesComparer, + DefinitionNamesByLine = definitionNamesByLine, + ScientificDefinitionNameIndicesByLine = + scientificDefinitionNameIndicesByLine, + AllDefinitionNames = allDefinitionNames, + FileDefinitionNames = fileDefinitionNames, + SqlDefinitionLeafSpansByLine = + sqlDefinitionLeafSpansByLine, + SqlWindowFunctionCallSiteSuppressions = + sqlWindowFunctionCallSiteSuppressions, + CobolCallableSymbols = cobolCallableSymbols, + ContainerCandidates = containerCandidates, + ContainerResolver = containerResolver, + SwiftPropertyDefinitionsByLine = + swiftPropertyDefinitionsByLine, + CSharpQualifiedEnumMemberLookup = + csharpQualifiedEnumMemberLookup, + CSharpQualifiedConstantPatternMemberLookup = csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - lookups.HasActiveSameFileCSharpTypeCandidate, - references, - seen, - fileId); - continue; - } - - if (context.Length == 0) - continue; - - var definitionNames = definitionNamesByLine.TryGetValue(lineNumber, out var namesOnLine) - ? namesOnLine - : null; - Dictionary>? scientificDefinitionNameIndices = null; - scientificDefinitionNameIndicesByLine?.TryGetValue( - lineNumber, - out scientificDefinitionNameIndices); - List? sqlDefinitionLeafSpans = null; - if (language == "sql") - sqlDefinitionLeafSpansByLine?.TryGetValue(lineNumber, out sqlDefinitionLeafSpans); - var container = containerResolver.Find(lineNumber); - var definitionState = new CoreLineDefinitionState( - language, - context, - preparedLine, - definitionNames, - definitionNamesComparer, - scientificDefinitionNameIndices, - sqlDefinitionLeafSpans); - var csharpLineHasWhereClause = language == "csharp" - && preparedLine.IndexOf("where", StringComparison.Ordinal) >= 0 - && CSharpWhereClauseRegex.IsMatch(preparedLine); - - // Per-line Java same-line ctor synthesis. When `public Leaf(){super(0); doWork();}` - // is entirely on one line, SymbolExtractor does not emit a function symbol for the - // ctor (its method regex requires the line to end with `{`), so `FindInnermostContainer` - // returns the enclosing `class:Leaf`. Body-level calls such as `doWork()` would then - // attach to the class rather than the ctor. We pre-compute a synthetic function-kind - // container covering the body `{ ... }` region on the current line, so those calls - // land on `function:Leaf` and `callers Leaf` reflects what the ctor actually does. - // 同一行 ctor は function symbol が作られないため、body `{ ... }` 内の通常 call が - // 外側クラスに吸われてしまう。合成 function コンテナを per-line で構築して差し替える。 - (SymbolRecord Synthetic, int NameIndex, int OpenBraceIndex, int CloseBraceIndex)? javaSameLineCtor = null; - if (language == "java") - { - javaSameLineCtor = JavaReferenceExtractor.TryBuildSameLineCtorSpan( - preparedLine, - lineNumber, - lookups.GetEnclosingTypeCandidates); - } - - // Per-call-site record primary-ctor override: only calls whose column sits inside the - // record header (not in a braced body on the same line) should land on the synthetic - // ctor. Overriding `container` for the whole line would steal body-level calls such as - // `record Child(int V) : Parent(V) { public int Sum() => Add(V, 1); }` where `Add(...)` - // lives past the header-terminating `{` and must stay with its real innermost container. - // 同一行 record で `{` より後ろの本体呼び出しまで合成 ctor に奪われないよう、コール単位で - // ヘッダ範囲(end line の end column より前)に入っているかを判定して差し替える。 - SymbolRecord? ResolveContainerForCall(int column) - { - if (language == "csharp") - { - foreach (var (rangeStart, rangeStartColumn, rangeEnd, rangeEndColumn, syntheticRecordCtor) in lookups.GetRecordPrimaryCtorRanges()) - { - if (lineNumber < rangeStart || lineNumber > rangeEnd) - continue; - if (lineNumber == rangeStart && column < rangeStartColumn) - continue; - if (lineNumber == rangeEnd && column >= rangeEndColumn) - continue; - return syntheticRecordCtor; - } - } - - // Java same-line ctor body override: calls whose column sits strictly inside the - // `{ ... }` block on the ctor declaration line attach to the synthetic function-kind - // ctor instead of the enclosing class container. When no matching `}` is found on - // the same line (CloseBraceIndex < 0), the body extends beyond the current line — - // in that case SymbolExtractor emits a real ctor function symbol (its regex matches - // because the line ends with `{`), so this override is only needed for the fully - // same-line shape where the matching `}` exists on the same line. - // Java の same-line ctor では `{ ... }` 内の call を合成 function コンテナに振り向ける。 - if (javaSameLineCtor != null) - { - var info = javaSameLineCtor.Value; - if (info.CloseBraceIndex >= 0 - && column > info.OpenBraceIndex - && column < info.CloseBraceIndex) - { - return info.Synthetic; - } - } - - if (language == "csharp") - { - if (csharpLineHasWhereClause) - { - var declarationRangeContainer = FindInnermostCSharpDeclarationRangeContainer( - containerCandidates, - structuralLines[i], - lineNumber, - column); - if (declarationRangeContainer != null) - return declarationRangeContainer; - } - - var sameLineContainer = FindInnermostSameLineCSharpContainer( - lookups.GetCSharpSameLineContainerCandidatesByLine(), - structuralLines[i], - lineNumber, - column); - if (sameLineContainer != null) - return sameLineContainer; - - if (csharpLineHasWhereClause - && container?.Kind == "function" - && container.StartLine == lineNumber - && (!TryFindCSharpFunctionNameColumn(structuralLines[i], container.Name, out var containerNameColumn) - || column < containerNameColumn)) - { - return null; - } - } - - return dynamicDeclarativeState?.ResolveContainer(lineNumber, column, container) ?? container; - } - - SymbolRecord? ResolvePythonDefinitionContainer(int line, string kind) - { - var pythonDefinitionContainersByLineAndKind = lookups.GetPythonDefinitionContainersByLineAndKind(); - if (pythonDefinitionContainersByLineAndKind == null) - return null; - return pythonDefinitionContainersByLineAndKind.TryGetValue((line, kind), out var symbol) - ? symbol - : null; - } - - SymbolRecord? ResolveSwiftPropertyContainerForCall(int column) - { - if (swiftPropertyDefinitionsByLine != null - && swiftPropertyDefinitionsByLine.TryGetValue(lineNumber, out var sameLineProperties)) - { - foreach (var property in sameLineProperties) - { - if ((property.StartColumn ?? 0) <= column) - return property; - } - } - - return ResolveContainerForCall(column); - } - - var lineContext = new CoreReferenceLineContext( - fileId, - language, - lines, - preparedLines, - i, - preparedLine, - originalLine, - context, - lineNumber, - references, - seen, - container, - definitionNames, - ResolveContainerForCall); - - if (shaderState is not null) - { - ShaderReferenceExtractor.EmitLineReferences( - shaderState, - preparedLine, - originalLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - - if (isJsxFile && (language is "javascript" or "typescript")) - EmitJsxElementReferences(lineContext); - - var typeContext = new CoreTypeReferenceContext( - lineContext, - lookups, - containerCandidates, - symbols, - structuralLines, - csharpQualifiedConstantPatternMemberLookup, - csharpQualifiedTypePatternLookup, - csharpUsingAliases, - csharpUsingStatics, - csharpLocalNamesByFunction, - pendingCSharpWhereConstraint, - kotlinConstructorTypeNames, - typeScriptNamespaceAliases, - typeScriptTypeAliases, - swiftTypeAliases, - ResolveSwiftPropertyContainerForCall, - goImportBlockLines, - luaReferenceLines, - originalLineForLanguage, - allDefinitionNames, - stylusVariableDefinitionNames, - xamlReferenceEnabled, - xamlBindingPropertyElementState, - xamlBindingMarkupExtensionState); - if (EmitCoreTypeReferences( - typeContext, - ref pendingCSharpMultiLineTypePattern, - ref xamlInXmlComment)) - { - continue; - } - - EmitInfrastructureLineReferences( - lineContext, - dockerfileStageNames, - dockerfileVariableNames, - cobolCallableSymbols); - - var sqlSuppressedCallIndices = EmitSqlLineReferences( - lineContext, - structuralLines[i], - sqlState, - definitionState); - - - - // C# / Java parenless initializers: `new T { ... }` / `new T { ... }` / - // `new T[] { ... }` etc. CallRegex requires a trailing `(`, so these forms slip - // through and the type is otherwise never recorded as instantiated. Emit an - // `instantiate` row here so `references` / `callers` / `impact` see the edge. - // See issue #286. - // 括弧省略の C# / Java インスタンス化 (`new T { ... }` 等) は CallRegex で拾えないため、 - // 専用パスで `instantiate` を発行する。issue #286 参照。 - if (language is "csharp" or "java") - EmitParenlessInitializerReferences(lineContext); - - EmitPhpAndScssLineReferences(lineContext); - - var callContext = new CoreCallReferenceContext( - lineContext, - lookups, - javaSameLineCtor, - csharpAttrRangesOnLine, - kotlinConstructorTypeNames, - kotlinInfixFunctionNames, - shellCallableNames, - shellGlobalAliasNames, - dynamicDeclarativeState, - referenceStructuralLines[i], - scientificNativeDependencyLimit, - request.ReportDiagnostic, - sqlSuppressedCallIndices, - sqlWindowFunctionCallSiteSuppressions, - definitionState); - EmitCoreCallReferences(callContext); - - if (language == "csharp") - { - EmitMethodGroupReferences( - language, - preparedLine, - callableDefinitionNames, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - else if (language is "java") - { - JavaReferenceExtractor.EmitMethodReferenceReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - else if (language is "kotlin") - { - KotlinReferenceExtractor.EmitMethodReferenceReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - else if (language is "scala") - { - ScalaReferenceExtractor.EmitMethodReferenceReferences( - preparedLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - - if (language == "csharp") - { - CSharpReferenceExtractor.EmitStaticMemberQualifierReferences( - preparedLine, - csharpAttrRangesOnLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - - // Qualified C# enum-member access such as `Nested.A` or `Outer.First.None` is not - // a method call, but downstream symbol workflows (`references`, `callers`, - // `callees`, `inspect`, `impact`) still need an edge anchored to the narrowest - // real owner symbol. Ordinary code paths stay `call` so existing graph readers - // keep working, while C# attribute metadata sites are downgraded to `attribute` - // to stay out of runtime call-graph traversals (issue #293 / #492). - // `Nested.A` や `Outer.First.None` のような C# enum member の修飾アクセスは - // メソッド呼び出しではないが、下流の symbol workflow では実 owner に紐づく edge が必要。 - // 通常コードでは既存 reader / SQL 契約を守るため kind は `call` を維持し、C# 属性メタデータ内だけ - // `attribute` に落として runtime call-graph への混入を防ぐ (issue #293 / #492)。 - if (language == "csharp" && csharpQualifiedEnumMemberLookup.Count > 0) - { - CSharpReferenceExtractor.EmitQualifiedEnumMemberReferences( - preparedLine, - csharpQualifiedEnumMemberLookup, - csharpAttrRangesOnLine, - csharpUsingAliases, - lookups.GetCSharpValueReceiverNames, - lookups.GetCSharpFunctionValueReceiverNames, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall); - } - - // issue #268: JS/TS tagged template literal call sites. The structural masker - // already located each template opener and captured its preceding tag identifier; - // emit one `call` row per hit so `gql\`...\`` / `styled.div\`...\`` / `sql\`...${x}...\`` - // surface in references / callers / callees / impact just like `fn()` call sites. - // issue #268: JS/TS タグ付きテンプレートリテラルの呼び出し位置。構造マスカーが - // テンプレート opener を検出済みで先行する tag 識別子を記録しているため、そのまま - // `call` として発行し、`gql\`...\``・`styled.div\`...\``・`sql\`...${x}...\`` を - // references / callers / callees / impact に反映する。 - if (jsTaggedTemplatesByLine != null - && jsTaggedTemplatesByLine.TryGetValue(lineNumber, out var tagHitsOnLine)) - { - EmitJavaScriptTaggedTemplateReferences(lineContext, tagHitsOnLine); - } - - // issue #293: bare no-arg attributes / annotations are invisible to CallRegex because - // it requires `(`. Emit them from dedicated regexes so `[Serializable]` / `@Deprecated` - // and their siblings still populate the reference table. - // issue #293: 引数なしの属性・アノテーションは `(` が必須な CallRegex では拾えないため、 - // 専用 regex から `[Serializable]` / `@Deprecated` などの素形を reference テーブルへ反映する。 - EmitMetadataLineReferences(lineContext, csharpAttrTopLevelOnLine); - - if (isRazorFile && language == "csharp") - { - RazorReferenceExtractor.EmitReferences( - razorReferenceLines?[i] ?? originalLine, - references, - seen, - fileId, - context, - lineNumber, - ResolveContainerForCall, - definitionNames, - fileDefinitionNames, - razorImplementedTypeNames); - } - - if (language == "python") - EmitPythonLineReferences(lineContext, lookups, ResolvePythonDefinitionContainer); - - if (language == "r") - EmitRLineReferences(lineContext); - } + CSharpQualifiedTypePatternLookup = + csharpQualifiedTypePatternLookup, + KotlinConstructorTypeNames = + kotlinConstructorTypeNames, + KotlinInfixFunctionNames = kotlinInfixFunctionNames, + CallableDefinitionNames = callableDefinitionNames, + StylusVariableDefinitionNames = + stylusVariableDefinitionNames, + DockerfileStageNames = + dockerfileNameSets.StageNames, + DockerfileVariableNames = + dockerfileNameSets.VariableNames, + ShellCallableNames = shellNameSets.CallableNames, + ShellGlobalAliasNames = + shellNameSets.GlobalAliasNames, + DynamicDeclarativeState = dynamicDeclarativeState, + CSharpUsingAliases = csharpUsingAliases, + CSharpUsingStatics = csharpUsingStatics, + Lookups = lookups, + TypeScriptTypeAliases = typeScriptTypeAliases, + SwiftTypeAliases = swiftTypeAliases, + References = references, + Seen = seen, + }); if (!ReferenceLimitReached(references) && language == "csharp") { @@ -802,51 +329,78 @@ internal static List ExtractCore(ReferenceExtractionContext req seen, fileId); - CSharpReferenceExtractor.FlushPendingMultiLineTypePatternReference( - ref pendingCSharpMultiLineTypePattern, - csharpQualifiedConstantPatternMemberLookup, - csharpUsingAliases, - csharpUsingStatics, - lookups.HasActiveSameFileCSharpTypeCandidate, - references, - seen, - fileId); + CSharpReferenceExtractor + .FlushPendingMultiLineTypePatternReference( + ref pendingCSharpMultiLineTypePattern, + csharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases, + csharpUsingStatics, + lookups.HasActiveSameFileCSharpTypeCandidate, + references, + seen, + fileId); } if (language == "csharp") { - foreach (var reference in references) - { - if (reference.ReferenceKind != "type_reference" - || reference.Line <= 0 - || reference.Line > preparedLines.Length - || reference.Column <= 0) - { - continue; - } - - var line = preparedLines[reference.Line - 1]; - var tokenEnd = reference.Column - 1 + reference.SymbolName.Length; - if (tokenEnd >= line.Length || !line.AsSpan(tokenEnd).TrimStart().StartsWith(".", StringComparison.Ordinal)) - continue; - - var owner = lookups.FindCSharpContainerCandidate(reference.ContainerName, reference.Line); - var containingType = GetContainingTypeQualifiedName(owner); - if (containingType == null || !lookups.HasCSharpPrivateProperty(containingType, reference.SymbolName)) - { - continue; - } - - reference.SymbolName = $"{containingType}.{reference.SymbolName}"; - reference.ReferenceKind = "reference"; - } + RewriteCSharpPrivatePropertyReceiverReferences( + preparedLines, + references, + lookups); } lookups.ApplyCSharpUsingAliasReferenceNames(references); if (!ReferenceLimitReached(references)) - lookups.EmitCSharpBclRegexWithoutTimeoutReferences(references, seen); + { + lookups.EmitCSharpBclRegexWithoutTimeoutReferences( + references, + seen); + } MarkMutualRecursionReferences(references); return references; } + private static void RewriteCSharpPrivatePropertyReceiverReferences( + IReadOnlyList preparedLines, + List references, + CoreExtractionLookups lookups) + { + foreach (var reference in references) + { + if (reference.ReferenceKind != "type_reference" + || reference.Line <= 0 + || reference.Line > preparedLines.Count + || reference.Column <= 0) + { + continue; + } + + var line = preparedLines[reference.Line - 1]; + var tokenEnd = + reference.Column - 1 + reference.SymbolName.Length; + if (tokenEnd >= line.Length + || !line.AsSpan(tokenEnd) + .TrimStart() + .StartsWith(".", StringComparison.Ordinal)) + { + continue; + } + + var owner = lookups.FindCSharpContainerCandidate( + reference.ContainerName, + reference.Line); + var containingType = GetContainingTypeQualifiedName(owner); + if (containingType == null + || !lookups.HasCSharpPrivateProperty( + containingType, + reference.SymbolName)) + { + continue; + } + + reference.SymbolName = + $"{containingType}.{reference.SymbolName}"; + reference.ReferenceKind = "reference"; + } + } } diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreReferenceLoop.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreReferenceLoop.cs new file mode 100644 index 000000000..a05f73e6c --- /dev/null +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreReferenceLoop.cs @@ -0,0 +1,716 @@ +using CodeIndex.Models; + +namespace CodeIndex.Indexer; + +public static partial class ReferenceExtractor +{ + private sealed class CoreReferenceLoopContext + { + internal required ReferenceExtractionContext Request { get; init; } + internal required ReferenceLinePreparation Preparation { get; init; } + internal required bool IsJsxFile { get; init; } + internal required bool IsRazorFile { get; init; } + internal required bool XamlReferenceEnabled { get; init; } + internal required int ScientificNativeDependencyLimit { get; init; } + internal required List<(int start, int end)>?[]? CSharpAttributeRanges { get; init; } + internal required List<(int start, int end)>?[]? CSharpAttributeTopLevelRanges { get; init; } + internal required StringComparer DefinitionNamesComparer { get; init; } + internal required IReadOnlyDictionary> DefinitionNamesByLine { get; init; } + internal IReadOnlyDictionary>>? ScientificDefinitionNameIndicesByLine { get; init; } + internal IReadOnlySet? AllDefinitionNames { get; init; } + internal IReadOnlySet? FileDefinitionNames { get; init; } + internal Dictionary>? SqlDefinitionLeafSpansByLine { get; init; } + internal HashSet<(int LineNumber, int ColumnIndex)>? SqlWindowFunctionCallSiteSuppressions { get; init; } + internal IReadOnlyList? CobolCallableSymbols { get; init; } + internal required IReadOnlyList ContainerCandidates { get; init; } + internal required InnermostContainerResolver ContainerResolver { get; init; } + internal IReadOnlyDictionary? SwiftPropertyDefinitionsByLine { get; init; } + internal required IReadOnlyDictionary> CSharpQualifiedEnumMemberLookup { get; init; } + internal required IReadOnlyDictionary> CSharpQualifiedConstantPatternMemberLookup { get; init; } + internal required IReadOnlyDictionary> CSharpQualifiedTypePatternLookup { get; init; } + internal HashSet? KotlinConstructorTypeNames { get; init; } + internal HashSet? KotlinInfixFunctionNames { get; init; } + internal HashSet? CallableDefinitionNames { get; init; } + internal HashSet? StylusVariableDefinitionNames { get; init; } + internal HashSet? DockerfileStageNames { get; init; } + internal HashSet? DockerfileVariableNames { get; init; } + internal HashSet? ShellCallableNames { get; init; } + internal HashSet? ShellGlobalAliasNames { get; init; } + internal DynamicDeclarativeReferenceExtractor.ExtractionState? DynamicDeclarativeState { get; init; } + internal required IReadOnlyList CSharpUsingAliases { get; init; } + internal required IReadOnlyList CSharpUsingStatics { get; init; } + internal required CoreExtractionLookups Lookups { get; init; } + internal IReadOnlyList? TypeScriptTypeAliases { get; init; } + internal IReadOnlyList? SwiftTypeAliases { get; init; } + internal required List References { get; init; } + internal required ReferenceDedupeSet Seen { get; init; } + } + + private sealed class CoreReferenceLoopMutableState + { + internal bool CSharpInDelimitedDocComment; + internal bool JvmInDelimitedDocComment; + internal bool PhpInDocblock; + internal SymbolRecord? PhpDocblockContainer; + internal HashSet? PhpDocblockPropertyNames; + internal MarkupSchemaReferenceExtractor.MarkupState? MarkupSchemaState; + internal CssReferenceExtractor.SassLoudCommentState? SassPreparedCommentState; + internal CssReferenceExtractor.SassLoudCommentState? SassOriginalCommentState; + internal bool SassStylusPreparedInBlockComment; + internal bool SassStylusOriginalInBlockComment; + + internal CoreReferenceLoopMutableState(string language) + { + if (language is "graphql" or "html" or "markdown") + { + MarkupSchemaState = + new MarkupSchemaReferenceExtractor.MarkupState(); + } + if (language == "sass") + { + SassPreparedCommentState = + new CssReferenceExtractor.SassLoudCommentState(); + SassOriginalCommentState = + new CssReferenceExtractor.SassLoudCommentState(); + } + } + } + + private static CSharpMultiLineTypePatternState EmitCoreReferenceLines( + CoreReferenceLoopContext loop) + { + var request = loop.Request; + var fileId = request.FileId; + var language = request.Language; + var symbols = request.Symbols; + var workspaceSymbols = request.WorkspaceSymbols; + var input = loop.Preparation; + var lines = input.Lines; + var preparedLines = input.PreparedLines; + var structuralLines = input.StructuralLines; + var referenceStructuralLines = input.ReferenceStructuralLines; + var references = loop.References; + var seen = loop.Seen; + var containerCandidates = loop.ContainerCandidates; + var containerResolver = loop.ContainerResolver; + var lookups = loop.Lookups; + var csharpAttrRanges = loop.CSharpAttributeRanges; + var csharpAttrTopLevelRanges = loop.CSharpAttributeTopLevelRanges; + var csharpUsingAliases = loop.CSharpUsingAliases; + var csharpUsingStatics = loop.CSharpUsingStatics; + var dynamicDeclarativeState = loop.DynamicDeclarativeState; + var pendingCSharpMultiLineTypePattern = + default(CSharpMultiLineTypePatternState); + var pendingCSharpWhereConstraint = language == "csharp" + ? new CSharpWhereConstraintState() + : null; + var csharpLocalNamesByFunction = language == "csharp" + ? new Dictionary>(StringComparer.Ordinal) + : null; + var sqlState = language == "sql" + ? SqlReferenceExtractor.CreateState() + : null; + var xamlInXmlComment = false; + var xamlBindingPropertyElementState = language == "xml" + ? new XamlReferenceExtractor.BindingPropertyElementState() + : null; + var xamlBindingMarkupExtensionState = language == "xml" + ? new XamlReferenceExtractor.BindingMarkupExtensionState() + : null; + var mutableState = new CoreReferenceLoopMutableState(language); + var shaderState = ShaderReferenceExtractor.CreateState( + language, + preparedLines, + symbols, + workspaceSymbols, + request.ReportDiagnostic); + + for (var i = 0; i < lines.Length; i++) + { + if (ReferenceLimitReached(references)) + break; + + if ((i & 0x3f) == 0) + request.CancellationToken.ThrowIfCancellationRequested(); + + var lineNumber = i + 1; + var originalLine = lines[i]; + var languageLines = PrepareCoreLanguageLine( + loop, + mutableState, + i, + originalLine); + var preparedLine = languageLines.PreparedLine; + var originalLineForLanguage = + languageLines.OriginalLineForLanguage; + var csharpAttrRangesOnLine = csharpAttrRanges?[i]; + var csharpAttrTopLevelOnLine = csharpAttrTopLevelRanges?[i]; + if (EmitCoreDocumentationAndSpecialLineReferences( + loop, + mutableState, + i, + originalLine, + preparedLine, + csharpAttrRangesOnLine, + out var sourceContext)) + { + continue; + } + + if (string.IsNullOrWhiteSpace(preparedLine)) + { + if (language == "csharp" + && (pendingCSharpMultiLineTypePattern.WaitingForHead + || pendingCSharpMultiLineTypePattern + .PendingTypeExpression != null)) + { + continue; + } + + if (language == "csharp") + { + CSharpReferenceExtractor + .FlushPendingMultiLineTypePatternReference( + ref pendingCSharpMultiLineTypePattern, + loop.CSharpQualifiedConstantPatternMemberLookup, + csharpUsingAliases, + csharpUsingStatics, + lookups.HasActiveSameFileCSharpTypeCandidate, + references, + seen, + fileId); + } + continue; + } + + if (sourceContext.Length == 0) + continue; + + var definitionNames = + loop.DefinitionNamesByLine.TryGetValue( + lineNumber, + out var namesOnLine) + ? namesOnLine + : null; + Dictionary>? + scientificDefinitionNameIndices = null; + loop.ScientificDefinitionNameIndicesByLine?.TryGetValue( + lineNumber, + out scientificDefinitionNameIndices); + List? + sqlDefinitionLeafSpans = null; + if (language == "sql") + { + loop.SqlDefinitionLeafSpansByLine?.TryGetValue( + lineNumber, + out sqlDefinitionLeafSpans); + } + var container = containerResolver.Find(lineNumber); + var definitionState = new CoreLineDefinitionState( + language, + sourceContext, + preparedLine, + definitionNames, + loop.DefinitionNamesComparer, + scientificDefinitionNameIndices, + sqlDefinitionLeafSpans); + var csharpLineHasWhereClause = language == "csharp" + && preparedLine.IndexOf( + "where", + StringComparison.Ordinal) >= 0 + && CSharpWhereClauseRegex.IsMatch(preparedLine); + + (SymbolRecord Synthetic, int NameIndex, int OpenBraceIndex, + int CloseBraceIndex)? javaSameLineCtor = null; + if (language == "java") + { + javaSameLineCtor = + JavaReferenceExtractor.TryBuildSameLineCtorSpan( + preparedLine, + lineNumber, + lookups.GetEnclosingTypeCandidates); + } + + SymbolRecord? ResolveContainerForCall(int column) + { + if (language == "csharp") + { + foreach (var ( + rangeStart, + rangeStartColumn, + rangeEnd, + rangeEndColumn, + syntheticRecordCtor) in + lookups.GetRecordPrimaryCtorRanges()) + { + if (lineNumber < rangeStart || lineNumber > rangeEnd) + continue; + if (lineNumber == rangeStart + && column < rangeStartColumn) + { + continue; + } + if (lineNumber == rangeEnd && column >= rangeEndColumn) + continue; + return syntheticRecordCtor; + } + } + + if (javaSameLineCtor != null) + { + var info = javaSameLineCtor.Value; + if (info.CloseBraceIndex >= 0 + && column > info.OpenBraceIndex + && column < info.CloseBraceIndex) + { + return info.Synthetic; + } + } + + if (language == "csharp") + { + if (csharpLineHasWhereClause) + { + var declarationRangeContainer = + FindInnermostCSharpDeclarationRangeContainer( + containerCandidates, + structuralLines[i], + lineNumber, + column); + if (declarationRangeContainer != null) + return declarationRangeContainer; + } + + var sameLineContainer = + FindInnermostSameLineCSharpContainer( + lookups + .GetCSharpSameLineContainerCandidatesByLine(), + structuralLines[i], + lineNumber, + column); + if (sameLineContainer != null) + return sameLineContainer; + + if (csharpLineHasWhereClause + && container?.Kind == "function" + && container.StartLine == lineNumber + && (!TryFindCSharpFunctionNameColumn( + structuralLines[i], + container.Name, + out var containerNameColumn) + || column < containerNameColumn)) + { + return null; + } + } + + return dynamicDeclarativeState?.ResolveContainer( + lineNumber, + column, + container) ?? container; + } + + SymbolRecord? ResolvePythonDefinitionContainer( + int lineNumberCandidate, + string kind) + { + var pythonDefinitionContainersByLineAndKind = + lookups.GetPythonDefinitionContainersByLineAndKind(); + if (pythonDefinitionContainersByLineAndKind == null) + return null; + return pythonDefinitionContainersByLineAndKind.TryGetValue( + (lineNumberCandidate, kind), + out var symbol) + ? symbol + : null; + } + + SymbolRecord? ResolveSwiftPropertyContainerForCall(int column) + { + if (loop.SwiftPropertyDefinitionsByLine != null + && loop.SwiftPropertyDefinitionsByLine.TryGetValue( + lineNumber, + out var sameLineProperties)) + { + foreach (var property in sameLineProperties) + { + if ((property.StartColumn ?? 0) <= column) + return property; + } + } + + return ResolveContainerForCall(column); + } + + var lineContext = new CoreReferenceLineContext( + fileId, + language, + lines, + preparedLines, + i, + preparedLine, + originalLine, + sourceContext, + lineNumber, + references, + seen, + container, + definitionNames, + ResolveContainerForCall); + + if (shaderState is not null) + { + ShaderReferenceExtractor.EmitLineReferences( + shaderState, + preparedLine, + originalLine, + references, + seen, + fileId, + sourceContext, + lineNumber, + ResolveContainerForCall); + } + + if (loop.IsJsxFile + && language is "javascript" or "typescript") + { + EmitJsxElementReferences(lineContext); + } + + var typeContext = new CoreTypeReferenceContext( + lineContext, + lookups, + containerCandidates, + symbols, + structuralLines, + loop.CSharpQualifiedConstantPatternMemberLookup, + loop.CSharpQualifiedTypePatternLookup, + csharpUsingAliases, + csharpUsingStatics, + csharpLocalNamesByFunction, + pendingCSharpWhereConstraint, + loop.KotlinConstructorTypeNames, + input.TypeScriptNamespaceAliases, + loop.TypeScriptTypeAliases, + loop.SwiftTypeAliases, + ResolveSwiftPropertyContainerForCall, + input.GoImportBlockLines, + input.LuaReferenceLines, + originalLineForLanguage, + loop.AllDefinitionNames, + loop.StylusVariableDefinitionNames, + loop.XamlReferenceEnabled, + xamlBindingPropertyElementState, + xamlBindingMarkupExtensionState); + if (EmitCoreTypeReferences( + typeContext, + ref pendingCSharpMultiLineTypePattern, + ref xamlInXmlComment)) + { + continue; + } + + EmitInfrastructureLineReferences( + lineContext, + loop.DockerfileStageNames, + loop.DockerfileVariableNames, + loop.CobolCallableSymbols); + + var sqlSuppressedCallIndices = EmitSqlLineReferences( + lineContext, + structuralLines[i], + sqlState, + definitionState); + + if (language is "csharp" or "java") + EmitParenlessInitializerReferences(lineContext); + + EmitPhpAndScssLineReferences(lineContext); + + var callContext = new CoreCallReferenceContext( + lineContext, + lookups, + javaSameLineCtor, + csharpAttrRangesOnLine, + loop.KotlinConstructorTypeNames, + loop.KotlinInfixFunctionNames, + loop.ShellCallableNames, + loop.ShellGlobalAliasNames, + dynamicDeclarativeState, + referenceStructuralLines[i], + loop.ScientificNativeDependencyLimit, + request.ReportDiagnostic, + sqlSuppressedCallIndices, + loop.SqlWindowFunctionCallSiteSuppressions, + definitionState); + EmitCoreCallReferences(callContext); + + EmitCoreMethodAndMemberReferences( + loop, + lineContext, + preparedLine, + csharpAttrRangesOnLine, + ResolveContainerForCall); + + if (input.JsTaggedTemplatesByLine != null + && input.JsTaggedTemplatesByLine.TryGetValue( + lineNumber, + out var tagHitsOnLine)) + { + EmitJavaScriptTaggedTemplateReferences( + lineContext, + tagHitsOnLine); + } + + EmitMetadataLineReferences( + lineContext, + csharpAttrTopLevelOnLine); + + if (loop.IsRazorFile && language == "csharp") + { + RazorReferenceExtractor.EmitReferences( + input.RazorReferenceLines?[i] ?? originalLine, + references, + seen, + fileId, + sourceContext, + lineNumber, + ResolveContainerForCall, + definitionNames, + loop.FileDefinitionNames, + input.RazorImplementedTypeNames); + } + + if (language == "python") + { + EmitPythonLineReferences( + lineContext, + lookups, + ResolvePythonDefinitionContainer); + } + if (language == "r") + EmitRLineReferences(lineContext); + } + + return pendingCSharpMultiLineTypePattern; + } + + private static ( + string PreparedLine, + string OriginalLineForLanguage) PrepareCoreLanguageLine( + CoreReferenceLoopContext loop, + CoreReferenceLoopMutableState state, + int lineIndex, + string originalLine) + { + var input = loop.Preparation; + var preparedLine = input.LuaPreparedLines?[lineIndex] + ?? input.LispReferenceLines?[lineIndex] + ?? input.PreparedLines[lineIndex]; + var originalLineForLanguage = originalLine; + if (loop.Request.Language == "sass") + { + preparedLine = CssReferenceExtractor.MaskSassBlockCommentLine( + preparedLine, + state.SassPreparedCommentState!); + originalLineForLanguage = + CssReferenceExtractor.MaskSassBlockCommentLine( + originalLine, + state.SassOriginalCommentState!); + } + else if (loop.Request.Language == "stylus") + { + preparedLine = + CssReferenceExtractor.MaskSassStylusBlockCommentLine( + preparedLine, + ref state.SassStylusPreparedInBlockComment); + originalLineForLanguage = + CssReferenceExtractor.MaskSassStylusBlockCommentLine( + originalLine, + ref state.SassStylusOriginalInBlockComment); + } + + return (preparedLine, originalLineForLanguage); + } + + private static bool EmitCoreDocumentationAndSpecialLineReferences( + CoreReferenceLoopContext loop, + CoreReferenceLoopMutableState state, + int lineIndex, + string originalLine, + string preparedLine, + List<(int start, int end)>? csharpAttributeRangesOnLine, + out string sourceContext) + { + var request = loop.Request; + var input = loop.Preparation; + var lineNumber = lineIndex + 1; + SymbolRecord? phpLineContainer = null; + var phpLineContainerResolved = false; + + SymbolRecord? GetPhpLineContainer() + { + if (!phpLineContainerResolved) + { + phpLineContainer = + loop.ContainerResolver.Find(lineNumber); + phpLineContainerResolved = true; + } + + return phpLineContainer; + } + + var documentationLine = new CoreDocumentationLineContext( + request.FileId, + request.Language, + input.Lines, + input.PreparedLines, + input.StructuralLines, + lineIndex, + lineNumber, + originalLine, + preparedLine, + loop.References, + loop.Seen, + loop.ContainerCandidates, + loop.ContainerResolver, + loop.Lookups, + input.CSharpLinesInsideMultilineStringContent, + input.CSharpLinesInsideBlockComment, + csharpAttributeRangesOnLine, + loop.CSharpAttributeRanges, + GetPhpLineContainer); + EmitCoreDocumentationReferences( + documentationLine, + ref state.CSharpInDelimitedDocComment, + ref state.JvmInDelimitedDocComment, + ref state.PhpInDocblock, + ref state.PhpDocblockContainer, + ref state.PhpDocblockPropertyNames); + + sourceContext = originalLine.Trim(); + if (request.Language + is "cmake" or "justfile" or "makefile" or "msbuild" + && sourceContext.Length > 0) + { + BuildAutomationReferenceExtractor.EmitReferences( + request.Language, + originalLine, + sourceContext, + lineNumber, + loop.References, + loop.Seen, + request.FileId, + loop.ContainerResolver.Find(lineNumber)); + return true; + } + + if (request.Language is not ("graphql" or "html" or "markdown") + || sourceContext.Length == 0) + { + return false; + } + + MarkupSchemaReferenceExtractor.EmitReferences( + request.Language, + originalLine, + sourceContext, + lineNumber, + loop.References, + loop.Seen, + request.FileId, + loop.ContainerResolver.Find(lineNumber), + state.MarkupSchemaState); + return true; + } + + private static void EmitCoreMethodAndMemberReferences( + CoreReferenceLoopContext loop, + CoreReferenceLineContext line, + string preparedLine, + List<(int start, int end)>? csharpAttributeRanges, + Func resolveContainerForCall) + { + var language = line.Language; + if (language == "csharp") + { + EmitMethodGroupReferences( + language, + preparedLine, + loop.CallableDefinitionNames, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + resolveContainerForCall); + } + else if (language == "java") + { + JavaReferenceExtractor.EmitMethodReferenceReferences( + preparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + resolveContainerForCall); + } + else if (language == "kotlin") + { + KotlinReferenceExtractor.EmitMethodReferenceReferences( + preparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + resolveContainerForCall); + } + else if (language == "scala") + { + ScalaReferenceExtractor.EmitMethodReferenceReferences( + preparedLine, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + resolveContainerForCall); + } + + if (language == "csharp") + { + CSharpReferenceExtractor.EmitStaticMemberQualifierReferences( + preparedLine, + csharpAttributeRanges, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + resolveContainerForCall); + } + + if (language != "csharp" + || loop.CSharpQualifiedEnumMemberLookup.Count == 0) + { + return; + } + + CSharpReferenceExtractor.EmitQualifiedEnumMemberReferences( + preparedLine, + loop.CSharpQualifiedEnumMemberLookup, + csharpAttributeRanges, + loop.CSharpUsingAliases, + loop.Lookups.GetCSharpValueReceiverNames, + loop.Lookups.GetCSharpFunctionValueReceiverNames, + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + resolveContainerForCall); + } +} From f87dfc3bbfa5f7fbd1824794a50051ab4a4638a4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 00:44:57 +0900 Subject: [PATCH 092/101] Separate scientific native reference emission --- .../ScientificNativeReferenceEmitter.cs | 686 ++++++++++++++++++ .../ScientificNativeReferenceExtractor.cs | 613 +++------------- 2 files changed, 784 insertions(+), 515 deletions(-) create mode 100644 src/CodeIndex/Indexer/References/Languages/ScientificNativeReferenceEmitter.cs diff --git a/src/CodeIndex/Indexer/References/Languages/ScientificNativeReferenceEmitter.cs b/src/CodeIndex/Indexer/References/Languages/ScientificNativeReferenceEmitter.cs new file mode 100644 index 000000000..a0790160e --- /dev/null +++ b/src/CodeIndex/Indexer/References/Languages/ScientificNativeReferenceEmitter.cs @@ -0,0 +1,686 @@ +using System.Text.RegularExpressions; +using CodeIndex.Models; +using Regex = CodeIndex.Indexer.BoundedRegex; + +namespace CodeIndex.Indexer; + +internal static partial class ScientificNativeReferenceExtractor +{ + private sealed class ScientificNativeReferenceEmitter + { + private readonly string language; + private readonly string preparedLine; + private readonly string originalLine; + private readonly List references; + private readonly ReferenceDedupeSet seen; + private readonly long fileId; + private readonly string context; + private readonly int lineNumber; + private readonly Func resolveContainerForColumn; + private readonly int maxDependenciesPerDeclaration; + private readonly Action? reportDiagnostic; + private bool dependencyLimitReported; + + internal ScientificNativeReferenceEmitter( + string language, + string preparedLine, + string originalLine, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + int maxDependenciesPerDeclaration, + Action? reportDiagnostic) + { + this.language = language; + this.preparedLine = preparedLine; + this.originalLine = originalLine; + this.references = references; + this.seen = seen; + this.fileId = fileId; + this.context = context; + this.lineNumber = lineNumber; + this.resolveContainerForColumn = resolveContainerForColumn; + this.maxDependenciesPerDeclaration = + maxDependenciesPerDeclaration; + this.reportDiagnostic = reportDiagnostic; + } + + internal IReadOnlyList? Emit( + Action addCallLikeReference) + { + List? dTemplateArgumentCallSpans = null; + switch (language) + { + case "nim": + EmitMatch(NimFromImportRegex, "import"); + EmitNimImportList(); + EmitMatches( + NimBaseTypeRegex, + "type_reference", + normalizeQualifiedTypeName: true); + EmitMatches( + NimAnnotatedTypeRegex, + "type_reference", + normalizeQualifiedTypeName: true); + break; + case "matlab": + EmitNameList( + MatlabImportListRegex, + "import", + ',', + splitOnWhitespace: true); + EmitNameList( + MatlabBaseTypeListRegex, + "type_reference", + '&', + normalizeQualifiedTypeName: true); + break; + case "julia": + EmitNameList( + JuliaImportListRegex, + "import", + ',', + stopAtColon: true, + stripLeadingRelativePrefix: true); + EmitMatches( + JuliaTypeRegex, + "type_reference", + normalizeQualifiedTypeName: true); + EmitCallMatches( + JuliaMacroCallRegex, + addCallLikeReference); + EmitCallMatches( + JuliaBangCallRegex, + addCallLikeReference); + EmitCallMatches( + JuliaBroadcastCallRegex, + addCallLikeReference); + break; + case "d": + EmitNameList( + DImportListRegex, + "import", + ',', + stopAtColon: true, + stripLeadingAlias: true); + EmitNameList( + DBaseTypeListRegex, + "type_reference", + ',', + normalizeQualifiedTypeName: true); + foreach (var invocation in + FindDTemplateInvocations(preparedLine)) + { + addCallLikeReference( + invocation.Name, + invocation.NameIndex); + (dTemplateArgumentCallSpans ??= []).Add( + new DTemplateArgumentCallSpan( + invocation.ArgumentStart, + invocation.EndExclusive)); + } + break; + case "cython": + EmitMatch( + CythonFromImportRegex, + "import", + stripLeadingRelativePrefix: true); + EmitNameList( + CythonImportListRegex, + "import", + ','); + EmitCythonStringDependency(); + EmitNameList( + CythonBaseTypeListRegex, + "type_reference", + ',', + normalizeQualifiedTypeName: true); + break; + case "ada": + EmitNameList( + AdaImportListRegex, + "import", + ','); + EmitMatches( + AdaDerivedTypeRegex, + "type_reference", + normalizeQualifiedTypeName: true); + EmitAdaBareCalls(); + break; + case "objc": + EmitObjectiveCImport(); + break; + } + + return dTemplateArgumentCallSpans; + } + + private void EmitCallMatches( + Regex regex, + Action addCallLikeReference) + { + foreach (Match match in regex.Matches(preparedLine)) + { + var group = match.Groups["name"]; + addCallLikeReference(group.Value, group.Index); + } + } + + private void EmitAdaBareCalls() + { + foreach (Match bareCall in AdaBareCallRegex.Matches(preparedLine)) + { + var group = bareCall.Groups["name"]; + var separatorIndex = group.Value.LastIndexOf('.'); + var leafOffset = separatorIndex + 1; + EmitName( + group.Value[leafOffset..], + group.Index + leafOffset, + "call", + separatorIndex >= 0 + ? group.Value[..separatorIndex] + : null); + } + } + + private void EmitMatch( + Regex regex, + string referenceKind, + bool stripLeadingRelativePrefix = false) + { + var match = regex.Match(preparedLine); + if (!match.Success) + return; + + var group = match.Groups["name"]; + if (!stripLeadingRelativePrefix) + { + EmitGroup(group, referenceKind); + return; + } + + var nameStart = 0; + while (nameStart < group.Length + && group.Value[nameStart] == '.') + { + nameStart++; + } + if (nameStart < group.Length) + { + EmitName( + group.Value[nameStart..], + group.Index + nameStart, + referenceKind); + } + } + + private void EmitMatches( + Regex regex, + string referenceKind, + bool normalizeQualifiedTypeName = false) + { + foreach (Match match in regex.Matches(preparedLine)) + { + EmitGroup( + match.Groups["name"], + referenceKind, + normalizeQualifiedTypeName); + } + } + + private void EmitNameList( + Regex regex, + string referenceKind, + char separator, + bool splitOnWhitespace = false, + bool stopAtColon = false, + bool stripLeadingAlias = false, + bool stripLeadingRelativePrefix = false, + bool normalizeQualifiedTypeName = false) + { + var match = regex.Match(preparedLine); + if (!match.Success) + return; + + var group = match.Groups["names"]; + if (!group.Success || group.Length == 0) + return; + + var names = group.Value; + var namesEnd = names.Length; + if (stopAtColon) + { + var colonIndex = names.IndexOf(':'); + if (colonIndex >= 0) + namesEnd = colonIndex; + } + + var dependencyCount = 0; + var segmentStart = 0; + for (var index = 0; index <= namesEnd; index++) + { + var atEnd = index == namesEnd; + var isSeparator = !atEnd + && (names[index] == separator + || (splitOnWhitespace + && char.IsWhiteSpace(names[index]))); + if (!atEnd && !isSeparator) + continue; + + var canEmit = + dependencyCount < maxDependenciesPerDeclaration; + if (TryEmitDependencySegment( + names, + segmentStart, + index, + group.Index, + referenceKind, + stripLeadingAlias, + stripLeadingRelativePrefix, + normalizeQualifiedTypeName, + emit: canEmit)) + { + if (!canEmit) + { + ReportDependencyLimit(); + return; + } + + dependencyCount++; + } + + segmentStart = index + 1; + while (segmentStart < namesEnd + && (names[segmentStart] == separator + || (splitOnWhitespace + && char.IsWhiteSpace( + names[segmentStart])))) + { + segmentStart++; + index++; + } + } + } + + private bool TryEmitDependencySegment( + string names, + int segmentStart, + int segmentEnd, + int absoluteOffset, + string referenceKind, + bool stripLeadingAlias, + bool stripLeadingRelativePrefix, + bool normalizeQualifiedTypeName = false, + bool emit = true) + { + TrimDependencySegment( + names, + ref segmentStart, + ref segmentEnd); + if (segmentStart >= segmentEnd) + return false; + + if (stripLeadingAlias) + { + var equalsIndex = names.LastIndexOf( + '=', + segmentEnd - 1, + segmentEnd - segmentStart); + if (equalsIndex >= segmentStart) + { + segmentStart = equalsIndex + 1; + while (segmentStart < segmentEnd + && char.IsWhiteSpace(names[segmentStart])) + { + segmentStart++; + } + } + } + + segmentEnd = FindDependencyAliasEnd( + names, + segmentStart, + segmentEnd); + while (segmentEnd > segmentStart + && char.IsWhiteSpace(names[segmentEnd - 1])) + { + segmentEnd--; + } + + var nameEnd = segmentStart; + while (nameEnd < segmentEnd + && IsDependencyNameChar(names[nameEnd])) + { + nameEnd++; + } + while (nameEnd > segmentStart + && names[nameEnd - 1] is '.' or '/') + { + nameEnd--; + } + + var firstIdentifierIndex = segmentStart; + while (firstIdentifierIndex < nameEnd + && names[firstIdentifierIndex] == '.') + { + firstIdentifierIndex++; + } + if (firstIdentifierIndex >= nameEnd + || !(char.IsLetter(names[firstIdentifierIndex]) + || names[firstIdentifierIndex] == '_')) + { + return false; + } + + var emittedNameStart = stripLeadingRelativePrefix + ? firstIdentifierIndex + : segmentStart; + string? targetQualifier = null; + if (normalizeQualifiedTypeName) + { + var lastDotIndex = names.LastIndexOf( + '.', + nameEnd - 1, + nameEnd - emittedNameStart); + if (lastDotIndex >= emittedNameStart) + { + targetQualifier = + names[emittedNameStart..lastDotIndex]; + emittedNameStart = lastDotIndex + 1; + } + } + if (emit) + { + EmitName( + names[emittedNameStart..nameEnd], + absoluteOffset + emittedNameStart, + referenceKind, + targetQualifier); + } + + return true; + } + + private static void TrimDependencySegment( + string names, + ref int segmentStart, + ref int segmentEnd) + { + while (segmentStart < segmentEnd + && char.IsWhiteSpace(names[segmentStart])) + { + segmentStart++; + } + while (segmentEnd > segmentStart + && char.IsWhiteSpace(names[segmentEnd - 1])) + { + segmentEnd--; + } + } + + private static int FindDependencyAliasEnd( + string names, + int segmentStart, + int segmentEnd) + { + for (var index = segmentStart; + index + 3 < segmentEnd; + index++) + { + if (char.IsWhiteSpace(names[index]) + && names.AsSpan(index + 1, 2) + .Equals( + "as", + StringComparison.OrdinalIgnoreCase) + && char.IsWhiteSpace(names[index + 3])) + { + return index; + } + } + + return segmentEnd; + } + + private void EmitNimImportList() + { + var match = NimImportListRegex.Match(preparedLine); + if (!match.Success) + return; + + var group = match.Groups["names"]; + if (!group.Success || group.Length == 0) + return; + + var names = group.Value; + var dependencyCount = 0; + var segmentStart = 0; + var bracketDepth = 0; + for (var index = 0; index <= names.Length; index++) + { + if (index < names.Length) + { + if (names[index] == '[') + bracketDepth++; + else if (names[index] == ']' && bracketDepth > 0) + bracketDepth--; + } + + if (index < names.Length + && (names[index] != ',' || bracketDepth != 0)) + { + continue; + } + + var (emittedCount, truncated) = EmitNimImportSegment( + names, + segmentStart, + index, + group.Index, + Math.Max( + 0, + maxDependenciesPerDeclaration + - dependencyCount)); + dependencyCount += emittedCount; + if (truncated) + { + ReportDependencyLimit(); + return; + } + + segmentStart = index + 1; + } + } + + private (int EmittedCount, bool Truncated) EmitNimImportSegment( + string names, + int segmentStart, + int segmentEnd, + int absoluteOffset, + int remainingCapacity) + { + TrimDependencySegment( + names, + ref segmentStart, + ref segmentEnd); + if (segmentStart >= segmentEnd) + return (0, false); + + var openingBracket = names.IndexOf( + '[', + segmentStart, + segmentEnd - segmentStart); + var closingBracket = openingBracket >= 0 + ? names.IndexOf( + ']', + openingBracket + 1, + segmentEnd - openingBracket - 1) + : -1; + if (openingBracket < 0 || closingBracket < 0) + { + var canEmit = remainingCapacity > 0; + var hasDependency = TryEmitDependencySegment( + names, + segmentStart, + segmentEnd, + absoluteOffset, + "import", + stripLeadingAlias: false, + stripLeadingRelativePrefix: false, + emit: canEmit); + return hasDependency + ? (canEmit ? 1 : 0, !canEmit) + : (0, false); + } + + var prefixStart = segmentStart; + var prefixEnd = openingBracket; + while (prefixEnd > prefixStart + && char.IsWhiteSpace(names[prefixEnd - 1])) + { + prefixEnd--; + } + if (prefixEnd <= prefixStart + || names[prefixEnd - 1] != '/') + { + return (0, false); + } + + var prefix = names[prefixStart..prefixEnd]; + var emittedCount = 0; + var itemStart = openingBracket + 1; + for (var index = itemStart; + index <= closingBracket; + index++) + { + if (index < closingBracket && names[index] != ',') + continue; + + var itemEnd = index; + TrimDependencySegment( + names, + ref itemStart, + ref itemEnd); + if (itemStart < itemEnd) + { + var nameEnd = itemStart; + while (nameEnd < itemEnd + && IsDependencyNameChar(names[nameEnd])) + { + nameEnd++; + } + while (nameEnd > itemStart + && names[nameEnd - 1] is '.' or '/') + { + nameEnd--; + } + if (nameEnd > itemStart) + { + if (emittedCount >= remainingCapacity) + return (emittedCount, true); + + EmitName( + prefix + names[itemStart..nameEnd], + absoluteOffset + itemStart, + "import"); + emittedCount++; + } + } + + itemStart = index + 1; + } + + return (emittedCount, false); + } + + private void ReportDependencyLimit() + { + if (dependencyLimitReported) + return; + + dependencyLimitReported = true; + reportDiagnostic?.Invoke( + new ReferenceExtractionDiagnostic( + "reference_scientific_native_dependency_name_budget_exceeded", + $"Scientific/native dependency extraction used the first {maxDependenciesPerDeclaration:N0} names on line {lineNumber:N0} and skipped additional names.")); + } + + private void EmitGroup( + Group group, + string referenceKind, + bool normalizeQualifiedTypeName = false) + { + if (!group.Success || group.Length == 0) + return; + + if (!normalizeQualifiedTypeName) + { + EmitName(group.Value, group.Index, referenceKind); + return; + } + + var lastDotIndex = group.Value.LastIndexOf('.'); + EmitName( + lastDotIndex >= 0 + ? group.Value[(lastDotIndex + 1)..] + : group.Value, + group.Index + lastDotIndex + 1, + referenceKind, + lastDotIndex >= 0 + ? group.Value[..lastDotIndex] + : null); + } + + private void EmitName( + string name, + int index, + string referenceKind, + string? targetQualifier = null) + => ReferenceExtractor.AddReference( + references, + seen, + fileId, + name, + index, + referenceKind, + context, + lineNumber, + resolveContainerForColumn(index), + language, + targetQualifier); + + private void EmitObjectiveCImport() + { + var directiveLine = ObjectiveCImportRegex.IsMatch(preparedLine) + ? preparedLine + : ObjectiveCImportDirectiveRegex.IsMatch(preparedLine) + ? originalLine + : null; + if (directiveLine == null) + return; + + var match = ObjectiveCImportRegex.Match(directiveLine); + if (match.Success) + EmitGroup(match.Groups["name"], "import"); + } + + private void EmitCythonStringDependency() + { + var directiveLine = + CythonStringDependencyRegex.IsMatch(preparedLine) + ? preparedLine + : CythonStringDependencyDirectiveRegex.IsMatch( + preparedLine) + ? originalLine + : null; + if (directiveLine == null) + return; + + var match = CythonStringDependencyRegex.Match(directiveLine); + if (match.Success) + EmitGroup(match.Groups["name"], "import"); + } + } +} diff --git a/src/CodeIndex/Indexer/References/Languages/ScientificNativeReferenceExtractor.cs b/src/CodeIndex/Indexer/References/Languages/ScientificNativeReferenceExtractor.cs index 683d1445a..66117775f 100644 --- a/src/CodeIndex/Indexer/References/Languages/ScientificNativeReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/Languages/ScientificNativeReferenceExtractor.cs @@ -4,13 +4,18 @@ namespace CodeIndex.Indexer; -internal static class ScientificNativeReferenceExtractor +internal static partial class ScientificNativeReferenceExtractor { - internal const string CurrentContainerReceiverMarker = "\u001fcurrent-container"; + internal const string CurrentContainerReceiverMarker = + "\u001fcurrent-container"; private const string JuliaIdentifierPattern = @"[\p{L}_]\w*"; - private const string JuliaCallableIdentifierPattern = JuliaIdentifierPattern + @"!?"; + private const string JuliaCallableIdentifierPattern = + JuliaIdentifierPattern + @"!?"; + + internal readonly record struct DTemplateArgumentCallSpan( + int Start, + int EndExclusive); - internal readonly record struct DTemplateArgumentCallSpan(int Start, int EndExclusive); private readonly record struct DTemplateInvocation( string Name, int NameIndex, @@ -18,7 +23,16 @@ private readonly record struct DTemplateInvocation( int EndExclusive); private static readonly HashSet SupportedLanguages = - new(StringComparer.Ordinal) { "ada", "cython", "d", "julia", "matlab", "nim", "objc" }; + new(StringComparer.Ordinal) + { + "ada", + "cython", + "d", + "julia", + "matlab", + "nim", + "objc", + }; private static readonly Regex NimFromImportRegex = new( @"^\s*from\s+(?[A-Za-z_][\w./]*)\s+import\b", @@ -80,13 +94,19 @@ private readonly record struct DTemplateInvocation( private static readonly Regex AdaImportListRegex = new( @"^\s*(?:(?:limited|private)\s+)*with\s+(?[^;\r\n]+)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + RegexOptions.Compiled + | RegexOptions.IgnoreCase + | RegexOptions.CultureInvariant); private static readonly Regex AdaDerivedTypeRegex = new( @"^\s*type\s+[A-Za-z]\w*\s+is\s+new\s+(?[A-Za-z]\w*(?:\.[A-Za-z]\w*)*)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + RegexOptions.Compiled + | RegexOptions.IgnoreCase + | RegexOptions.CultureInvariant); private static readonly Regex AdaBareCallRegex = new( @"(?:^|;|\b(?:begin|then|else|loop)\b|=>)\s*(?!(?:end|null|return|exit|raise|goto)\b)(?[A-Za-z]\w*(?:\.[A-Za-z]\w*)*)\s*(?=;)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + RegexOptions.Compiled + | RegexOptions.IgnoreCase + | RegexOptions.CultureInvariant); private static readonly Regex ObjectiveCImportRegex = new( """^\s*#\s*(?:import|include)\s*[<"](?[^>"]+)[>"]""", RegexOptions.Compiled | RegexOptions.CultureInvariant); @@ -94,7 +114,8 @@ private readonly record struct DTemplateInvocation( @"^\s*#\s*(?:import|include)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant); - internal static bool Supports(string language) => SupportedLanguages.Contains(language); + internal static bool Supports(string language) => + SupportedLanguages.Contains(language); internal static string? GetParenthesizedCallTargetQualifier( string language, @@ -102,13 +123,19 @@ private readonly record struct DTemplateInvocation( int callIndex) { var separatorIndex = callIndex - 1; - while (separatorIndex >= 0 && char.IsWhiteSpace(preparedLine[separatorIndex])) + while (separatorIndex >= 0 + && char.IsWhiteSpace(preparedLine[separatorIndex])) + { separatorIndex--; + } if (separatorIndex >= 0 && preparedLine[separatorIndex] == '@') { separatorIndex--; - while (separatorIndex >= 0 && char.IsWhiteSpace(preparedLine[separatorIndex])) + while (separatorIndex >= 0 + && char.IsWhiteSpace(preparedLine[separatorIndex])) + { separatorIndex--; + } } if (separatorIndex < 0 || preparedLine[separatorIndex] != '.') return null; @@ -118,22 +145,33 @@ private readonly record struct DTemplateInvocation( { var segmentEnd = separatorIndex; var segmentStart = segmentEnd - 1; - while (segmentStart >= 0 && char.IsWhiteSpace(preparedLine[segmentStart])) + while (segmentStart >= 0 + && char.IsWhiteSpace(preparedLine[segmentStart])) + { segmentStart--; + } segmentEnd = segmentStart + 1; - while (segmentStart >= 0 && IsQualifierIdentifierPart(preparedLine[segmentStart])) + while (segmentStart >= 0 + && IsQualifierIdentifierPart( + preparedLine[segmentStart])) + { segmentStart--; + } segmentStart++; if (segmentStart >= segmentEnd - || !IsQualifierIdentifierStart(preparedLine[segmentStart])) + || !IsQualifierIdentifierStart( + preparedLine[segmentStart])) { return null; } segments.Add(preparedLine[segmentStart..segmentEnd]); separatorIndex = segmentStart - 1; - while (separatorIndex >= 0 && char.IsWhiteSpace(preparedLine[separatorIndex])) + while (separatorIndex >= 0 + && char.IsWhiteSpace(preparedLine[separatorIndex])) + { separatorIndex--; + } } segments.Reverse(); @@ -154,10 +192,14 @@ internal static bool IsDTemplateArgumentCall( if (spans == null) return false; - while (spanIndex < spans.Count && callIndex >= spans[spanIndex].EndExclusive) + while (spanIndex < spans.Count + && callIndex >= spans[spanIndex].EndExclusive) + { spanIndex++; + } - return spanIndex < spans.Count && callIndex >= spans[spanIndex].Start; + return spanIndex < spans.Count + && callIndex >= spans[spanIndex].Start; } internal static IReadOnlyList? EmitReferences( @@ -173,507 +215,33 @@ internal static bool IsDTemplateArgumentCall( Action addCallLikeReference, int maxDependenciesPerDeclaration, Action? reportDiagnostic) - { - var dependencyLimitReported = false; - List? dTemplateArgumentCallSpans = null; - - switch (language) - { - case "nim": - EmitMatch(NimFromImportRegex, "import"); - EmitNimImportList(); - EmitMatches(NimBaseTypeRegex, "type_reference", normalizeQualifiedTypeName: true); - EmitMatches(NimAnnotatedTypeRegex, "type_reference", normalizeQualifiedTypeName: true); - break; - case "matlab": - EmitNameList(MatlabImportListRegex, "import", ',', splitOnWhitespace: true); - EmitNameList( - MatlabBaseTypeListRegex, - "type_reference", - '&', - normalizeQualifiedTypeName: true); - break; - case "julia": - EmitNameList( - JuliaImportListRegex, - "import", - ',', - stopAtColon: true, - stripLeadingRelativePrefix: true); - EmitMatches(JuliaTypeRegex, "type_reference", normalizeQualifiedTypeName: true); - foreach (Match match in JuliaMacroCallRegex.Matches(preparedLine)) - { - var group = match.Groups["name"]; - addCallLikeReference(group.Value, group.Index); - } - foreach (Match match in JuliaBangCallRegex.Matches(preparedLine)) - { - var group = match.Groups["name"]; - addCallLikeReference(group.Value, group.Index); - } - foreach (Match match in JuliaBroadcastCallRegex.Matches(preparedLine)) - { - var group = match.Groups["name"]; - addCallLikeReference(group.Value, group.Index); - } - break; - case "d": - EmitNameList(DImportListRegex, "import", ',', stopAtColon: true, stripLeadingAlias: true); - EmitNameList( - DBaseTypeListRegex, - "type_reference", - ',', - normalizeQualifiedTypeName: true); - foreach (var invocation in FindDTemplateInvocations(preparedLine)) - { - addCallLikeReference(invocation.Name, invocation.NameIndex); - (dTemplateArgumentCallSpans ??= []).Add( - new DTemplateArgumentCallSpan( - invocation.ArgumentStart, - invocation.EndExclusive)); - } - break; - case "cython": - EmitMatch(CythonFromImportRegex, "import", stripLeadingRelativePrefix: true); - EmitNameList(CythonImportListRegex, "import", ','); - EmitCythonStringDependency(); - EmitNameList( - CythonBaseTypeListRegex, - "type_reference", - ',', - normalizeQualifiedTypeName: true); - break; - case "ada": - EmitNameList(AdaImportListRegex, "import", ','); - EmitMatches(AdaDerivedTypeRegex, "type_reference", normalizeQualifiedTypeName: true); - foreach (Match bareCall in AdaBareCallRegex.Matches(preparedLine)) - { - var group = bareCall.Groups["name"]; - var separatorIndex = group.Value.LastIndexOf('.'); - var leafOffset = separatorIndex + 1; - EmitName( - group.Value[leafOffset..], - group.Index + leafOffset, - "call", - separatorIndex >= 0 ? group.Value[..separatorIndex] : null); - } - break; - case "objc": - EmitObjectiveCImport(); - break; - } - - return dTemplateArgumentCallSpans; - - void EmitMatch( - Regex regex, - string referenceKind, - bool stripLeadingRelativePrefix = false) - { - var match = regex.Match(preparedLine); - if (!match.Success) - return; - - var group = match.Groups["name"]; - if (!stripLeadingRelativePrefix) - { - EmitGroup(group, referenceKind); - return; - } - - var nameStart = 0; - while (nameStart < group.Length && group.Value[nameStart] == '.') - nameStart++; - if (nameStart < group.Length) - EmitName(group.Value[nameStart..], group.Index + nameStart, referenceKind); - } - - void EmitMatches( - Regex regex, - string referenceKind, - bool normalizeQualifiedTypeName = false) - { - foreach (Match match in regex.Matches(preparedLine)) - EmitGroup(match.Groups["name"], referenceKind, normalizeQualifiedTypeName); - } - - void EmitNameList( - Regex regex, - string referenceKind, - char separator, - bool splitOnWhitespace = false, - bool stopAtColon = false, - bool stripLeadingAlias = false, - bool stripLeadingRelativePrefix = false, - bool normalizeQualifiedTypeName = false) - { - var match = regex.Match(preparedLine); - if (!match.Success) - return; - - var group = match.Groups["names"]; - if (!group.Success || group.Length == 0) - return; - - var names = group.Value; - var namesEnd = names.Length; - if (stopAtColon) - { - var colonIndex = names.IndexOf(':'); - if (colonIndex >= 0) - namesEnd = colonIndex; - } - - var dependencyCount = 0; - var segmentStart = 0; - for (var index = 0; index <= namesEnd; index++) - { - var atEnd = index == namesEnd; - var isSeparator = !atEnd - && (names[index] == separator || (splitOnWhitespace && char.IsWhiteSpace(names[index]))); - if (!atEnd && !isSeparator) - continue; - - var canEmit = dependencyCount < maxDependenciesPerDeclaration; - if (TryEmitDependencySegment( - names, - segmentStart, - index, - group.Index, - referenceKind, - stripLeadingAlias, - stripLeadingRelativePrefix, - normalizeQualifiedTypeName, - emit: canEmit)) - { - if (!canEmit) - { - ReportDependencyLimit(); - return; - } - - dependencyCount++; - } - - segmentStart = index + 1; - while (segmentStart < namesEnd - && (names[segmentStart] == separator - || (splitOnWhitespace && char.IsWhiteSpace(names[segmentStart])))) - { - segmentStart++; - index++; - } - } - } - - bool TryEmitDependencySegment( - string names, - int segmentStart, - int segmentEnd, - int absoluteOffset, - string referenceKind, - bool stripLeadingAlias, - bool stripLeadingRelativePrefix, - bool normalizeQualifiedTypeName = false, - bool emit = true) - { - while (segmentStart < segmentEnd && char.IsWhiteSpace(names[segmentStart])) - segmentStart++; - while (segmentEnd > segmentStart && char.IsWhiteSpace(names[segmentEnd - 1])) - segmentEnd--; - if (segmentStart >= segmentEnd) - return false; - - if (stripLeadingAlias) - { - var equalsIndex = names.LastIndexOf('=', segmentEnd - 1, segmentEnd - segmentStart); - if (equalsIndex >= segmentStart) - { - segmentStart = equalsIndex + 1; - while (segmentStart < segmentEnd && char.IsWhiteSpace(names[segmentStart])) - segmentStart++; - } - } - - for (var index = segmentStart; index + 3 < segmentEnd; index++) - { - if (!char.IsWhiteSpace(names[index]) - || !names.AsSpan(index + 1, 2).Equals("as", StringComparison.OrdinalIgnoreCase) - || !char.IsWhiteSpace(names[index + 3])) - { - continue; - } - - segmentEnd = index; - break; - } - - while (segmentEnd > segmentStart && char.IsWhiteSpace(names[segmentEnd - 1])) - segmentEnd--; - - var nameEnd = segmentStart; - while (nameEnd < segmentEnd && IsDependencyNameChar(names[nameEnd])) - nameEnd++; - while (nameEnd > segmentStart && names[nameEnd - 1] is '.' or '/') - nameEnd--; - - var firstIdentifierIndex = segmentStart; - while (firstIdentifierIndex < nameEnd && names[firstIdentifierIndex] == '.') - firstIdentifierIndex++; - if (firstIdentifierIndex >= nameEnd - || !(char.IsLetter(names[firstIdentifierIndex]) || names[firstIdentifierIndex] == '_')) - { - return false; - } - - var emittedNameStart = stripLeadingRelativePrefix - ? firstIdentifierIndex - : segmentStart; - string? targetQualifier = null; - if (normalizeQualifiedTypeName) - { - var lastDotIndex = names.LastIndexOf( - '.', - nameEnd - 1, - nameEnd - emittedNameStart); - if (lastDotIndex >= emittedNameStart) - { - targetQualifier = names[emittedNameStart..lastDotIndex]; - emittedNameStart = lastDotIndex + 1; - } - } - if (emit) - { - EmitName( - names[emittedNameStart..nameEnd], - absoluteOffset + emittedNameStart, - referenceKind, - targetQualifier); - } - - return true; - } - - void EmitNimImportList() - { - var match = NimImportListRegex.Match(preparedLine); - if (!match.Success) - return; - - var group = match.Groups["names"]; - if (!group.Success || group.Length == 0) - return; - - var names = group.Value; - var dependencyCount = 0; - var segmentStart = 0; - var bracketDepth = 0; - for (var index = 0; index <= names.Length; index++) - { - if (index < names.Length) - { - if (names[index] == '[') - bracketDepth++; - else if (names[index] == ']' && bracketDepth > 0) - bracketDepth--; - } - - if (index < names.Length && (names[index] != ',' || bracketDepth != 0)) - continue; - - var (emittedCount, truncated) = EmitNimImportSegment( - names, - segmentStart, - index, - group.Index, - Math.Max(0, maxDependenciesPerDeclaration - dependencyCount)); - dependencyCount += emittedCount; - if (truncated) - { - ReportDependencyLimit(); - return; - } - - segmentStart = index + 1; - } - } - - (int EmittedCount, bool Truncated) EmitNimImportSegment( - string names, - int segmentStart, - int segmentEnd, - int absoluteOffset, - int remainingCapacity) - { - while (segmentStart < segmentEnd && char.IsWhiteSpace(names[segmentStart])) - segmentStart++; - while (segmentEnd > segmentStart && char.IsWhiteSpace(names[segmentEnd - 1])) - segmentEnd--; - if (segmentStart >= segmentEnd) - return (0, false); - - var openingBracket = names.IndexOf('[', segmentStart, segmentEnd - segmentStart); - var closingBracket = openingBracket >= 0 - ? names.IndexOf(']', openingBracket + 1, segmentEnd - openingBracket - 1) - : -1; - if (openingBracket < 0 || closingBracket < 0) - { - var canEmit = remainingCapacity > 0; - var hasDependency = TryEmitDependencySegment( - names, - segmentStart, - segmentEnd, - absoluteOffset, - "import", - stripLeadingAlias: false, - stripLeadingRelativePrefix: false, - emit: canEmit); - return hasDependency - ? (canEmit ? 1 : 0, !canEmit) - : (0, false); - } - - var prefixStart = segmentStart; - var prefixEnd = openingBracket; - while (prefixEnd > prefixStart && char.IsWhiteSpace(names[prefixEnd - 1])) - prefixEnd--; - if (prefixEnd <= prefixStart || names[prefixEnd - 1] != '/') - return (0, false); - - var prefix = names[prefixStart..prefixEnd]; - var emittedCount = 0; - var itemStart = openingBracket + 1; - for (var index = itemStart; index <= closingBracket; index++) - { - if (index < closingBracket && names[index] != ',') - continue; - - var itemEnd = index; - while (itemStart < itemEnd && char.IsWhiteSpace(names[itemStart])) - itemStart++; - while (itemEnd > itemStart && char.IsWhiteSpace(names[itemEnd - 1])) - itemEnd--; - if (itemStart < itemEnd) - { - var nameEnd = itemStart; - while (nameEnd < itemEnd && IsDependencyNameChar(names[nameEnd])) - nameEnd++; - while (nameEnd > itemStart && names[nameEnd - 1] is '.' or '/') - nameEnd--; - if (nameEnd > itemStart) - { - if (emittedCount >= remainingCapacity) - return (emittedCount, true); - - EmitName( - prefix + names[itemStart..nameEnd], - absoluteOffset + itemStart, - "import"); - emittedCount++; - } - } - - itemStart = index + 1; - } - - return (emittedCount, false); - } - - void ReportDependencyLimit() - { - if (dependencyLimitReported) - return; - - dependencyLimitReported = true; - reportDiagnostic?.Invoke(new ReferenceExtractionDiagnostic( - "reference_scientific_native_dependency_name_budget_exceeded", - $"Scientific/native dependency extraction used the first {maxDependenciesPerDeclaration:N0} names on line {lineNumber:N0} and skipped additional names.")); - } - - void EmitGroup( - Group group, - string referenceKind, - bool normalizeQualifiedTypeName = false) - { - if (!group.Success || group.Length == 0) - return; - - if (!normalizeQualifiedTypeName) - { - EmitName(group.Value, group.Index, referenceKind); - return; - } - - var lastDotIndex = group.Value.LastIndexOf('.'); - EmitName( - lastDotIndex >= 0 ? group.Value[(lastDotIndex + 1)..] : group.Value, - group.Index + lastDotIndex + 1, - referenceKind, - lastDotIndex >= 0 ? group.Value[..lastDotIndex] : null); - } - - void EmitName( - string name, - int index, - string referenceKind, - string? targetQualifier = null) - { - ReferenceExtractor.AddReference( + => new ScientificNativeReferenceEmitter( + language, + preparedLine, + originalLine, references, seen, fileId, - name, - index, - referenceKind, context, lineNumber, - resolveContainerForColumn(index), - language, - targetQualifier); - } - - void EmitObjectiveCImport() - { - var directiveLine = ObjectiveCImportRegex.IsMatch(preparedLine) - ? preparedLine - : ObjectiveCImportDirectiveRegex.IsMatch(preparedLine) - ? originalLine - : null; - if (directiveLine == null) - return; - - var match = ObjectiveCImportRegex.Match(directiveLine); - if (match.Success) - EmitGroup(match.Groups["name"], "import"); - } - - void EmitCythonStringDependency() - { - var directiveLine = CythonStringDependencyRegex.IsMatch(preparedLine) - ? preparedLine - : CythonStringDependencyDirectiveRegex.IsMatch(preparedLine) - ? originalLine - : null; - if (directiveLine == null) - return; - - var match = CythonStringDependencyRegex.Match(directiveLine); - if (match.Success) - EmitGroup(match.Groups["name"], "import"); - } - } + resolveContainerForColumn, + maxDependenciesPerDeclaration, + reportDiagnostic) + .Emit(addCallLikeReference); private static bool IsDependencyNameChar(char value) => char.IsLetterOrDigit(value) || value is '_' or '.' or '/' or '*'; - private static IReadOnlyList FindDTemplateInvocations(string line) + private static IReadOnlyList + FindDTemplateInvocations(string line) { List? invocations = null; var cursor = 0; while (cursor < line.Length) { if (!IsDIdentifierStart(line[cursor]) - || (cursor > 0 && IsDIdentifierPart(line[cursor - 1]))) + || (cursor > 0 + && IsDIdentifierPart(line[cursor - 1]))) { cursor++; continue; @@ -691,8 +259,11 @@ private static IReadOnlyList FindDTemplateInvocations(strin var nextNameIndex = scan + 1; SkipWhitespace(line, ref nextNameIndex); - if (nextNameIndex >= line.Length || !IsDIdentifierStart(line[nextNameIndex])) + if (nextNameIndex >= line.Length + || !IsDIdentifierStart(line[nextNameIndex])) + { break; + } nameIndex = nextNameIndex; nameEnd = ScanDIdentifier(line, nextNameIndex); @@ -717,7 +288,10 @@ private static IReadOnlyList FindDTemplateInvocations(strin var argumentStart = nameEnd; if (scan < line.Length && line[scan] == '(') { - if (!TryScanBalancedDTemplateArguments(line, scan, out scan)) + if (!TryScanBalancedDTemplateArguments( + line, + scan, + out scan)) { cursor = line.Length; continue; @@ -727,8 +301,8 @@ private static IReadOnlyList FindDTemplateInvocations(strin { var tokenStart = scan; while (scan < line.Length - && !char.IsWhiteSpace(line[scan]) - && line[scan] is not ('(' or ';' or ',')) + && !char.IsWhiteSpace(line[scan]) + && line[scan] is not ('(' or ';' or ',')) { scan++; } @@ -744,11 +318,12 @@ private static IReadOnlyList FindDTemplateInvocations(strin continue; } - (invocations ??= []).Add(new DTemplateInvocation( - line[nameIndex..nameEnd], - nameIndex, - argumentStart, - scan + 1)); + (invocations ??= []).Add( + new DTemplateInvocation( + line[nameIndex..nameEnd], + nameIndex, + argumentStart, + scan + 1)); cursor = scan + 1; } @@ -761,7 +336,9 @@ private static bool TryScanBalancedDTemplateArguments( out int endExclusive) { var depth = 0; - for (var cursor = openingParenthesis; cursor < line.Length; cursor++) + for (var cursor = openingParenthesis; + cursor < line.Length; + cursor++) { if (line[cursor] == '(') { @@ -787,16 +364,22 @@ private static bool TryScanBalancedDTemplateArguments( private static int ScanDIdentifier(string line, int start) { var cursor = start + 1; - while (cursor < line.Length && IsDIdentifierPart(line[cursor])) + while (cursor < line.Length + && IsDIdentifierPart(line[cursor])) + { cursor++; + } return cursor; } private static void SkipWhitespace(string line, ref int cursor) { - while (cursor < line.Length && char.IsWhiteSpace(line[cursor])) + while (cursor < line.Length + && char.IsWhiteSpace(line[cursor])) + { cursor++; + } } private static bool IsDIdentifierStart(char value) => From 715a442d6b5eea4020a8306c31d595b417401cd4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 00:50:09 +0900 Subject: [PATCH 093/101] Separate full scan progress session --- .../IndexCommandRunner.FullScan.Progress.cs | 143 ++++++++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 113 ++------------ 2 files changed, 159 insertions(+), 97 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.Progress.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Progress.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Progress.cs new file mode 100644 index 000000000..b5f06fc52 --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Progress.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class FullScanProgressSession : IDisposable + { + private readonly IndexCommandOptions options; + private readonly int filesCount; + private readonly IndexProgressReporter indexProgress; + private readonly Func getProcessed; + private readonly Func isProgressVisible; + private readonly Func getCurrentJsonIndexFile; + private readonly Func getActiveExtractionPhases; + private bool redirectedIndexingMessagePrinted; + private long lastJsonProgressAt = Stopwatch.GetTimestamp(); + private CancellationTokenSource? jsonHeartbeatCts; + private Task? jsonHeartbeatTask; + + internal FullScanProgressSession( + IndexCommandOptions options, + int filesCount, + IndexProgressReporter indexProgress, + Func getProcessed, + Func isProgressVisible, + Func getCurrentJsonIndexFile, + Func getActiveExtractionPhases) + { + this.options = options; + this.filesCount = filesCount; + this.indexProgress = indexProgress; + this.getProcessed = getProcessed; + this.isProgressVisible = isProgressVisible; + this.getCurrentJsonIndexFile = getCurrentJsonIndexFile; + this.getActiveExtractionPhases = getActiveExtractionPhases; + } + + internal void EnsureIndexingActivityVisible() + { + if (options.Json || options.Quiet || isProgressVisible()) + return; + + if (indexProgress.Interactive) + { + indexProgress.Start(); + return; + } + + if (redirectedIndexingMessagePrinted) + return; + + CommandOutputWriter.WriteLine("Indexing..."); + redirectedIndexingMessagePrinted = true; + } + + internal void ReportJsonIndexProgressIfNeeded() + { + if (!options.Json || options.Quiet || filesCount == 0) + return; + + var processed = getProcessed(); + var now = Stopwatch.GetTimestamp(); + if (processed == 0 + || processed == filesCount + || processed % 100 == 0 + || Stopwatch.GetElapsedTime(lastJsonProgressAt, now) + >= TimeSpan.FromSeconds(5)) + { + ConsoleUi.TryWriteErrorLine( + $"cdidx: indexed {processed:N0}/{filesCount:N0} file(s)..."); + lastJsonProgressAt = now; + } + } + + internal void StartJsonHeartbeatIfNeeded() + { + if (!options.Json + || options.Quiet + || filesCount == 0 + || jsonHeartbeatCts != null) + { + return; + } + + jsonHeartbeatCts = new CancellationTokenSource(); + var token = jsonHeartbeatCts.Token; + jsonHeartbeatTask = Task.Run( + async () => + { + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(5), token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + + if (token.IsCancellationRequested) + break; + + var file = GetJsonIndexHeartbeatPath( + getCurrentJsonIndexFile(), + FormatActiveExtractionPhases( + getActiveExtractionPhases())); + var fileSuffix = string.IsNullOrEmpty(file) + ? string.Empty + : $": {file}"; + ConsoleUi.TryWriteErrorLine( + $"cdidx: still indexing {getProcessed():N0}/{filesCount:N0} file(s){fileSuffix}..."); + } + }, + token); + } + + internal void StopJsonHeartbeat() + { + if (jsonHeartbeatCts == null) + return; + + jsonHeartbeatCts.Cancel(); + try + { + jsonHeartbeatTask?.Wait(TimeSpan.FromSeconds(1)); + } + catch (AggregateException ex) when ( + ex.InnerExceptions.All( + inner => inner is OperationCanceledException + or TaskCanceledException)) + { + } + jsonHeartbeatCts.Dispose(); + jsonHeartbeatCts = null; + jsonHeartbeatTask = null; + } + + public void Dispose() => StopJsonHeartbeat(); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 8857a3395..0855d36c6 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -242,7 +242,6 @@ void ThrowIfFullScanCancelled(int filesProcessed, int? filesTotal) var mutualRecursionRefreshNeeded = !options.SymbolsOnly && (!writer.ReferenceIdentityContractMatchesCurrent() || purged > 0); - var redirectedIndexingMessagePrinted = false; var indexProgressVisible = false; var indexProgress = new IndexProgressReporter( options, @@ -254,11 +253,16 @@ void ThrowIfFullScanCancelled(int filesProcessed, int? filesTotal) HashSet? reusedHotspotFamilyLanguages = null; HashSet? skippedSymbolExtractorLanguages = null; var indexedSymbolExtractorLanguages = new HashSet(languageCounts.Count, StringComparer.Ordinal); - var lastJsonProgressAt = Stopwatch.GetTimestamp(); string? currentJsonIndexFile = null; ActiveExtractionPhase?[] activeExtractionPhases = []; - CancellationTokenSource? jsonHeartbeatCts = null; - Task? jsonHeartbeatTask = null; + using var fullScanProgress = new FullScanProgressSession( + options, + files.Count, + indexProgress, + () => processed, + () => indexProgressVisible, + () => currentJsonIndexFile, + () => activeExtractionPhases); var extractionParallelism = Math.Max(1, options.Parallelism); var typeScriptAugmentationNeedsRefresh = !options.SymbolsOnly && (options.Rebuild @@ -341,93 +345,6 @@ void RequireTypeScriptAugmentationRefresh() && !startedWithNoIndexedFiles && FullScanJavaScriptTypeScriptConfigChanged()); - void EnsureIndexingActivityVisible() - { - if (options.Json || options.Quiet) - return; - - if (indexProgressVisible) - return; - - if (indexProgress.Interactive) - { - indexProgress.Start(); - return; - } - - if (redirectedIndexingMessagePrinted) - return; - - CommandOutputWriter.WriteLine("Indexing..."); - redirectedIndexingMessagePrinted = true; - } - - void ReportJsonIndexProgressIfNeeded() - { - if (!options.Json || options.Quiet || files.Count == 0) - return; - - var now = Stopwatch.GetTimestamp(); - if (processed == 0 - || processed == files.Count - || processed % 100 == 0 - || Stopwatch.GetElapsedTime(lastJsonProgressAt, now) >= TimeSpan.FromSeconds(5)) - { - ConsoleUi.TryWriteErrorLine($"cdidx: indexed {processed:N0}/{files.Count:N0} file(s)..."); - lastJsonProgressAt = now; - } - } - - void StartJsonHeartbeatIfNeeded() - { - if (!options.Json || options.Quiet || files.Count == 0 || jsonHeartbeatCts != null) - return; - - jsonHeartbeatCts = new CancellationTokenSource(); - var token = jsonHeartbeatCts.Token; - jsonHeartbeatTask = Task.Run(async () => - { - while (!token.IsCancellationRequested) - { - try - { - await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } - - if (token.IsCancellationRequested) - break; - - var file = GetJsonIndexHeartbeatPath( - currentJsonIndexFile, - FormatActiveExtractionPhases(activeExtractionPhases)); - var fileSuffix = string.IsNullOrEmpty(file) ? string.Empty : $": {file}"; - ConsoleUi.TryWriteErrorLine($"cdidx: still indexing {processed:N0}/{files.Count:N0} file(s){fileSuffix}..."); - } - }, token); - } - - void StopJsonHeartbeat() - { - if (jsonHeartbeatCts == null) - return; - - jsonHeartbeatCts.Cancel(); - try - { - jsonHeartbeatTask?.Wait(TimeSpan.FromSeconds(1)); - } - catch (AggregateException ex) when (ex.InnerExceptions.All(inner => inner is OperationCanceledException or TaskCanceledException)) - { - } - jsonHeartbeatCts.Dispose(); - jsonHeartbeatCts = null; - jsonHeartbeatTask = null; - } - bool FullScanJavaScriptTypeScriptConfigChanged() { foreach (var indexedConfigPath in indexedJavaScriptTypeScriptConfigPathsBeforePurge) @@ -1286,7 +1203,7 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis ConsoleUi.PrintWarning("Skipped authoritative purge outside directories whose file listing completed successfully because some paths could not be scanned."); } - ReportJsonIndexProgressIfNeeded(); + fullScanProgress.ReportJsonIndexProgressIfNeeded(); PostExtractionHookRunner? postExtractionHooks = null; if (extractionWorkItemCount == 0) @@ -1313,8 +1230,8 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis parallelizeExtraction, parallelizeExtractionReason); - EnsureIndexingActivityVisible(); - StartJsonHeartbeatIfNeeded(); + fullScanProgress.EnsureIndexingActivityVisible(); + fullScanProgress.StartJsonHeartbeatIfNeeded(); try { @@ -1397,8 +1314,10 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis ActiveExtractionPhases = activeExtractionPhases, CancellationToken = cancellationToken, CancelExtraction = extractionStallCts.Cancel, - EnsureIndexingActivityVisible = EnsureIndexingActivityVisible, - ReportJsonIndexProgressIfNeeded = ReportJsonIndexProgressIfNeeded, + EnsureIndexingActivityVisible = + fullScanProgress.EnsureIndexingActivityVisible, + ReportJsonIndexProgressIfNeeded = + fullScanProgress.ReportJsonIndexProgressIfNeeded, ThrowIfFullScanCancelled = ThrowIfFullScanCancelled, PublishProcessedCount = value => processed = value, SetCurrentJsonIndexFile = path => currentJsonIndexFile = path, @@ -1465,7 +1384,7 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis finally { currentJsonIndexFile = null; - StopJsonHeartbeat(); + fullScanProgress.StopJsonHeartbeat(); postExtractionHooks?.Dispose(); } } From f2081a5ea705b031de8c0a0b0c24bbcf520301b4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 01:08:46 +0900 Subject: [PATCH 094/101] Separate full scan C# workspace preflight --- ...xCommandRunner.FullScan.CSharpPreflight.cs | 328 ++++++++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 259 ++++---------- 2 files changed, 387 insertions(+), 200 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.CSharpPreflight.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.CSharpPreflight.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.CSharpPreflight.cs new file mode 100644 index 000000000..80794508e --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.CSharpPreflight.cs @@ -0,0 +1,328 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class FullScanCSharpPreflightContext + { + internal required DbWriter Writer { get; init; } + internal required FileIndexer Indexer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required string ProjectRoot { get; init; } + internal required FullScanFileTarget[] FileTargets { get; init; } + internal required IReadOnlyList CSharpPrepassTargets { get; init; } + internal required int CSharpPrepassCapacity { get; init; } + internal required FilePurgePlan StaleFilePurgePlan { get; init; } + internal required bool StartedWithNoIndexedFiles { get; init; } + internal required bool PriorIndexComplete { get; init; } + internal required int PriorReadiness { get; init; } + internal required bool ScanHadErrors { get; init; } + internal required bool ForceExtractorRefresh { get; init; } + internal required bool PriorSymbolsOnlyGraphOmitted { get; init; } + internal required bool SymbolKindFilterMatchesPrior { get; init; } + internal required bool CSharpSymbolNameContractMatchesCurrent { get; init; } + internal required bool CSharpIndexedProjectRootCompatible { get; init; } + internal required bool CSharpHotspotTrustMatchesCurrent { get; init; } + internal required bool RequiresConservativeCSharpSourceRefresh { get; init; } + internal required bool HadCSharpStaticInterfaceContractsBeforePurge { get; init; } + internal required bool? PriorCSharpStaticInterfaceSourceEvidence { get; init; } + internal required bool ProjectRootWritten { get; init; } + internal required int ExtractionParallelism { get; init; } + internal required int FilesCount { get; init; } + internal required string ActualMode { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required Func IsExistingCSharpSymbolPathNowNonCSharp { get; init; } + internal required Func GetDeferCSharpMutationsForIncompleteScan { get; init; } + internal required Func GetPurged { get; init; } + internal required Action DeferCSharpMutationsForIncompleteWorkspace { get; init; } + } + + private sealed record FullScanCSharpPreflightResult( + ReusableIndexedFileStatsSnapshot? ReusableIndexedFileStats, + Dictionary? CSharpPrepassStatReuse, + Dictionary? CSharpWorkspaceFileSnapshots, + CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace, + bool ForceFullCSharpRefreshFromInvalidatedNoOp, + bool PreservePriorPositiveCSharpSourceNoOp, + bool CSharpSourceEvidenceForStamp, + bool CSharpSourceEvidenceComplete); + + private static FullScanCSharpPreflightResult PrepareFullScanCSharpWorkspace( + FullScanCSharpPreflightContext context) + { + var writer = context.Writer; + var options = context.Options; + var cancellationToken = context.CancellationToken; + HashSet? retainedPathsForReuse = null; + if (!options.Rebuild + && !context.StartedWithNoIndexedFiles + && context.StaleFilePurgePlan.RemainingFileCount + - context.FileTargets.LongLength + > context.FileTargets.LongLength) + { + retainedPathsForReuse = new HashSet( + context.FileTargets.Length, + StringComparer.Ordinal); + foreach (var target in context.FileTargets) + retainedPathsForReuse.Add(target.IndexPath); + } + + var csharpPositiveNoOpPolicyCandidate = !options.SymbolsOnly + && context.PriorCSharpStaticInterfaceSourceEvidence is not null + && context.PriorIndexComplete + && (context.PriorReadiness & DbContext.GraphReadyFlag) != 0 + && !context.ScanHadErrors + && !context.HadCSharpStaticInterfaceContractsBeforePurge + && !context.ForceExtractorRefresh + && !context.PriorSymbolsOnlyGraphOmitted + && context.SymbolKindFilterMatchesPrior + && context.CSharpSymbolNameContractMatchesCurrent + && context.CSharpIndexedProjectRootCompatible + && context.CSharpHotspotTrustMatchesCurrent + && context.CSharpPrepassTargets.Count > 0; + var hasCSharpLanguageTransitions = false; + void ObservePersistedCSharpPath(string indexPath) + { + if (!hasCSharpLanguageTransitions + && context.IsExistingCSharpSymbolPathNowNonCSharp(indexPath)) + { + hasCSharpLanguageTransitions = true; + } + } + + var reusableIndexedFileStats = + !options.Rebuild && !context.StartedWithNoIndexedFiles + ? writer.LoadReusableIndexedFileStats( + options.MaxSymbolsPerFile, + options.MaxReferencesPerFile, + cancellationToken, + context.FileTargets.Length, + retainedPathsForReuse, + context.StaleFilePurgePlan.FileIds, + csharpPositiveNoOpPolicyCandidate + ? ObservePersistedCSharpPath + : null) + : null; + Dictionary? + csharpPrepassStatReuse = null; + var priorPositiveCSharpSourceNoOpCandidate = + csharpPositiveNoOpPolicyCandidate + && !hasCSharpLanguageTransitions; + var allCSharpPrepassTargetsReusable = false; + if (priorPositiveCSharpSourceNoOpCandidate) + { + allCSharpPrepassTargetsReusable = true; + csharpPrepassStatReuse = + new Dictionary( + context.CSharpPrepassCapacity, + StringComparer.Ordinal); + foreach (var target in context.CSharpPrepassTargets) + { + cancellationToken.ThrowIfCancellationRequested(); + var existingFile = + IndexedFileStatReuse.TryGetReusableUnchangedFile( + reusableIndexedFileStats!, + target.FilePath, + target.IndexPath, + target.Language, + target.GeneratedExtractionSuppressed); + csharpPrepassStatReuse[target.IndexPath] = existingFile; + allCSharpPrepassTargetsReusable &= existingFile != null; + } + } + + bool CanReuseCSharpPrepassTargetWithoutRead( + CSharpStaticInterfacePrepass.FileTarget target) + { + if (context.ForceExtractorRefresh + || options.Rebuild + || context.StartedWithNoIndexedFiles + || !context.ProjectRootWritten + || (context.RequiresConservativeCSharpSourceRefresh + && !priorPositiveCSharpSourceNoOpCandidate) + || !context.SymbolKindFilterMatchesPrior + || !context.CSharpSymbolNameContractMatchesCurrent + || target.Language != "csharp") + { + return false; + } + + var existingFile = + IndexedFileStatReuse.TryGetReusableUnchangedFile( + reusableIndexedFileStats!, + target.FilePath, + target.IndexPath, + target.Language, + target.GeneratedExtractionSuppressed); + if (existingFile == null) + allCSharpPrepassTargetsReusable = false; + (csharpPrepassStatReuse ??= + new Dictionary( + context.CSharpPrepassCapacity, + StringComparer.Ordinal))[target.IndexPath] = existingFile; + return existingFile != null; + } + + Dictionary? + csharpWorkspaceFileSnapshots = null; + CSharpStaticInterfaceWorkspaceSymbols csharpWorkspace; + var forceFullCSharpRefreshFromInvalidatedNoOp = false; + var csharpWorkspaceMaterialized = + !options.SymbolsOnly + && !context.GetDeferCSharpMutationsForIncompleteScan() + && context.CSharpPrepassTargets.Count > 0 + && !(priorPositiveCSharpSourceNoOpCandidate + && allCSharpPrepassTargetsReusable); + if (options.SymbolsOnly + || context.GetDeferCSharpMutationsForIncompleteScan()) + { + csharpWorkspace = + new CSharpStaticInterfaceWorkspaceSymbols([], false); + } + else + { + csharpWorkspace = BuildFullScanCSharpWorkspaceWithHeartbeat( + context, + priorPositiveCSharpSourceNoOpCandidate, + allCSharpPrepassTargetsReusable, + CanReuseCSharpPrepassTargetWithoutRead, + out csharpWorkspaceFileSnapshots); + forceFullCSharpRefreshFromInvalidatedNoOp = + csharpWorkspaceMaterialized + && (context.PriorCSharpStaticInterfaceSourceEvidence == true + || csharpWorkspace.HasStaticInterfaceContracts); + } + + if (!options.SymbolsOnly + && !csharpWorkspace.SourceContractEvidenceComplete) + { + var incompleteSourcePaths = + csharpWorkspace.IncompleteSourcePaths; + context.DeferCSharpMutationsForIncompleteWorkspace( + csharpWorkspace); + csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols( + [], + false, + SourceContractEvidenceComplete: false, + IncompleteSourcePaths: incompleteSourcePaths); + } + + var preservePriorPositiveCSharpSourceNoOp = + priorPositiveCSharpSourceNoOpCandidate + && allCSharpPrepassTargetsReusable + && !context.GetDeferCSharpMutationsForIncompleteScan(); + var csharpSourceEvidenceForStamp = + preservePriorPositiveCSharpSourceNoOp + ? context.PriorCSharpStaticInterfaceSourceEvidence == true + : csharpWorkspace.HasSourceStaticInterfaceContracts; + var csharpSourceEvidenceComplete = + preservePriorPositiveCSharpSourceNoOp + || csharpWorkspace.SourceContractEvidenceComplete; + if (preservePriorPositiveCSharpSourceNoOp) + { + csharpWorkspace = + csharpWorkspace with { HasStaticInterfaceContracts = false }; + } + if (!options.SymbolsOnly + && !context.GetDeferCSharpMutationsForIncompleteScan() + && !preservePriorPositiveCSharpSourceNoOp + && (forceFullCSharpRefreshFromInvalidatedNoOp + || context.RequiresConservativeCSharpSourceRefresh + || !csharpSourceEvidenceComplete + || (context.GetPurged() > 0 + && context + .HadCSharpStaticInterfaceContractsBeforePurge))) + { + csharpWorkspace = + csharpWorkspace with { HasStaticInterfaceContracts = true }; + } + + return new FullScanCSharpPreflightResult( + reusableIndexedFileStats, + csharpPrepassStatReuse, + csharpWorkspaceFileSnapshots, + csharpWorkspace, + forceFullCSharpRefreshFromInvalidatedNoOp, + preservePriorPositiveCSharpSourceNoOp, + csharpSourceEvidenceForStamp, + csharpSourceEvidenceComplete); + } + + private static CSharpStaticInterfaceWorkspaceSymbols + BuildFullScanCSharpWorkspaceWithHeartbeat( + FullScanCSharpPreflightContext context, + bool priorPositiveCSharpSourceNoOpCandidate, + bool allCSharpPrepassTargetsReusable, + Func + canReuseCSharpPrepassTargetWithoutRead, + out Dictionary? + csharpWorkspaceFileSnapshots) + { + WriteFullScanJsonLiveness( + context.Options, + "preparing C# workspace symbols..."); + var activeCSharpWorkspaceFiles = + new string?[context.CSharpPrepassTargets.Count]; + var heartbeat = StartFullScanJsonPhaseHeartbeat( + context.Options, + "preparing C# workspace symbols", + () => GetActiveCSharpPrepassPath( + activeCSharpWorkspaceFiles)); + try + { + if (context.CSharpPrepassTargets.Count == 0 + || (priorPositiveCSharpSourceNoOpCandidate + && allCSharpPrepassTargetsReusable)) + { + csharpWorkspaceFileSnapshots = null; + return new CSharpStaticInterfaceWorkspaceSymbols([], false); + } + + return BuildStableFullScanCSharpWorkspace( + context.ProjectRoot, + context.CSharpPrepassTargets, + out csharpWorkspaceFileSnapshots, + () => CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( + context.Writer, + context.Indexer, + context.CSharpPrepassTargets, + includeExistingSymbols: + context.CSharpIndexedProjectRootCompatible + && !context.Options.Rebuild + && !context.StartedWithNoIndexedFiles, + canReuseExistingSymbolsWithoutRead: + priorPositiveCSharpSourceNoOpCandidate + ? null + : canReuseCSharpPrepassTargetWithoutRead, + reportCandidateFile: (candidateIndex, path) => + SetActiveCSharpPrepassPath( + activeCSharpWorkspaceFiles, + candidateIndex, + path), + parallelism: context.ExtractionParallelism, + excludedExistingFileIds: + context.StaleFilePurgePlan.FileIds, + isExistingSymbolPathExcluded: + context + .IsExistingCSharpSymbolPathNowNonCSharp, + cancellationToken: context.CancellationToken), + context.CancellationToken); + } + catch (OperationCanceledException) when ( + context.CancellationToken.IsCancellationRequested) + { + throw new IndexInterruptedException( + 0, + context.FilesCount, + context.ActualMode); + } + finally + { + Array.Clear(activeCSharpWorkspaceFiles); + StopFullScanJsonPhaseHeartbeat(heartbeat); + } + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 0855d36c6..be6874c66 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -383,88 +383,6 @@ void InsertIssuesForIndexedFile(long fileId, IReadOnlyList issues) writer.InsertIssues(fileId, issues); } - HashSet? retainedPathsForReuse = null; - if (!options.Rebuild - && !startedWithNoIndexedFiles - && staleFilePurgePlan.RemainingFileCount - fileTargets.LongLength > fileTargets.LongLength) - { - retainedPathsForReuse = new HashSet(fileTargets.Length, StringComparer.Ordinal); - foreach (var target in fileTargets) - retainedPathsForReuse.Add(target.IndexPath); - } - var csharpPositiveNoOpPolicyCandidate = !options.SymbolsOnly - && priorCSharpStaticInterfaceSourceEvidence is not null - && priorIndexComplete - && (priorReadiness & DbContext.GraphReadyFlag) != 0 - && !scanHadErrors - && !hadCSharpStaticInterfaceContractsBeforePurge - && !forceExtractorRefresh - && !priorSymbolsOnlyGraphOmitted - && symbolKindFilterMatchesPrior - && csharpSymbolNameContractMatchesCurrent - && csharpIndexedProjectRootCompatible - && AllowReuseWithCurrentHotspotFamilyTrust( - "csharp", - hotspotFamilyTrustMatchesCurrent) - && csharpPrepassTargets.Count > 0; - var hasCSharpLanguageTransitions = false; - void ObservePersistedCSharpPath(string indexPath) - { - if (!hasCSharpLanguageTransitions && IsExistingCSharpSymbolPathNowNonCSharp(indexPath)) - hasCSharpLanguageTransitions = true; - } - - var reusableIndexedFileStats = !options.Rebuild && !startedWithNoIndexedFiles - ? writer.LoadReusableIndexedFileStats( - options.MaxSymbolsPerFile, - options.MaxReferencesPerFile, - cancellationToken, - fileTargets.Length, - retainedPathsForReuse, - staleFilePurgePlan.FileIds, - csharpPositiveNoOpPolicyCandidate - ? ObservePersistedCSharpPath - : null) - : null; - Dictionary? csharpPrepassStatReuse = null; - var priorPositiveCSharpSourceNoOpCandidate = false; - var allCSharpPrepassTargetsReusable = false; - - bool CanReuseCSharpPrepassTargetWithoutRead(CSharpStaticInterfacePrepass.FileTarget target) - { - if (forceExtractorRefresh - || options.Rebuild - || startedWithNoIndexedFiles - || !projectRootWritten - || (requiresConservativeCSharpSourceRefresh - && !priorPositiveCSharpSourceNoOpCandidate) - || !symbolKindFilterMatchesPrior - || !csharpSymbolNameContractMatchesCurrent) - return false; - if (target.Language != "csharp") - return false; - - var existingFile = IndexedFileStatReuse.TryGetReusableUnchangedFile( - reusableIndexedFileStats!, - target.FilePath, - target.IndexPath, - target.Language, - target.GeneratedExtractionSuppressed); - if (existingFile == null) - { - allCSharpPrepassTargetsReusable = false; - (csharpPrepassStatReuse ??= new Dictionary( - csharpPrepassCapacity, - StringComparer.Ordinal))[target.IndexPath] = null; - return false; - } - - (csharpPrepassStatReuse ??= new Dictionary( - csharpPrepassCapacity, - StringComparer.Ordinal))[target.IndexPath] = existingFile.Value; - return true; - } - bool IsExistingCSharpSymbolPathNowNonCSharp(string indexPath) { var currentPath = Path.Combine( @@ -474,125 +392,66 @@ bool IsExistingCSharpSymbolPathNowNonCSharp(string indexPath) && currentLanguage != "csharp"; } - Dictionary? csharpWorkspaceFileSnapshots = null; - - priorPositiveCSharpSourceNoOpCandidate = csharpPositiveNoOpPolicyCandidate - && !hasCSharpLanguageTransitions; - if (priorPositiveCSharpSourceNoOpCandidate) - { - allCSharpPrepassTargetsReusable = true; - csharpPrepassStatReuse = new Dictionary( - csharpPrepassCapacity, - StringComparer.Ordinal); - foreach (var target in csharpPrepassTargets) - { - cancellationToken.ThrowIfCancellationRequested(); - var existingFile = IndexedFileStatReuse.TryGetReusableUnchangedFile( - reusableIndexedFileStats!, - target.FilePath, - target.IndexPath, - target.Language, - target.GeneratedExtractionSuppressed); - csharpPrepassStatReuse[target.IndexPath] = existingFile; - allCSharpPrepassTargetsReusable &= existingFile != null; - } - } - - CSharpStaticInterfaceWorkspaceSymbols csharpWorkspace; - var forceFullCSharpRefreshFromInvalidatedNoOp = false; - if (options.SymbolsOnly || deferCSharpMutationsForIncompleteScan) - { - csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols([], false); - } - else - { - WriteFullScanJsonLiveness(options, "preparing C# workspace symbols..."); - var activeCSharpWorkspaceFiles = new string?[csharpPrepassTargets.Count]; - var csharpWorkspaceHeartbeat = StartFullScanJsonPhaseHeartbeat( - options, - "preparing C# workspace symbols", - () => GetActiveCSharpPrepassPath(activeCSharpWorkspaceFiles)); - try - { - if (csharpPrepassTargets.Count == 0) - { - csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols([], false); - } - else if (priorPositiveCSharpSourceNoOpCandidate - && allCSharpPrepassTargetsReusable) - { - // A strict positive no-op needs neither persisted C# symbols nor a - // workspace lookup: every existing reference row is retained unchanged. - // positive完全no-opではDB symbol/lookupを一切materializeしない。 - csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols([], false); - } - else - { - csharpWorkspace = BuildStableFullScanCSharpWorkspace( - projectRoot, - csharpPrepassTargets, - out csharpWorkspaceFileSnapshots, - () => - CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( - writer, - indexer, - csharpPrepassTargets, - includeExistingSymbols: csharpIndexedProjectRootCompatible && !options.Rebuild && !startedWithNoIndexedFiles, - canReuseExistingSymbolsWithoutRead: - priorPositiveCSharpSourceNoOpCandidate - ? null - : CanReuseCSharpPrepassTargetWithoutRead, - reportCandidateFile: (candidateIndex, path) => SetActiveCSharpPrepassPath(activeCSharpWorkspaceFiles, candidateIndex, path), - parallelism: extractionParallelism, - excludedExistingFileIds: staleFilePurgePlan.FileIds, - isExistingSymbolPathExcluded: IsExistingCSharpSymbolPathNowNonCSharp, - cancellationToken: cancellationToken), - cancellationToken); - forceFullCSharpRefreshFromInvalidatedNoOp = - priorCSharpStaticInterfaceSourceEvidence == true - || csharpWorkspace.HasStaticInterfaceContracts; - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + var csharpPreflight = PrepareFullScanCSharpWorkspace( + new FullScanCSharpPreflightContext { - throw new IndexInterruptedException(0, files.Count, actualMode); - } - finally - { - Array.Clear(activeCSharpWorkspaceFiles); - StopFullScanJsonPhaseHeartbeat(csharpWorkspaceHeartbeat); - } - } - if (!options.SymbolsOnly && !csharpWorkspace.SourceContractEvidenceComplete) - { - var incompleteSourcePaths = csharpWorkspace.IncompleteSourcePaths; - DeferCSharpMutationsForIncompleteWorkspace(csharpWorkspace); - csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols( - [], - false, - SourceContractEvidenceComplete: false, - IncompleteSourcePaths: incompleteSourcePaths); - } - var preservePriorPositiveCSharpSourceNoOp = priorPositiveCSharpSourceNoOpCandidate - && allCSharpPrepassTargetsReusable - && !deferCSharpMutationsForIncompleteScan; - var csharpSourceEvidenceForStamp = preservePriorPositiveCSharpSourceNoOp - ? priorCSharpStaticInterfaceSourceEvidence == true - : csharpWorkspace.HasSourceStaticInterfaceContracts; - var csharpSourceEvidenceComplete = preservePriorPositiveCSharpSourceNoOp - || csharpWorkspace.SourceContractEvidenceComplete; - if (preservePriorPositiveCSharpSourceNoOp) - csharpWorkspace = csharpWorkspace with { HasStaticInterfaceContracts = false }; - if (!options.SymbolsOnly - && !deferCSharpMutationsForIncompleteScan - && !preservePriorPositiveCSharpSourceNoOp - && (forceFullCSharpRefreshFromInvalidatedNoOp - || requiresConservativeCSharpSourceRefresh - || !csharpSourceEvidenceComplete - || (purged > 0 && hadCSharpStaticInterfaceContractsBeforePurge))) - { - csharpWorkspace = csharpWorkspace with { HasStaticInterfaceContracts = true }; - } + Writer = writer, + Indexer = indexer, + Options = options, + ProjectRoot = projectRoot, + FileTargets = fileTargets, + CSharpPrepassTargets = csharpPrepassTargets, + CSharpPrepassCapacity = csharpPrepassCapacity, + StaleFilePurgePlan = staleFilePurgePlan, + StartedWithNoIndexedFiles = startedWithNoIndexedFiles, + PriorIndexComplete = priorIndexComplete, + PriorReadiness = priorReadiness, + ScanHadErrors = scanHadErrors, + ForceExtractorRefresh = forceExtractorRefresh, + PriorSymbolsOnlyGraphOmitted = priorSymbolsOnlyGraphOmitted, + SymbolKindFilterMatchesPrior = symbolKindFilterMatchesPrior, + CSharpSymbolNameContractMatchesCurrent = + csharpSymbolNameContractMatchesCurrent, + CSharpIndexedProjectRootCompatible = + csharpIndexedProjectRootCompatible, + CSharpHotspotTrustMatchesCurrent = + AllowReuseWithCurrentHotspotFamilyTrust( + "csharp", + hotspotFamilyTrustMatchesCurrent), + RequiresConservativeCSharpSourceRefresh = + requiresConservativeCSharpSourceRefresh, + HadCSharpStaticInterfaceContractsBeforePurge = + hadCSharpStaticInterfaceContractsBeforePurge, + PriorCSharpStaticInterfaceSourceEvidence = + priorCSharpStaticInterfaceSourceEvidence, + ProjectRootWritten = projectRootWritten, + ExtractionParallelism = extractionParallelism, + FilesCount = files.Count, + ActualMode = actualMode, + CancellationToken = cancellationToken, + IsExistingCSharpSymbolPathNowNonCSharp = + IsExistingCSharpSymbolPathNowNonCSharp, + GetDeferCSharpMutationsForIncompleteScan = + () => deferCSharpMutationsForIncompleteScan, + GetPurged = () => purged, + DeferCSharpMutationsForIncompleteWorkspace = + DeferCSharpMutationsForIncompleteWorkspace, + }); + var reusableIndexedFileStats = + csharpPreflight.ReusableIndexedFileStats; + var csharpPrepassStatReuse = + csharpPreflight.CSharpPrepassStatReuse; + var csharpWorkspaceFileSnapshots = + csharpPreflight.CSharpWorkspaceFileSnapshots; + var csharpWorkspace = csharpPreflight.CSharpWorkspace; + var forceFullCSharpRefreshFromInvalidatedNoOp = + csharpPreflight.ForceFullCSharpRefreshFromInvalidatedNoOp; + var preservePriorPositiveCSharpSourceNoOp = + csharpPreflight.PreservePriorPositiveCSharpSourceNoOp; + var csharpSourceEvidenceForStamp = + csharpPreflight.CSharpSourceEvidenceForStamp; + var csharpSourceEvidenceComplete = + csharpPreflight.CSharpSourceEvidenceComplete; void DeferCSharpMutationsForLoadedSnapshotDrift(string path) { From ba4eb49a0b4ab749a4daf8f15026fa0ad4901d04 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 01:25:34 +0900 Subject: [PATCH 095/101] Separate update C# workspace preflight --- ...dexCommandRunner.Update.CSharpPreflight.cs | 468 ++++++++++++++++++ .../Cli/IndexCommandRunner.Update.cs | 306 ++---------- 2 files changed, 511 insertions(+), 263 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpPreflight.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpPreflight.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpPreflight.cs new file mode 100644 index 000000000..ffc98bacb --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpPreflight.cs @@ -0,0 +1,468 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class UpdateCSharpPreflightContext + { + internal required DbWriter Writer { get; init; } + internal required FileIndexer Indexer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required string ProjectRoot { get; init; } + internal required HashSet TargetPaths { get; init; } + internal required bool PriorFilterRetainedCSharpContractMembers { get; init; } + internal required bool? PriorCSharpStaticInterfaceSourceEvidence { get; init; } + internal required FilePurgePlan ScopedCleanupPlan { get; init; } + internal required bool ScopedCleanupHadCSharp { get; init; } + internal required bool ScopedCleanupHadContract { get; init; } + internal required bool HadIndexedCSharpFilesBeforeUpdate { get; init; } + internal required int Updated { get; init; } + internal required int Removed { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required Action ThrowIfUpdateCancelled { get; init; } + internal required Action> + RecordScanErrors + { get; init; } + internal required Action RecordCSharpWorkspaceDrift + { + get; + init; + } + } + + private sealed class UpdateCSharpPreflightState + { + internal IReadOnlyDictionary? ScannedUpdateLanguages + { + get; + set; + } + + internal required List + CSharpPrepassTargets + { get; set; } + internal HashSet? + ExistingCSharpPathsNowUnsupportedOrNonCSharp + { get; set; } + internal required CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace + { + get; + set; + } + + internal Dictionary? + CSharpWorkspaceSnapshots + { get; set; } + internal FileIndexer.ScanInputSnapshot? CSharpWorkspaceInputSnapshot + { + get; + set; + } + + internal bool DeferCSharpMutationsForIncompleteWorkspace + { + get; + set; + } + + internal bool? CSharpSourceEvidenceForStamp { get; set; } + internal bool CSharpSourceEvidenceCompleteForStamp { get; set; } + internal bool PreserveConservativePersistedContractEvidence + { + get; + set; + } + + internal bool CSharpTargetAffected { get; set; } + } + + private sealed record UpdateCSharpPreflightResult( + IReadOnlyDictionary? ScannedUpdateLanguages, + List CSharpPrepassTargets, + CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace, + Dictionary? + CSharpWorkspaceSnapshots, + FileIndexer.ScanInputSnapshot? CSharpWorkspaceInputSnapshot, + bool DeferCSharpMutationsForIncompleteWorkspace, + bool? CSharpSourceEvidenceForStamp, + bool CSharpSourceEvidenceCompleteForStamp, + bool CSharpTargetAffected); + + private static UpdateCSharpPreflightResult PrepareUpdateCSharpWorkspace( + UpdateCSharpPreflightContext context) + { + context.ThrowIfUpdateCancelled(); + WriteIndexJsonLiveness( + context.Options, + "checking C# workspace contracts..."); + var heartbeat = StartIndexJsonPhaseHeartbeat( + context.Options, + "checking C# workspace contracts"); + var targets = BuildUpdateCSharpPrepassTargets( + context.Indexer, + context.ProjectRoot, + context.TargetPaths, + scannedLanguages: null, + out var transitionedPaths); + var state = new UpdateCSharpPreflightState + { + CSharpPrepassTargets = targets, + ExistingCSharpPathsNowUnsupportedOrNonCSharp = + transitionedPaths, + CSharpWorkspace = + new CSharpStaticInterfaceWorkspaceSymbols([], false), + }; + try + { + BuildInitialUpdateCSharpWorkspace(context, state); + } + catch (OperationCanceledException) when ( + context.CancellationToken.IsCancellationRequested) + { + throw new IndexInterruptedException( + context.Updated + context.Removed, + context.TargetPaths.Count); + } + finally + { + StopIndexJsonPhaseHeartbeat(heartbeat); + } + + if (state.CSharpWorkspace.HasStaticInterfaceContracts) + ExpandUpdateCSharpWorkspace(context, state); + + return new UpdateCSharpPreflightResult( + state.ScannedUpdateLanguages, + state.CSharpPrepassTargets, + state.CSharpWorkspace, + state.CSharpWorkspaceSnapshots, + state.CSharpWorkspaceInputSnapshot, + state.DeferCSharpMutationsForIncompleteWorkspace, + state.CSharpSourceEvidenceForStamp, + state.CSharpSourceEvidenceCompleteForStamp, + state.CSharpTargetAffected); + } + + private static void BuildInitialUpdateCSharpWorkspace( + UpdateCSharpPreflightContext context, + UpdateCSharpPreflightState state) + { + var writer = context.Writer; + var cancellationToken = context.CancellationToken; + var transitionedPaths = + state.ExistingCSharpPathsNowUnsupportedOrNonCSharp; + var transitionedPathWasCSharp = transitionedPaths is { Count: > 0 } + && writer.HasCSharpFilesInPaths( + transitionedPaths, + cancellationToken); + var transitionedPathHadContract = transitionedPaths is { Count: > 0 } + && writer.HasCSharpStaticInterfaceContractSymbolsInPaths( + transitionedPaths, + includeInterfaceDeclarationsAsConservativeEvidence: + context.PriorCSharpStaticInterfaceSourceEvidence == null + || !context.PriorFilterRetainedCSharpContractMembers, + cancellationToken); + state.CSharpTargetAffected = state.CSharpPrepassTargets.Count > 0 + || transitionedPathWasCSharp + || context.ScopedCleanupHadCSharp; + var persistedContractEvidence = context.ScopedCleanupHadContract + || transitionedPathHadContract + || (state.CSharpTargetAffected + && context.HadIndexedCSharpFilesBeforeUpdate + && context.PriorCSharpStaticInterfaceSourceEvidence != false); + state.PreserveConservativePersistedContractEvidence = + persistedContractEvidence; + + if (state.CSharpPrepassTargets.Count == 0 + && !persistedContractEvidence) + { + state.CSharpWorkspace = + new CSharpStaticInterfaceWorkspaceSymbols( + [], + transitionedPathHadContract); + } + else if (persistedContractEvidence) + { + // Persisted contracts already require the complete C# update set. Defer + // candidate reads and workspace materialization to that authoritative pass. + // 永続化済みcontractがある場合は全C# update setが必要なため、candidate + // readとworkspace materializationを後続のauthoritative passへ委譲する。 + state.CSharpWorkspace = + new CSharpStaticInterfaceWorkspaceSymbols([], true); + } + else + { + BuildInitialUpdateCSharpWorkspaceSnapshot(context, state); + } + + if (state.CSharpTargetAffected + && context.PriorCSharpStaticInterfaceSourceEvidence == false) + { + state.CSharpSourceEvidenceForStamp = + state.CSharpWorkspace.HasSourceStaticInterfaceContracts; + state.CSharpSourceEvidenceCompleteForStamp = + state.CSharpWorkspace.SourceContractEvidenceComplete; + } + + if (!state.CSharpWorkspace.SourceContractEvidenceComplete) + { + state.CSharpWorkspace = state.CSharpWorkspace with + { + HasStaticInterfaceContracts = true, + }; + } + } + + private static void BuildInitialUpdateCSharpWorkspaceSnapshot( + UpdateCSharpPreflightContext context, + UpdateCSharpPreflightState state) + { + var capturedBefore = + CSharpStaticInterfacePrepass.TryCaptureFileStatSnapshots( + state.CSharpPrepassTargets, + out var beforeSnapshots, + out _, + context.CancellationToken); + if (!capturedBefore) + { + state.CSharpWorkspace = + new CSharpStaticInterfaceWorkspaceSymbols( + [], + HasStaticInterfaceContracts: true, + SourceContractEvidenceComplete: false); + return; + } + + UpdateCSharpPrepassForTesting?.Invoke(); + state.CSharpWorkspace = + CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( + context.Writer, + context.Indexer, + state.CSharpPrepassTargets, + includeExistingSymbols: false, + parallelism: context.Options.Parallelism, + cancellationToken: context.CancellationToken); + if (!CSharpStaticInterfacePrepass.TryValidateFileStatSnapshots( + state.CSharpPrepassTargets, + beforeSnapshots, + out _, + context.CancellationToken)) + { + state.CSharpWorkspace = state.CSharpWorkspace with + { + HasStaticInterfaceContracts = true, + SourceContractEvidenceComplete = false, + }; + return; + } + + state.CSharpWorkspaceSnapshots = beforeSnapshots; + } + + private static void ExpandUpdateCSharpWorkspace( + UpdateCSharpPreflightContext context, + UpdateCSharpPreflightState state) + { + WriteIndexJsonLiveness( + context.Options, + "expanding C# update set for static interface contracts..."); + var heartbeat = StartIndexJsonPhaseHeartbeat( + context.Options, + "expanding C# update set for static interface contracts"); + try + { + UpdateCSharpExpansionScanStartingForTesting?.Invoke(); + var scanWithDirectorySnapshots = + context.Indexer.ScanFilesDetailedWithDirectoryListingSnapshots( + cancellationToken: context.CancellationToken); + var scanResult = scanWithDirectorySnapshots.ScanResult; + state.CSharpWorkspaceInputSnapshot = + scanWithDirectorySnapshots.InputSnapshot; + var expandedScanHadFatalErrors = + scanResult.Errors.Any(error => error.IsFatal); + context.RecordScanErrors(scanResult.Errors); + state.ScannedUpdateLanguages = scanResult.FileLanguages; + if (expandedScanHadFatalErrors) + { + DeferExpandedUpdateCSharpWorkspace(context, state); + return; + } + + AddExpandedUpdateCSharpTargets(context, state, scanResult); + BuildExpandedUpdateCSharpWorkspace(context, state); + } + catch (OperationCanceledException) when ( + context.CancellationToken.IsCancellationRequested) + { + throw new IndexInterruptedException( + context.Updated + context.Removed, + context.TargetPaths.Count); + } + finally + { + StopIndexJsonPhaseHeartbeat(heartbeat); + } + } + + private static void AddExpandedUpdateCSharpTargets( + UpdateCSharpPreflightContext context, + UpdateCSharpPreflightState state, + FileIndexer.ScanFilesResult scanResult) + { + var expandedTargetIndexPaths = + new HashSet(StringComparer.Ordinal); + foreach (var existingTargetPath in context.TargetPaths) + { + expandedTargetIndexPaths.Add( + UpdateFileTarget.Create( + context.ProjectRoot, + existingTargetPath).IndexPath); + } + + foreach (var filePath in scanResult.Files) + { + if (scanResult.FileLanguages.TryGetValue( + filePath, + out var language) + && language == "csharp" + && expandedTargetIndexPaths.Add( + UpdateFileTarget.Create( + context.ProjectRoot, + filePath).IndexPath)) + { + context.TargetPaths.Add(filePath); + } + } + + state.CSharpPrepassTargets = BuildUpdateCSharpPrepassTargets( + context.Indexer, + context.ProjectRoot, + context.TargetPaths, + state.ScannedUpdateLanguages, + out var transitionedPaths); + state.ExistingCSharpPathsNowUnsupportedOrNonCSharp = + transitionedPaths; + } + + private static void BuildExpandedUpdateCSharpWorkspace( + UpdateCSharpPreflightContext context, + UpdateCSharpPreflightState state) + { + var cancellationToken = context.CancellationToken; + var capturedBefore = + CSharpStaticInterfacePrepass.TryCaptureFileStatSnapshots( + state.CSharpPrepassTargets, + out var beforeSnapshots, + out var snapshotFailurePath, + cancellationToken); + if (state.CSharpPrepassTargets.Count == 0) + { + state.CSharpWorkspace = + new CSharpStaticInterfaceWorkspaceSymbols([], false); + } + else if (!capturedBefore) + { + state.CSharpWorkspace = + new CSharpStaticInterfaceWorkspaceSymbols( + [], + HasStaticInterfaceContracts: true, + SourceContractEvidenceComplete: false); + } + else + { + UpdateCSharpPrepassForTesting?.Invoke(); + state.CSharpWorkspace = + CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( + context.Writer, + context.Indexer, + state.CSharpPrepassTargets, + isExistingSymbolPathExcluded: path => + state + .ExistingCSharpPathsNowUnsupportedOrNonCSharp? + .Contains(path) == true, + parallelism: context.Options.Parallelism, + excludedExistingFileIds: + context.ScopedCleanupPlan.FileIds, + cancellationToken: cancellationToken); + } + + string? afterSnapshotFailurePath = null; + var stableFilesAfterPrepass = capturedBefore + && CSharpStaticInterfacePrepass.TryValidateFileStatSnapshots( + state.CSharpPrepassTargets, + beforeSnapshots, + out afterSnapshotFailurePath, + cancellationToken); + var stableSnapshot = stableFilesAfterPrepass + && state.CSharpWorkspace.SourceContractEvidenceComplete; + if (!stableSnapshot) + { + state.DeferCSharpMutationsForIncompleteWorkspace = true; + context.RecordCSharpWorkspaceDrift( + state.CSharpWorkspace.IncompleteSourcePaths?.FirstOrDefault() + ?? snapshotFailurePath + ?? afterSnapshotFailurePath + ?? "", + "The C# workspace changed or became unreadable during contract preflight."); + state.CSharpSourceEvidenceForStamp = null; + state.CSharpSourceEvidenceCompleteForStamp = false; + state.CSharpWorkspaceSnapshots = null; + state.CSharpWorkspace = state.CSharpWorkspace with + { + HasStaticInterfaceContracts = true, + SourceContractEvidenceComplete = false, + }; + DeferCSharpTargetsAfterIncompleteWorkspace( + context.Writer, + context.ProjectRoot, + context.TargetPaths, + cancellationToken); + return; + } + + state.CSharpWorkspaceSnapshots = beforeSnapshots; + state.CSharpSourceEvidenceForStamp = + state.CSharpWorkspace.HasSourceStaticInterfaceContracts; + state.CSharpSourceEvidenceCompleteForStamp = true; + + // Persisted positive/legacy evidence remains conservative until every C# file + // has been refreshed successfully. Even when the new source snapshot is + // negative, disable C# stat reuse for this pass. + // persisted positive/legacy evidence は全C# refresh成功まで保持し、 + // 新 snapshot がnegativeでも今回のC# stat reuseは無効化する。 + if (state.PreserveConservativePersistedContractEvidence) + { + state.CSharpWorkspace = state.CSharpWorkspace with + { + HasStaticInterfaceContracts = true, + }; + } + } + + private static void DeferExpandedUpdateCSharpWorkspace( + UpdateCSharpPreflightContext context, + UpdateCSharpPreflightState state) + { + // An incomplete enumeration cannot prove that a hook-hidden source contract + // was absent from the omitted subtree. Preserve every C# row and reference + // instead of rebuilding visible implementations against a partial lookup. + // 不完全列挙では omitted subtree の hook-hidden contract 不在を証明できないため、 + // C# row/ref は全て保持し、non-C# target のみ進める。 + state.DeferCSharpMutationsForIncompleteWorkspace = true; + state.CSharpSourceEvidenceForStamp = null; + state.CSharpSourceEvidenceCompleteForStamp = false; + state.CSharpWorkspaceSnapshots = null; + state.CSharpWorkspace = + new CSharpStaticInterfaceWorkspaceSymbols( + [], + HasStaticInterfaceContracts: true, + SourceContractEvidenceComplete: false); + DeferCSharpTargetsAfterIncompleteWorkspace( + context.Writer, + context.ProjectRoot, + context.TargetPaths, + context.CancellationToken); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index fc12698a3..9411f7362 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -377,10 +377,6 @@ void RecordCSharpWorkspaceDrift( ], fatalPhase); } - IReadOnlyDictionary? scannedUpdateLanguages = null; - ThrowIfUpdateCancelled(); - WriteIndexJsonLiveness(options, "checking C# workspace contracts..."); - var csharpWorkspaceHeartbeat = StartIndexJsonPhaseHeartbeat(options, "checking C# workspace contracts"); var priorCSharpStaticInterfaceSourceEvidence = writer.GetCSharpStaticInterfaceSourceEvidence(); var scopedCleanupPlan = PlanUpdateCSharpCleanup( @@ -431,20 +427,49 @@ int PurgeStaleUpdateCleanupPaths( return writer.ApplyScopedFileCleanupPlan(livePlan, cancellationToken); } - var csharpPrepassTargets = BuildUpdateCSharpPrepassTargets( - indexer, - projectRoot, - targetPaths, - scannedUpdateLanguages, - out var existingCSharpPathsNowUnsupportedOrNonCSharp); - CSharpStaticInterfaceWorkspaceSymbols csharpWorkspace; - Dictionary? csharpWorkspaceSnapshots = null; - FileIndexer.ScanInputSnapshot? csharpWorkspaceInputSnapshot = null; - var deferCSharpMutationsForIncompleteWorkspace = false; - bool? csharpSourceEvidenceForStamp = null; - var csharpSourceEvidenceCompleteForStamp = false; - var preserveConservativePersistedContractEvidence = false; - var csharpTargetAffected = false; + var csharpPreflight = PrepareUpdateCSharpWorkspace( + new UpdateCSharpPreflightContext + { + Writer = writer, + Indexer = indexer, + Options = options, + ProjectRoot = projectRoot, + TargetPaths = targetPaths, + PriorFilterRetainedCSharpContractMembers = + priorFilterRetainedCSharpContractMembers, + PriorCSharpStaticInterfaceSourceEvidence = + priorCSharpStaticInterfaceSourceEvidence, + ScopedCleanupPlan = scopedCleanupPlan, + ScopedCleanupHadCSharp = scopedCleanupHadCSharp, + ScopedCleanupHadContract = scopedCleanupHadContract, + HadIndexedCSharpFilesBeforeUpdate = + hadIndexedCSharpFilesBeforeUpdate, + Updated = updated, + Removed = removed, + CancellationToken = cancellationToken, + ThrowIfUpdateCancelled = ThrowIfUpdateCancelled, + RecordScanErrors = errors => RecordScanErrors(errors), + RecordCSharpWorkspaceDrift = (path, detail) => + RecordCSharpWorkspaceDrift(path, detail), + }); + var scannedUpdateLanguages = + csharpPreflight.ScannedUpdateLanguages; + var csharpPrepassTargets = + csharpPreflight.CSharpPrepassTargets; + var csharpWorkspace = csharpPreflight.CSharpWorkspace; + var csharpWorkspaceSnapshots = + csharpPreflight.CSharpWorkspaceSnapshots; + var csharpWorkspaceInputSnapshot = + csharpPreflight.CSharpWorkspaceInputSnapshot; + var deferCSharpMutationsForIncompleteWorkspace = + csharpPreflight.DeferCSharpMutationsForIncompleteWorkspace; + var csharpSourceEvidenceForStamp = + csharpPreflight.CSharpSourceEvidenceForStamp; + var csharpSourceEvidenceCompleteForStamp = + csharpPreflight.CSharpSourceEvidenceCompleteForStamp; + var csharpTargetAffected = + csharpPreflight.CSharpTargetAffected; + bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) { if (csharpWorkspaceInputSnapshot == null) @@ -460,251 +485,6 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) changedPath = changedInputPath; return stable; } - try - { - var transitionedPathWasCSharp = existingCSharpPathsNowUnsupportedOrNonCSharp is { Count: > 0 } - && writer.HasCSharpFilesInPaths( - existingCSharpPathsNowUnsupportedOrNonCSharp, - cancellationToken); - var transitionedPathHadContract = existingCSharpPathsNowUnsupportedOrNonCSharp is { Count: > 0 } - && writer.HasCSharpStaticInterfaceContractSymbolsInPaths( - existingCSharpPathsNowUnsupportedOrNonCSharp, - includeInterfaceDeclarationsAsConservativeEvidence: - priorCSharpStaticInterfaceSourceEvidence == null - || !priorFilterRetainedCSharpContractMembers, - cancellationToken); - csharpTargetAffected = csharpPrepassTargets.Count > 0 - || transitionedPathWasCSharp - || scopedCleanupHadCSharp; - var persistedContractEvidence = scopedCleanupHadContract - || transitionedPathHadContract - || (csharpTargetAffected - && hadIndexedCSharpFilesBeforeUpdate - && priorCSharpStaticInterfaceSourceEvidence != false); - preserveConservativePersistedContractEvidence = persistedContractEvidence; - if (csharpPrepassTargets.Count == 0 && !persistedContractEvidence) - { - csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols([], transitionedPathHadContract); - } - else if (persistedContractEvidence) - { - // Persisted contracts already require the complete C# update set. Defer - // candidate reads and workspace materialization to that authoritative pass. - // 永続化済みcontractがある場合は全C# update setが必要なため、candidate - // readとworkspace materializationを後続のauthoritative passへ委譲する。 - csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols([], true); - } - else - { - var capturedBefore = CSharpStaticInterfacePrepass.TryCaptureFileStatSnapshots( - csharpPrepassTargets, - out var beforeSnapshots, - out _, - cancellationToken); - if (!capturedBefore) - { - csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols( - [], - HasStaticInterfaceContracts: true, - SourceContractEvidenceComplete: false); - } - else - { - UpdateCSharpPrepassForTesting?.Invoke(); - csharpWorkspace = CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( - writer, - indexer, - csharpPrepassTargets, - includeExistingSymbols: false, - parallelism: options.Parallelism, - cancellationToken: cancellationToken); - if (!CSharpStaticInterfacePrepass.TryValidateFileStatSnapshots( - csharpPrepassTargets, - beforeSnapshots, - out _, - cancellationToken)) - { - csharpWorkspace = csharpWorkspace with - { - HasStaticInterfaceContracts = true, - SourceContractEvidenceComplete = false, - }; - } - else - { - csharpWorkspaceSnapshots = beforeSnapshots; - } - } - } - - if (csharpTargetAffected && priorCSharpStaticInterfaceSourceEvidence == false) - { - csharpSourceEvidenceForStamp = csharpWorkspace.HasSourceStaticInterfaceContracts; - csharpSourceEvidenceCompleteForStamp = csharpWorkspace.SourceContractEvidenceComplete; - } - - if (!csharpWorkspace.SourceContractEvidenceComplete) - { - csharpWorkspace = csharpWorkspace with { HasStaticInterfaceContracts = true }; - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw new IndexInterruptedException(updated + removed, targetPaths.Count); - } - finally - { - StopIndexJsonPhaseHeartbeat(csharpWorkspaceHeartbeat); - } - if (csharpWorkspace.HasStaticInterfaceContracts) - { - WriteIndexJsonLiveness(options, "expanding C# update set for static interface contracts..."); - var expandHeartbeat = StartIndexJsonPhaseHeartbeat(options, "expanding C# update set for static interface contracts"); - try - { - UpdateCSharpExpansionScanStartingForTesting?.Invoke(); - var scanWithDirectorySnapshots = - indexer.ScanFilesDetailedWithDirectoryListingSnapshots( - cancellationToken: cancellationToken); - var scanResult = scanWithDirectorySnapshots.ScanResult; - csharpWorkspaceInputSnapshot = scanWithDirectorySnapshots.InputSnapshot; - var expandedScanHadFatalErrors = scanResult.Errors.Any(error => error.IsFatal); - RecordScanErrors(scanResult.Errors); - scannedUpdateLanguages = scanResult.FileLanguages; - if (expandedScanHadFatalErrors) - { - // An incomplete enumeration cannot prove that a hook-hidden source - // contract was absent from the omitted subtree. Preserve every C# row - // and reference instead of rebuilding visible implementations against - // a partial lookup. Non-C# caller targets may still make progress. - // 不完全列挙では omitted subtree の hook-hidden contract 不在を証明 - // できないため、C# row/ref は全て保持し、non-C# target のみ進める。 - deferCSharpMutationsForIncompleteWorkspace = true; - csharpSourceEvidenceForStamp = null; - csharpSourceEvidenceCompleteForStamp = false; - csharpWorkspaceSnapshots = null; - csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols( - [], - HasStaticInterfaceContracts: true, - SourceContractEvidenceComplete: false); - DeferCSharpTargetsAfterIncompleteWorkspace( - writer, - projectRoot, - targetPaths, - cancellationToken); - } - else - { - var expandedTargetIndexPaths = new HashSet(StringComparer.Ordinal); - foreach (var existingTargetPath in targetPaths) - { - expandedTargetIndexPaths.Add( - UpdateFileTarget.Create(projectRoot, existingTargetPath).IndexPath); - } - foreach (var filePath in scanResult.Files) - { - if (scanResult.FileLanguages.TryGetValue(filePath, out var language) - && language == "csharp" - && expandedTargetIndexPaths.Add( - UpdateFileTarget.Create(projectRoot, filePath).IndexPath)) - { - targetPaths.Add(filePath); - } - } - - csharpPrepassTargets = BuildUpdateCSharpPrepassTargets( - indexer, - projectRoot, - targetPaths, - scannedUpdateLanguages, - out existingCSharpPathsNowUnsupportedOrNonCSharp); - var capturedBefore = CSharpStaticInterfacePrepass.TryCaptureFileStatSnapshots( - csharpPrepassTargets, - out var beforeSnapshots, - out var snapshotFailurePath, - cancellationToken); - if (csharpPrepassTargets.Count == 0) - { - csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols([], false); - } - else if (!capturedBefore) - { - csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols( - [], - HasStaticInterfaceContracts: true, - SourceContractEvidenceComplete: false); - } - else - { - UpdateCSharpPrepassForTesting?.Invoke(); - csharpWorkspace = CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( - writer, - indexer, - csharpPrepassTargets, - isExistingSymbolPathExcluded: path => - existingCSharpPathsNowUnsupportedOrNonCSharp?.Contains(path) == true, - parallelism: options.Parallelism, - excludedExistingFileIds: scopedCleanupPlan.FileIds, - cancellationToken: cancellationToken); - } - - string? afterSnapshotFailurePath = null; - var stableFilesAfterPrepass = capturedBefore - && CSharpStaticInterfacePrepass.TryValidateFileStatSnapshots( - csharpPrepassTargets, - beforeSnapshots, - out afterSnapshotFailurePath, - cancellationToken); - var stableSnapshot = stableFilesAfterPrepass - && csharpWorkspace.SourceContractEvidenceComplete; - if (!stableSnapshot) - { - deferCSharpMutationsForIncompleteWorkspace = true; - RecordCSharpWorkspaceDrift( - csharpWorkspace.IncompleteSourcePaths?.FirstOrDefault() - ?? snapshotFailurePath - ?? afterSnapshotFailurePath - ?? "", - "The C# workspace changed or became unreadable during contract preflight."); - csharpSourceEvidenceForStamp = null; - csharpSourceEvidenceCompleteForStamp = false; - csharpWorkspaceSnapshots = null; - csharpWorkspace = csharpWorkspace with - { - HasStaticInterfaceContracts = true, - SourceContractEvidenceComplete = false, - }; - DeferCSharpTargetsAfterIncompleteWorkspace( - writer, - projectRoot, - targetPaths, - cancellationToken); - } - else - { - csharpWorkspaceSnapshots = beforeSnapshots; - csharpSourceEvidenceForStamp = csharpWorkspace.HasSourceStaticInterfaceContracts; - csharpSourceEvidenceCompleteForStamp = true; - - // Persisted positive/legacy evidence remains conservative until - // every C# file has been refreshed successfully. Even when the new - // source snapshot is negative, disable C# stat reuse for this pass. - // persisted positive/legacy evidence は全C# refresh成功まで保持し、 - // 新 snapshot がnegativeでも今回のC# stat reuseは無効化する。 - if (preserveConservativePersistedContractEvidence) - csharpWorkspace = csharpWorkspace with { HasStaticInterfaceContracts = true }; - } - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw new IndexInterruptedException(updated + removed, targetPaths.Count); - } - finally - { - StopIndexJsonPhaseHeartbeat(expandHeartbeat); - } - } if (csharpWorkspaceInputSnapshot != null) { From 80847d5a4019d28f0bda0f4d0bdd2dc415a37453 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 01:31:10 +0900 Subject: [PATCH 096/101] Separate update C# mutation guards --- ...ommandRunner.Update.CSharpMutationGuard.cs | 241 ++++++++++++++++++ .../Cli/IndexCommandRunner.Update.cs | 213 ++++++---------- 2 files changed, 319 insertions(+), 135 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpMutationGuard.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpMutationGuard.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpMutationGuard.cs new file mode 100644 index 000000000..f6620ce3c --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpMutationGuard.cs @@ -0,0 +1,241 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class UpdateCSharpMutationGuardContext + { + internal required DbWriter Writer { get; init; } + internal required FileIndexer Indexer { get; init; } + internal required string ProjectRoot { get; init; } + internal required HashSet TargetPaths { get; init; } + internal IReadOnlyDictionary? ScannedUpdateLanguages + { + get; + init; + } + + internal required FilePurgePlan ScopedCleanupPlan { get; init; } + internal FileIndexer.ScanInputSnapshot? CSharpWorkspaceInputSnapshot + { + get; + init; + } + + internal Dictionary? + CSharpWorkspaceSnapshots + { get; init; } + internal required CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace + { + get; + init; + } + + internal required bool DeferCSharpMutationsForIncompleteWorkspace + { + get; + init; + } + + internal required bool? CSharpSourceEvidenceForStamp { get; init; } + internal required bool CSharpSourceEvidenceCompleteForStamp + { + get; + init; + } + + internal required CancellationToken CancellationToken { get; init; } + internal required Action RecordCSharpWorkspaceDrift + { + get; + init; + } + } + + private sealed record UpdateCSharpMutationGuardResult( + string? InputSnapshotFailurePath, + bool DeferCSharpMutationsForIncompleteWorkspace, + bool? CSharpSourceEvidenceForStamp, + bool CSharpSourceEvidenceCompleteForStamp, + Dictionary? + CSharpWorkspaceSnapshots, + CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace); + + private static UpdateCSharpMutationGuardResult + GuardUpdateCSharpMutationInputs( + UpdateCSharpMutationGuardContext context) + { + var inputSnapshotFailurePath = + ValidateUpdateCSharpInputSnapshot(context); + if (inputSnapshotFailurePath != null) + return BuildUpdateCSharpMutationGuardResult( + context, + inputSnapshotFailurePath); + + var deferCSharpMutations = + context.DeferCSharpMutationsForIncompleteWorkspace; + var sourceEvidence = context.CSharpSourceEvidenceForStamp; + var sourceEvidenceComplete = + context.CSharpSourceEvidenceCompleteForStamp; + var workspaceSnapshots = context.CSharpWorkspaceSnapshots; + var workspace = context.CSharpWorkspace; + + string? changedTargetPath = null; + var stableTargetSet = workspaceSnapshots == null + || TryValidateCurrentCSharpTargetSet( + context.ProjectRoot, + context.TargetPaths, + context.ScannedUpdateLanguages, + workspaceSnapshots, + out changedTargetPath, + context.CancellationToken); + if (!deferCSharpMutations && !stableTargetSet) + { + DeferUpdateCSharpWorkspaceForMutationDrift( + context, + changedTargetPath ?? "", + "The C# workspace target set changed after contract preflight.", + ref deferCSharpMutations, + ref sourceEvidence, + ref sourceEvidenceComplete, + ref workspaceSnapshots, + ref workspace); + } + + if (!deferCSharpMutations && context.ScopedCleanupPlan.Count > 0) + { + var reappearedCleanupPath = + FindReappearedUpdateCleanupPath(context); + if (reappearedCleanupPath != null) + { + DeferUpdateCSharpWorkspaceForMutationDrift( + context, + reappearedCleanupPath, + "A cleanup-planned path reappeared after C# workspace discovery.", + ref deferCSharpMutations, + ref sourceEvidence, + ref sourceEvidenceComplete, + ref workspaceSnapshots, + ref workspace); + } + } + + return new UpdateCSharpMutationGuardResult( + InputSnapshotFailurePath: null, + deferCSharpMutations, + sourceEvidence, + sourceEvidenceComplete, + workspaceSnapshots, + workspace); + } + + private static string? ValidateUpdateCSharpInputSnapshot( + UpdateCSharpMutationGuardContext context) + { + if (context.CSharpWorkspaceInputSnapshot == null) + return null; + + UpdateScanInputSnapshotBarrierForTesting?.Invoke("before_write"); + return context.Indexer.TryValidateScanInputSnapshot( + context.CSharpWorkspaceInputSnapshot, + out var changedInputPath, + context.CancellationToken) + ? null + : changedInputPath ?? context.ProjectRoot; + } + + private static string? FindReappearedUpdateCleanupPath( + UpdateCSharpMutationGuardContext context) + { + UpdateScanInputSnapshotBarrierForTesting?.Invoke( + "before_cleanup_apply"); + Dictionary>? + retainedFileIdentitiesByCaseFold = null; + var retainedPathsExact = + new HashSet(StringComparer.Ordinal); + foreach (var retainedTargetPath in context.TargetPaths) + { + context.CancellationToken.ThrowIfCancellationRequested(); + var retainedTarget = UpdateFileTarget.Create( + context.ProjectRoot, + retainedTargetPath); + retainedPathsExact.Add(retainedTarget.IndexPath); + var ioPath = + LongPath.EnsureWindowsPrefix(retainedTarget.FilePath); + if (!File.Exists(ioPath)) + continue; + + if (!FileIndexer.TryGetFileIdentity( + ioPath, + out var retainedIdentity)) + { + continue; + } + + retainedFileIdentitiesByCaseFold ??= + new Dictionary>( + StringComparer.OrdinalIgnoreCase); + if (!retainedFileIdentitiesByCaseFold.TryGetValue( + retainedTarget.IndexPath, + out var retainedIdentities)) + { + retainedIdentities = []; + retainedFileIdentitiesByCaseFold.Add( + retainedTarget.IndexPath, + retainedIdentities); + } + + retainedIdentities.Add(retainedIdentity); + } + + return context.Writer.FindReappearedFileInScopedCleanupPlan( + context.ProjectRoot, + context.ScopedCleanupPlan.FileIds, + retainedPathsExact, + retainedFileIdentitiesByCaseFold, + context.CancellationToken); + } + + private static void DeferUpdateCSharpWorkspaceForMutationDrift( + UpdateCSharpMutationGuardContext context, + string path, + string detail, + ref bool deferCSharpMutations, + ref bool? sourceEvidence, + ref bool sourceEvidenceComplete, + ref Dictionary? + workspaceSnapshots, + ref CSharpStaticInterfaceWorkspaceSymbols workspace) + { + deferCSharpMutations = true; + context.RecordCSharpWorkspaceDrift(path, detail); + sourceEvidence = null; + sourceEvidenceComplete = false; + workspaceSnapshots = null; + workspace = workspace with + { + HasStaticInterfaceContracts = true, + SourceContractEvidenceComplete = false, + }; + DeferCSharpTargetsAfterIncompleteWorkspace( + context.Writer, + context.ProjectRoot, + context.TargetPaths, + context.CancellationToken); + } + + private static UpdateCSharpMutationGuardResult + BuildUpdateCSharpMutationGuardResult( + UpdateCSharpMutationGuardContext context, + string inputSnapshotFailurePath) + => new( + inputSnapshotFailurePath, + context.DeferCSharpMutationsForIncompleteWorkspace, + context.CSharpSourceEvidenceForStamp, + context.CSharpSourceEvidenceCompleteForStamp, + context.CSharpWorkspaceSnapshots, + context.CSharpWorkspace); +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index 9411f7362..d9d20b3a8 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -470,7 +470,76 @@ int PurgeStaleUpdateCleanupPaths( var csharpTargetAffected = csharpPreflight.CSharpTargetAffected; - bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) + var csharpMutationGuard = GuardUpdateCSharpMutationInputs( + new UpdateCSharpMutationGuardContext + { + Writer = writer, + Indexer = indexer, + ProjectRoot = projectRoot, + TargetPaths = targetPaths, + ScannedUpdateLanguages = scannedUpdateLanguages, + ScopedCleanupPlan = scopedCleanupPlan, + CSharpWorkspaceInputSnapshot = + csharpWorkspaceInputSnapshot, + CSharpWorkspaceSnapshots = csharpWorkspaceSnapshots, + CSharpWorkspace = csharpWorkspace, + DeferCSharpMutationsForIncompleteWorkspace = + deferCSharpMutationsForIncompleteWorkspace, + CSharpSourceEvidenceForStamp = + csharpSourceEvidenceForStamp, + CSharpSourceEvidenceCompleteForStamp = + csharpSourceEvidenceCompleteForStamp, + CancellationToken = cancellationToken, + RecordCSharpWorkspaceDrift = (path, detail) => + RecordCSharpWorkspaceDrift(path, detail), + }); + if (csharpMutationGuard.InputSnapshotFailurePath != null) + { + return WriteUpdateSnapshotFailure( + csharpMutationGuard.InputSnapshotFailurePath, + new UpdateSnapshotFailureContext + { + Writer = writer, + Options = options, + Stopwatch = stopwatch, + JsonContext = jsonContext, + ProjectRoot = projectRoot, + PriorReadiness = priorReadiness, + CSharpSymbolNameContractMatchesCurrent = + csharpSymbolNameContractMatchesCurrent, + PriorMetadataTargetCsharpMatchesCurrent = + priorMetadataTargetCsharpMatchesCurrent, + PriorFoldVersion = priorFoldVersion, + PriorFoldFingerprint = priorFoldFingerprint, + CurrentFoldVersion = currentFoldVersion, + CurrentFoldFingerprint = currentFoldFingerprint, + MemorySamples = memorySamples, + Skipped = skipped, + Warnings = warnings, + SymbolsDroppedByKindFilter = + symbolsDroppedByKindFilter, + ErrorList = errorList, + FileErrorList = fileErrorList, + WarningList = warningList, + RecordCSharpWorkspaceDrift = + RecordCSharpWorkspaceDrift, + GetErrorCount = () => errors, + }); + } + + deferCSharpMutationsForIncompleteWorkspace = + csharpMutationGuard + .DeferCSharpMutationsForIncompleteWorkspace; + csharpSourceEvidenceForStamp = + csharpMutationGuard.CSharpSourceEvidenceForStamp; + csharpSourceEvidenceCompleteForStamp = + csharpMutationGuard.CSharpSourceEvidenceCompleteForStamp; + csharpWorkspaceSnapshots = + csharpMutationGuard.CSharpWorkspaceSnapshots; + csharpWorkspace = csharpMutationGuard.CSharpWorkspace; + + bool TryValidateCSharpWorkspaceInputSnapshot( + out string? changedPath) { if (csharpWorkspaceInputSnapshot == null) { @@ -478,143 +547,16 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) return true; } - var stable = indexer.TryValidateScanInputSnapshot( + return indexer.TryValidateScanInputSnapshot( csharpWorkspaceInputSnapshot, - out var changedInputPath, - cancellationToken); - changedPath = changedInputPath; - return stable; - } - - if (csharpWorkspaceInputSnapshot != null) - { - UpdateScanInputSnapshotBarrierForTesting?.Invoke("before_write"); - if (!TryValidateCSharpWorkspaceInputSnapshot(out var changedInputPath)) - return WriteUpdateSnapshotFailure( - changedInputPath ?? projectRoot, - new UpdateSnapshotFailureContext - { - Writer = writer, - Options = options, - Stopwatch = stopwatch, - JsonContext = jsonContext, - ProjectRoot = projectRoot, - PriorReadiness = priorReadiness, - CSharpSymbolNameContractMatchesCurrent = csharpSymbolNameContractMatchesCurrent, - PriorMetadataTargetCsharpMatchesCurrent = priorMetadataTargetCsharpMatchesCurrent, - PriorFoldVersion = priorFoldVersion, - PriorFoldFingerprint = priorFoldFingerprint, - CurrentFoldVersion = currentFoldVersion, - CurrentFoldFingerprint = currentFoldFingerprint, - MemorySamples = memorySamples, - Skipped = skipped, - Warnings = warnings, - SymbolsDroppedByKindFilter = symbolsDroppedByKindFilter, - ErrorList = errorList, - FileErrorList = fileErrorList, - WarningList = warningList, - RecordCSharpWorkspaceDrift = RecordCSharpWorkspaceDrift, - GetErrorCount = () => errors, - }); - } - - string? changedCSharpTargetPath = null; - var stableCSharpWorkspaceBeforeMutation = csharpWorkspaceSnapshots == null - || TryValidateCurrentCSharpTargetSet( - projectRoot, - targetPaths, - scannedUpdateLanguages, - csharpWorkspaceSnapshots, - out changedCSharpTargetPath, - cancellationToken); - if (!deferCSharpMutationsForIncompleteWorkspace - && !stableCSharpWorkspaceBeforeMutation) - { - deferCSharpMutationsForIncompleteWorkspace = true; - RecordCSharpWorkspaceDrift( - changedCSharpTargetPath ?? "", - "The C# workspace target set changed after contract preflight."); - csharpSourceEvidenceForStamp = null; - csharpSourceEvidenceCompleteForStamp = false; - csharpWorkspaceSnapshots = null; - csharpWorkspace = csharpWorkspace with - { - HasStaticInterfaceContracts = true, - SourceContractEvidenceComplete = false, - }; - DeferCSharpTargetsAfterIncompleteWorkspace( - writer, - projectRoot, - targetPaths, + out changedPath, cancellationToken); } - // The workspace lookup was built with these immutable IDs excluded. Apply exactly - // that snapshot only after complete C# discovery/preflight; fatal scans leave the - // old rows and references untouched for the retry. - // workspace lookup から除外した immutable ID snapshot は complete な C# discovery - // 後だけ適用し、fatal scan 時は旧 row/ref を retry まで保持する。 - if (!deferCSharpMutationsForIncompleteWorkspace && scopedCleanupPlan.Count > 0) - { - UpdateScanInputSnapshotBarrierForTesting?.Invoke("before_cleanup_apply"); - Dictionary>? - retainedFileIdentitiesByCaseFold = null; - var retainedPathsExact = new HashSet(StringComparer.Ordinal); - foreach (var retainedTargetPath in targetPaths) - { - cancellationToken.ThrowIfCancellationRequested(); - var retainedTarget = UpdateFileTarget.Create(projectRoot, retainedTargetPath); - retainedPathsExact.Add(retainedTarget.IndexPath); - var ioPath = LongPath.EnsureWindowsPrefix(retainedTarget.FilePath); - if (!File.Exists(ioPath)) - continue; - - if (FileIndexer.TryGetFileIdentity(ioPath, out var retainedIdentity)) - { - retainedFileIdentitiesByCaseFold ??= new Dictionary< - string, - HashSet>(StringComparer.OrdinalIgnoreCase); - if (!retainedFileIdentitiesByCaseFold.TryGetValue( - retainedTarget.IndexPath, - out var retainedIdentities)) - { - retainedIdentities = []; - retainedFileIdentitiesByCaseFold.Add( - retainedTarget.IndexPath, - retainedIdentities); - } - - retainedIdentities.Add(retainedIdentity); - } - } - - var reappearedCleanupPath = writer.FindReappearedFileInScopedCleanupPlan( - projectRoot, - scopedCleanupPlan.FileIds, - retainedPathsExact, - retainedFileIdentitiesByCaseFold, - cancellationToken); - if (reappearedCleanupPath != null) - { - deferCSharpMutationsForIncompleteWorkspace = true; - RecordCSharpWorkspaceDrift( - reappearedCleanupPath, - "A cleanup-planned path reappeared after C# workspace discovery."); - csharpSourceEvidenceForStamp = null; - csharpSourceEvidenceCompleteForStamp = false; - csharpWorkspaceSnapshots = null; - csharpWorkspace = csharpWorkspace with - { - HasStaticInterfaceContracts = true, - SourceContractEvidenceComplete = false, - }; - DeferCSharpTargetsAfterIncompleteWorkspace( - writer, - projectRoot, - targetPaths, - cancellationToken); - } - } + // The workspace lookup was built with immutable cleanup IDs excluded. + // The guard above applies target and path authority checks before mutation. + // workspace lookup から除外した immutable cleanup ID は、上のguardで + // target/path authorityを検証してから適用する。 // Expanded discovery has crossed its sole pre-write snapshot barrier. Start graph // tracking only now, then publish conservative C# evidence immediately before the @@ -641,7 +583,8 @@ bool TryValidateCSharpWorkspaceInputSnapshot(out string? changedPath) : null); } - if (!deferCSharpMutationsForIncompleteWorkspace && scopedCleanupPlan.Count > 0) + if (!deferCSharpMutationsForIncompleteWorkspace + && scopedCleanupPlan.Count > 0) { using var cleanupTxn = writer.BeginTransaction( cancellationToken, From 73d08d0bd8923954f351db8a6e7e3eccb6c528a6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 01:41:36 +0900 Subject: [PATCH 097/101] Separate full scan target selection --- ...xCommandRunner.FullScan.TargetSelection.cs | 400 ++++++++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 241 +++-------- 2 files changed, 460 insertions(+), 181 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.TargetSelection.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.TargetSelection.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.TargetSelection.cs new file mode 100644 index 000000000..5dd5bbabe --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.TargetSelection.cs @@ -0,0 +1,400 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class FullScanTargetSelectionContext + { + internal required DbWriter Writer { get; init; } + internal required FileIndexer Indexer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required string ProjectRoot { get; init; } + internal required FullScanFileTarget[] FileTargets { get; init; } + internal required IReadOnlyList + CSharpPrepassTargets + { get; init; } + internal required FilePurgePlan StaleFilePurgePlan { get; init; } + internal required bool CanSkipTargetsBeforeContentLoad { get; init; } + internal required bool StartedWithNoIndexedFiles { get; init; } + internal required bool CSharpIndexedProjectRootCompatible + { + get; + init; + } + + internal required int ExtractionParallelism { get; init; } + internal required bool? PriorCSharpStaticInterfaceSourceEvidence + { + get; + init; + } + + internal required CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace + { + get; + init; + } + + internal Dictionary? + CSharpWorkspaceFileSnapshots + { get; init; } + internal required bool ForceFullCSharpRefreshFromInvalidatedNoOp + { + get; + init; + } + + internal required bool PreservePriorPositiveCSharpSourceNoOp + { + get; + init; + } + + internal required bool CSharpSourceEvidenceForStamp { get; init; } + internal required bool CSharpSourceEvidenceComplete { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required Action ThrowIfFullScanCancelled { get; init; } + internal required Func + GetDeferCSharpMutationsForIncompleteScan + { get; init; } + internal required Func + GetFullScanTargetStatMatch + { get; init; } + internal required Action + RecordFullScanTargetStatSkip + { get; init; } + internal required Action + DeferCSharpMutationsForIncompleteWorkspace + { get; init; } + internal required Func + IsExistingCSharpSymbolPathNowNonCSharp + { get; init; } + } + + private sealed class FullScanTargetSelectionState + { + internal List? ExtractionFileIndexes { get; set; } + internal int ExtractionWorkItemCount { get; set; } + internal required CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace + { + get; + set; + } + + internal Dictionary? + CSharpWorkspaceFileSnapshots + { get; set; } + internal bool ForceFullCSharpRefreshFromInvalidatedNoOp { get; set; } + internal bool PreservePriorPositiveCSharpSourceNoOp { get; set; } + internal bool CSharpSourceEvidenceForStamp { get; set; } + internal bool CSharpSourceEvidenceComplete { get; set; } + } + + private sealed record FullScanTargetSelectionResult( + List? ExtractionFileIndexes, + int ExtractionWorkItemCount, + CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace, + Dictionary? + CSharpWorkspaceFileSnapshots, + bool ForceFullCSharpRefreshFromInvalidatedNoOp, + bool PreservePriorPositiveCSharpSourceNoOp, + bool CSharpSourceEvidenceForStamp, + bool CSharpSourceEvidenceComplete); + + private static FullScanTargetSelectionResult + PrepareFullScanExtractionTargets( + FullScanTargetSelectionContext context) + { + context.ThrowIfFullScanCancelled(); + var state = new FullScanTargetSelectionState + { + CSharpWorkspace = context.CSharpWorkspace, + CSharpWorkspaceFileSnapshots = + context.CSharpWorkspaceFileSnapshots, + ForceFullCSharpRefreshFromInvalidatedNoOp = + context.ForceFullCSharpRefreshFromInvalidatedNoOp, + PreservePriorPositiveCSharpSourceNoOp = + context.PreservePriorPositiveCSharpSourceNoOp, + CSharpSourceEvidenceForStamp = + context.CSharpSourceEvidenceForStamp, + CSharpSourceEvidenceComplete = + context.CSharpSourceEvidenceComplete, + }; + + if (context.CanSkipTargetsBeforeContentLoad) + { + SelectReusableFullScanTargets(context, state); + } + else if (context.GetDeferCSharpMutationsForIncompleteScan()) + { + SelectFullScanTargetsWithDeferredCSharp(context, state); + } + else + { + state.ExtractionWorkItemCount = context.FileTargets.Length; + } + + return new FullScanTargetSelectionResult( + state.ExtractionFileIndexes, + state.ExtractionWorkItemCount, + state.CSharpWorkspace, + state.CSharpWorkspaceFileSnapshots, + state.ForceFullCSharpRefreshFromInvalidatedNoOp, + state.PreservePriorPositiveCSharpSourceNoOp, + state.CSharpSourceEvidenceForStamp, + state.CSharpSourceEvidenceComplete); + } + + private static void SelectReusableFullScanTargets( + FullScanTargetSelectionContext context, + FullScanTargetSelectionState state) + { + var fileTargets = context.FileTargets; + var statPreflightMatched = new bool[fileTargets.Length]; + var csharpNoOpHasInterveningWork = + context.StaleFilePurgePlan.Count > 0; + for (var fileIndex = 0; fileIndex < fileTargets.Length; fileIndex++) + { + context.ThrowIfFullScanCancelled(); + if (context.GetDeferCSharpMutationsForIncompleteScan() + && fileTargets[fileIndex].Language == "csharp") + { + continue; + } + + statPreflightMatched[fileIndex] = + context.GetFullScanTargetStatMatch( + fileIndex, + true) != null; + if (!statPreflightMatched[fileIndex]) + csharpNoOpHasInterveningWork = true; + } + + var revalidatedMatches = + new IndexedFileStatReuseResult?[fileTargets.Length]; + RevalidateNonCSharpFullScanTargets( + context, + statPreflightMatched, + revalidatedMatches, + ref csharpNoOpHasInterveningWork); + var preservedCSharpNoOpInvalidated = + RevalidateCSharpFullScanTargets( + context, + state, + statPreflightMatched, + revalidatedMatches, + csharpNoOpHasInterveningWork); + if (preservedCSharpNoOpInvalidated) + { + RebuildInvalidatedFullScanCSharpNoOp( + context, + state, + revalidatedMatches); + } + + state.ExtractionFileIndexes = + new List(fileTargets.Length); + for (var fileIndex = 0; fileIndex < fileTargets.Length; fileIndex++) + { + context.ThrowIfFullScanCancelled(); + if (context.GetDeferCSharpMutationsForIncompleteScan() + && fileTargets[fileIndex].Language == "csharp") + { + RecordDeferredFullScanCSharpTarget(context, fileIndex); + continue; + } + + var revalidated = revalidatedMatches[fileIndex]; + if (revalidated != null) + { + context.RecordFullScanTargetStatSkip( + fileIndex, + revalidated.Value); + } + else + { + state.ExtractionFileIndexes.Add(fileIndex); + } + } + + state.ExtractionWorkItemCount = + state.ExtractionFileIndexes.Count; + } + + private static void RevalidateNonCSharpFullScanTargets( + FullScanTargetSelectionContext context, + IReadOnlyList statPreflightMatched, + IList revalidatedMatches, + ref bool csharpNoOpHasInterveningWork) + { + for (var fileIndex = 0; + fileIndex < context.FileTargets.Length; + fileIndex++) + { + context.ThrowIfFullScanCancelled(); + if (context.FileTargets[fileIndex].Language == "csharp") + continue; + + var revalidated = statPreflightMatched[fileIndex] + ? context.GetFullScanTargetStatMatch(fileIndex, false) + : null; + revalidatedMatches[fileIndex] = revalidated; + if (revalidated == null) + csharpNoOpHasInterveningWork = true; + } + } + + private static bool RevalidateCSharpFullScanTargets( + FullScanTargetSelectionContext context, + FullScanTargetSelectionState state, + IReadOnlyList statPreflightMatched, + IList revalidatedMatches, + bool csharpNoOpHasInterveningWork) + { + var preservedCSharpNoOpInvalidated = false; + for (var fileIndex = 0; + fileIndex < context.FileTargets.Length; + fileIndex++) + { + context.ThrowIfFullScanCancelled(); + if (context.FileTargets[fileIndex].Language != "csharp" + || context.GetDeferCSharpMutationsForIncompleteScan()) + { + continue; + } + + var revalidated = statPreflightMatched[fileIndex] + ? context.GetFullScanTargetStatMatch( + fileIndex, + state.PreservePriorPositiveCSharpSourceNoOp + && !csharpNoOpHasInterveningWork) + : null; + revalidatedMatches[fileIndex] = revalidated; + if (state.PreservePriorPositiveCSharpSourceNoOp + && revalidated == null) + { + preservedCSharpNoOpInvalidated = true; + } + } + + return preservedCSharpNoOpInvalidated; + } + + private static void RebuildInvalidatedFullScanCSharpNoOp( + FullScanTargetSelectionContext context, + FullScanTargetSelectionState state, + IList revalidatedMatches) + { + state.CSharpWorkspace = BuildStableFullScanCSharpWorkspace( + context.ProjectRoot, + context.CSharpPrepassTargets, + out var workspaceFileSnapshots, + () => CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( + context.Writer, + context.Indexer, + context.CSharpPrepassTargets, + includeExistingSymbols: + context.CSharpIndexedProjectRootCompatible + && !context.Options.Rebuild + && !context.StartedWithNoIndexedFiles, + canReuseExistingSymbolsWithoutRead: null, + parallelism: context.ExtractionParallelism, + excludedExistingFileIds: + context.StaleFilePurgePlan.FileIds, + isExistingSymbolPathExcluded: + context.IsExistingCSharpSymbolPathNowNonCSharp, + cancellationToken: context.CancellationToken), + context.CancellationToken); + state.CSharpWorkspaceFileSnapshots = workspaceFileSnapshots; + state.PreservePriorPositiveCSharpSourceNoOp = false; + if (!state.CSharpWorkspace.SourceContractEvidenceComplete) + { + var incompleteSourcePaths = + state.CSharpWorkspace.IncompleteSourcePaths; + context.DeferCSharpMutationsForIncompleteWorkspace( + state.CSharpWorkspace); + state.CSharpSourceEvidenceForStamp = false; + state.CSharpSourceEvidenceComplete = false; + state.CSharpWorkspace = + new CSharpStaticInterfaceWorkspaceSymbols( + [], + false, + SourceContractEvidenceComplete: false, + IncompleteSourcePaths: incompleteSourcePaths); + return; + } + + var requiresFullCSharpRefresh = + context.PriorCSharpStaticInterfaceSourceEvidence == true + || state.CSharpWorkspace.HasStaticInterfaceContracts; + state.ForceFullCSharpRefreshFromInvalidatedNoOp = + requiresFullCSharpRefresh; + state.CSharpSourceEvidenceForStamp = + state.CSharpWorkspace.HasSourceStaticInterfaceContracts; + state.CSharpSourceEvidenceComplete = true; + if (!requiresFullCSharpRefresh) + return; + + state.CSharpWorkspace = state.CSharpWorkspace with + { + HasStaticInterfaceContracts = true, + }; + for (var fileIndex = 0; + fileIndex < context.FileTargets.Length; + fileIndex++) + { + if (context.FileTargets[fileIndex].Language == "csharp") + revalidatedMatches[fileIndex] = null; + } + } + + private static void SelectFullScanTargetsWithDeferredCSharp( + FullScanTargetSelectionContext context, + FullScanTargetSelectionState state) + { + state.ExtractionFileIndexes = + new List(context.FileTargets.Length); + for (var fileIndex = 0; + fileIndex < context.FileTargets.Length; + fileIndex++) + { + if (context.FileTargets[fileIndex].Language != "csharp") + { + state.ExtractionFileIndexes.Add(fileIndex); + continue; + } + + RecordDeferredFullScanCSharpTarget(context, fileIndex); + } + + state.ExtractionWorkItemCount = + state.ExtractionFileIndexes.Count; + } + + private static void RecordDeferredFullScanCSharpTarget( + FullScanTargetSelectionContext context, + int fileIndex) + { + long currentSize = 0; + try + { + var info = new FileInfo( + context.FileTargets[fileIndex].FilePath); + if (info.Exists && info.Length >= 0) + currentSize = info.Length; + } + catch (Exception ex) when ( + ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + } + + context.RecordFullScanTargetStatSkip( + fileIndex, + new IndexedFileStatReuseResult(0, currentSize)); + } +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index be6874c66..492d06816 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -568,188 +568,67 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis CommandOutputWriter.WriteLine($" [SKIP] {target.IndexPath} (unchanged)"); } - ThrowIfFullScanCancelled(processed, files.Count); - List? extractionFileIndexes = null; - int extractionWorkItemCount; - if (canSkipFullScanTargetsBeforeContentLoad) - { - var statPreflightMatched = new bool[fileTargets.Length]; - var csharpNoOpHasInterveningWork = staleFilePurgePlan.Count > 0; - for (var fileIndex = 0; fileIndex < fileTargets.Length; fileIndex++) - { - ThrowIfFullScanCancelled(processed, files.Count); - if (deferCSharpMutationsForIncompleteScan - && fileTargets[fileIndex].Language == "csharp") - { - continue; - } - statPreflightMatched[fileIndex] = GetFullScanTargetStatMatch( - fileIndex, - allowCSharpPrepassCache: true) != null; - if (!statPreflightMatched[fileIndex]) - csharpNoOpHasInterveningWork = true; - } - - var revalidatedMatches = new IndexedFileStatReuseResult?[fileTargets.Length]; - var preservedCSharpNoOpInvalidated = false; - // Revalidate non-C# targets first. If the whole run is still a pure stat no-op, - // retain the C# candidate cache until the final readiness-boundary stat instead - // of issuing two back-to-back full C# stat passes. - // non-C#を先に再確認し、純粋no-opならC#はreadiness直前の最終statへ統合する。 - for (var fileIndex = 0; fileIndex < fileTargets.Length; fileIndex++) - { - ThrowIfFullScanCancelled(processed, files.Count); - if (fileTargets[fileIndex].Language == "csharp") - continue; - - var revalidated = statPreflightMatched[fileIndex] - ? GetFullScanTargetStatMatch(fileIndex, allowCSharpPrepassCache: false) - : null; - revalidatedMatches[fileIndex] = revalidated; - if (revalidated == null) - csharpNoOpHasInterveningWork = true; - } - - for (var fileIndex = 0; fileIndex < fileTargets.Length; fileIndex++) - { - ThrowIfFullScanCancelled(processed, files.Count); - if (fileTargets[fileIndex].Language != "csharp" - || deferCSharpMutationsForIncompleteScan) - { - continue; - } - - var revalidated = statPreflightMatched[fileIndex] - ? GetFullScanTargetStatMatch( - fileIndex, - allowCSharpPrepassCache: preservePriorPositiveCSharpSourceNoOp - && !csharpNoOpHasInterveningWork) - : null; - revalidatedMatches[fileIndex] = revalidated; - if (preservePriorPositiveCSharpSourceNoOp && revalidated == null) - preservedCSharpNoOpInvalidated = true; - } - - if (preservedCSharpNoOpInvalidated) - { - // The final target-level stat pass is the last boundary before file writes. - // If it invalidates the empty-workspace shortcut, rebuild raw C# evidence and - // make every C# target dirty before any stale row can be retained or rewritten. - // 最終target statでno-opが崩れた場合、write前に全C# raw prepassへ戻す。 - csharpWorkspace = BuildStableFullScanCSharpWorkspace( - projectRoot, - csharpPrepassTargets, - out csharpWorkspaceFileSnapshots, - () => - CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( - writer, - indexer, - csharpPrepassTargets, - includeExistingSymbols: csharpIndexedProjectRootCompatible && !options.Rebuild && !startedWithNoIndexedFiles, - canReuseExistingSymbolsWithoutRead: null, - parallelism: extractionParallelism, - excludedExistingFileIds: staleFilePurgePlan.FileIds, - isExistingSymbolPathExcluded: IsExistingCSharpSymbolPathNowNonCSharp, - cancellationToken: cancellationToken), - cancellationToken); - preservePriorPositiveCSharpSourceNoOp = false; - if (!csharpWorkspace.SourceContractEvidenceComplete) - { - var incompleteSourcePaths = csharpWorkspace.IncompleteSourcePaths; - DeferCSharpMutationsForIncompleteWorkspace(csharpWorkspace); - csharpSourceEvidenceForStamp = false; - csharpSourceEvidenceComplete = false; - csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols( - [], - false, - SourceContractEvidenceComplete: false, - IncompleteSourcePaths: incompleteSourcePaths); - } - else - { - var requiresFullCSharpRefresh = - priorCSharpStaticInterfaceSourceEvidence == true - || csharpWorkspace.HasStaticInterfaceContracts; - forceFullCSharpRefreshFromInvalidatedNoOp = requiresFullCSharpRefresh; - csharpSourceEvidenceForStamp = csharpWorkspace.HasSourceStaticInterfaceContracts; - csharpSourceEvidenceComplete = true; - if (requiresFullCSharpRefresh) - { - csharpWorkspace = csharpWorkspace with { HasStaticInterfaceContracts = true }; - for (var fileIndex = 0; fileIndex < fileTargets.Length; fileIndex++) - { - if (fileTargets[fileIndex].Language == "csharp") - revalidatedMatches[fileIndex] = null; - } - } - } - } - - extractionFileIndexes = new List(fileTargets.Length); - for (var fileIndex = 0; fileIndex < fileTargets.Length; fileIndex++) - { - ThrowIfFullScanCancelled(processed, files.Count); - if (deferCSharpMutationsForIncompleteScan - && fileTargets[fileIndex].Language == "csharp") - { - long currentSize = 0; - try - { - var info = new FileInfo(fileTargets[fileIndex].FilePath); - if (info.Exists && info.Length >= 0) - currentSize = info.Length; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) - { - } - RecordFullScanTargetStatSkip( - fileIndex, - new IndexedFileStatReuseResult(0, currentSize)); - continue; - } - - var revalidated = revalidatedMatches[fileIndex]; - if (revalidated != null) - RecordFullScanTargetStatSkip(fileIndex, revalidated.Value); - else - extractionFileIndexes.Add(fileIndex); - } - extractionWorkItemCount = extractionFileIndexes.Count; - } - else - { - if (deferCSharpMutationsForIncompleteScan) - { - extractionFileIndexes = new List(fileTargets.Length); - for (var fileIndex = 0; fileIndex < fileTargets.Length; fileIndex++) - { - if (fileTargets[fileIndex].Language != "csharp") - { - extractionFileIndexes.Add(fileIndex); - continue; - } - - long currentSize = 0; - try - { - var info = new FileInfo(fileTargets[fileIndex].FilePath); - if (info.Exists && info.Length >= 0) - currentSize = info.Length; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) - { - } - RecordFullScanTargetStatSkip( - fileIndex, - new IndexedFileStatReuseResult(0, currentSize)); - } - extractionWorkItemCount = extractionFileIndexes.Count; - } - else + var targetSelection = PrepareFullScanExtractionTargets( + new FullScanTargetSelectionContext { - extractionWorkItemCount = fileTargets.Length; - } - } + Writer = writer, + Indexer = indexer, + Options = options, + ProjectRoot = projectRoot, + FileTargets = fileTargets, + CSharpPrepassTargets = csharpPrepassTargets, + StaleFilePurgePlan = staleFilePurgePlan, + CanSkipTargetsBeforeContentLoad = + canSkipFullScanTargetsBeforeContentLoad, + StartedWithNoIndexedFiles = startedWithNoIndexedFiles, + CSharpIndexedProjectRootCompatible = + csharpIndexedProjectRootCompatible, + ExtractionParallelism = extractionParallelism, + PriorCSharpStaticInterfaceSourceEvidence = + priorCSharpStaticInterfaceSourceEvidence, + CSharpWorkspace = csharpWorkspace, + CSharpWorkspaceFileSnapshots = + csharpWorkspaceFileSnapshots, + ForceFullCSharpRefreshFromInvalidatedNoOp = + forceFullCSharpRefreshFromInvalidatedNoOp, + PreservePriorPositiveCSharpSourceNoOp = + preservePriorPositiveCSharpSourceNoOp, + CSharpSourceEvidenceForStamp = + csharpSourceEvidenceForStamp, + CSharpSourceEvidenceComplete = + csharpSourceEvidenceComplete, + CancellationToken = cancellationToken, + ThrowIfFullScanCancelled = + () => ThrowIfFullScanCancelled( + processed, + files.Count), + GetDeferCSharpMutationsForIncompleteScan = + () => deferCSharpMutationsForIncompleteScan, + GetFullScanTargetStatMatch = + GetFullScanTargetStatMatch, + RecordFullScanTargetStatSkip = + RecordFullScanTargetStatSkip, + DeferCSharpMutationsForIncompleteWorkspace = + DeferCSharpMutationsForIncompleteWorkspace, + IsExistingCSharpSymbolPathNowNonCSharp = + IsExistingCSharpSymbolPathNowNonCSharp, + }); + var extractionFileIndexes = + targetSelection.ExtractionFileIndexes; + var extractionWorkItemCount = + targetSelection.ExtractionWorkItemCount; + csharpWorkspace = targetSelection.CSharpWorkspace; + csharpWorkspaceFileSnapshots = + targetSelection.CSharpWorkspaceFileSnapshots; + forceFullCSharpRefreshFromInvalidatedNoOp = + targetSelection + .ForceFullCSharpRefreshFromInvalidatedNoOp; + preservePriorPositiveCSharpSourceNoOp = + targetSelection.PreservePriorPositiveCSharpSourceNoOp; + csharpSourceEvidenceForStamp = + targetSelection.CSharpSourceEvidenceForStamp; + csharpSourceEvidenceComplete = + targetSelection.CSharpSourceEvidenceComplete; var useFtsBulkLoad = ShouldUseFullScanFtsBulkLoad( options.Rebuild, From d69af87872cc9ec13a6c3b78ff0430f45a78e43f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 01:46:37 +0900 Subject: [PATCH 098/101] Separate final full scan C# revalidation --- ...Runner.FullScan.CSharpFinalRevalidation.cs | 250 ++++++++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 165 +++++------- 2 files changed, 315 insertions(+), 100 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.CSharpFinalRevalidation.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.CSharpFinalRevalidation.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.CSharpFinalRevalidation.cs new file mode 100644 index 000000000..6739dddac --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.CSharpFinalRevalidation.cs @@ -0,0 +1,250 @@ +using CodeIndex.Database; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class FullScanCSharpFinalRevalidationContext + { + internal required DbWriter Writer { get; init; } + internal required FileIndexer Indexer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required string ProjectRoot { get; init; } + internal required FullScanFileTarget[] FileTargets { get; init; } + internal required IReadOnlyList + CSharpPrepassTargets + { get; init; } + internal required FilePurgePlan StaleFilePurgePlan { get; init; } + internal required bool StartedWithNoIndexedFiles { get; init; } + internal required bool CSharpIndexedProjectRootCompatible + { + get; + init; + } + + internal required int ExtractionParallelism { get; init; } + internal required bool? PriorCSharpStaticInterfaceSourceEvidence + { + get; + init; + } + + internal required ReusableIndexedFileStatsSnapshot + ReusableIndexedFileStats + { get; init; } + internal List? ExtractionFileIndexes { get; init; } + internal required int ExtractionWorkItemCount { get; init; } + internal required bool UseFtsBulkLoad { get; init; } + internal required CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace + { + get; + init; + } + + internal Dictionary? + CSharpWorkspaceFileSnapshots + { get; init; } + internal required bool ForceFullCSharpRefreshFromInvalidatedNoOp + { + get; + init; + } + + internal required bool PreservePriorPositiveCSharpSourceNoOp + { + get; + init; + } + + internal required bool CSharpSourceEvidenceForStamp { get; init; } + internal required bool CSharpSourceEvidenceComplete { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required Action + DeferCSharpMutationsForIncompleteWorkspace + { get; init; } + internal required Func + IsExistingCSharpSymbolPathNowNonCSharp + { get; init; } + } + + private sealed record FullScanCSharpFinalRevalidationResult( + List? ExtractionFileIndexes, + int ExtractionWorkItemCount, + bool UseFtsBulkLoad, + CSharpStaticInterfaceWorkspaceSymbols CSharpWorkspace, + Dictionary? + CSharpWorkspaceFileSnapshots, + bool ForceFullCSharpRefreshFromInvalidatedNoOp, + bool PreservePriorPositiveCSharpSourceNoOp, + bool CSharpSourceEvidenceForStamp, + bool CSharpSourceEvidenceComplete, + int PromotedCSharpTargetCount, + bool PromotedAllCSharpTargets); + + private static FullScanCSharpFinalRevalidationResult + RevalidateFinalFullScanCSharpNoOp( + FullScanCSharpFinalRevalidationContext context) + { + if (!context.PreservePriorPositiveCSharpSourceNoOp + || (context.ExtractionWorkItemCount == 0 + && context.StaleFilePurgePlan.Count == 0)) + { + return BuildUnchangedFinalCSharpRevalidationResult(context); + } + + // The dirty-byte pass can be long on a mixed-language monorepo. Revalidate C# + // once more at the final read-only boundary, then undo tentative stat skips + // and promote every affected C# target if any source changed. + // mixed-language dirty-byte pass後の最終read-only境界でC#を再statする。 + FullScanCSharpFinalStatRevalidationForTesting?.Invoke(); + var invalidatedCSharpFileIndexes = FindInvalidatedFinalCSharpTargets( + context); + if (invalidatedCSharpFileIndexes.Count == 0) + return BuildUnchangedFinalCSharpRevalidationResult(context); + + return RebuildFinalFullScanCSharpWorkspace( + context, + invalidatedCSharpFileIndexes); + } + + private static List FindInvalidatedFinalCSharpTargets( + FullScanCSharpFinalRevalidationContext context) + { + var invalidatedCSharpFileIndexes = new List(); + for (var fileIndex = 0; + fileIndex < context.FileTargets.Length; + fileIndex++) + { + var target = context.FileTargets[fileIndex]; + if (target.Language != "csharp") + continue; + + context.CancellationToken.ThrowIfCancellationRequested(); + if (IndexedFileStatReuse.TryGetReusableUnchangedFile( + context.ReusableIndexedFileStats, + target.FilePath, + target.IndexPath, + target.Language, + target.GeneratedExtractionSuppressed) == null) + { + invalidatedCSharpFileIndexes.Add(fileIndex); + } + } + + return invalidatedCSharpFileIndexes; + } + + private static FullScanCSharpFinalRevalidationResult + RebuildFinalFullScanCSharpWorkspace( + FullScanCSharpFinalRevalidationContext context, + IReadOnlyList invalidatedCSharpFileIndexes) + { + var workspace = BuildStableFullScanCSharpWorkspace( + context.ProjectRoot, + context.CSharpPrepassTargets, + out var workspaceFileSnapshots, + () => CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( + context.Writer, + context.Indexer, + context.CSharpPrepassTargets, + includeExistingSymbols: + context.CSharpIndexedProjectRootCompatible + && !context.Options.Rebuild + && !context.StartedWithNoIndexedFiles, + canReuseExistingSymbolsWithoutRead: null, + parallelism: context.ExtractionParallelism, + excludedExistingFileIds: + context.StaleFilePurgePlan.FileIds, + isExistingSymbolPathExcluded: + context.IsExistingCSharpSymbolPathNowNonCSharp, + cancellationToken: context.CancellationToken), + context.CancellationToken); + if (!workspace.SourceContractEvidenceComplete) + { + var incompleteSourcePaths = workspace.IncompleteSourcePaths; + context.DeferCSharpMutationsForIncompleteWorkspace(workspace); + return new FullScanCSharpFinalRevalidationResult( + context.ExtractionFileIndexes, + context.ExtractionWorkItemCount, + UseFtsBulkLoad: false, + new CSharpStaticInterfaceWorkspaceSymbols( + [], + false, + SourceContractEvidenceComplete: false, + IncompleteSourcePaths: incompleteSourcePaths), + workspaceFileSnapshots, + context.ForceFullCSharpRefreshFromInvalidatedNoOp, + PreservePriorPositiveCSharpSourceNoOp: false, + CSharpSourceEvidenceForStamp: false, + CSharpSourceEvidenceComplete: false, + PromotedCSharpTargetCount: 0, + PromotedAllCSharpTargets: false); + } + + var requiresFullCSharpRefresh = + context.PriorCSharpStaticInterfaceSourceEvidence == true + || workspace.HasStaticInterfaceContracts; + IReadOnlyList csharpFileIndexesToRefresh; + if (requiresFullCSharpRefresh) + { + workspace = workspace with + { + HasStaticInterfaceContracts = true, + }; + var allCSharpFileIndexes = + new List(context.CSharpPrepassTargets.Count); + for (var fileIndex = 0; + fileIndex < context.FileTargets.Length; + fileIndex++) + { + if (context.FileTargets[fileIndex].Language == "csharp") + allCSharpFileIndexes.Add(fileIndex); + } + + csharpFileIndexesToRefresh = allCSharpFileIndexes; + } + else + { + // A previously authoritative negative workspace only needs the + // stat-invalidated files when the raw fallback is still negative. + // prior negative のraw fallbackもnegativeなら変更fileだけを更新する。 + csharpFileIndexesToRefresh = invalidatedCSharpFileIndexes; + } + + var extractionFileIndexes = context.ExtractionFileIndexes + ?? new List(csharpFileIndexesToRefresh.Count); + foreach (var fileIndex in csharpFileIndexesToRefresh) + extractionFileIndexes.Add(fileIndex); + extractionFileIndexes.Sort(); + return new FullScanCSharpFinalRevalidationResult( + extractionFileIndexes, + extractionFileIndexes.Count, + UseFtsBulkLoad: false, + workspace, + workspaceFileSnapshots, + requiresFullCSharpRefresh, + PreservePriorPositiveCSharpSourceNoOp: false, + workspace.HasSourceStaticInterfaceContracts, + CSharpSourceEvidenceComplete: true, + csharpFileIndexesToRefresh.Count, + csharpFileIndexesToRefresh.Count + == context.CSharpPrepassTargets.Count); + } + + private static FullScanCSharpFinalRevalidationResult + BuildUnchangedFinalCSharpRevalidationResult( + FullScanCSharpFinalRevalidationContext context) + => new( + context.ExtractionFileIndexes, + context.ExtractionWorkItemCount, + context.UseFtsBulkLoad, + context.CSharpWorkspace, + context.CSharpWorkspaceFileSnapshots, + context.ForceFullCSharpRefreshFromInvalidatedNoOp, + context.PreservePriorPositiveCSharpSourceNoOp, + context.CSharpSourceEvidenceForStamp, + context.CSharpSourceEvidenceComplete, + PromotedCSharpTargetCount: 0, + PromotedAllCSharpTargets: false); +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 492d06816..9468b048e 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -642,107 +642,72 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis extractionFileIndexes, () => ThrowIfFullScanCancelled(processed, files.Count)); - if (preservePriorPositiveCSharpSourceNoOp - && (extractionWorkItemCount > 0 || staleFilePurgePlan.Count > 0)) - { - // The dirty-byte pass can be long on a mixed-language monorepo. Revalidate C# - // once more at the final read-only boundary, then undo the tentative stat skips - // and promote every C# target if any source changed. - // mixed-language dirty-byte pass後の最終read-only境界でC#を再statする。 - FullScanCSharpFinalStatRevalidationForTesting?.Invoke(); - var invalidatedCSharpFileIndexes = new List(); - for (var fileIndex = 0; fileIndex < fileTargets.Length; fileIndex++) - { - var target = fileTargets[fileIndex]; - if (target.Language != "csharp") - continue; - - cancellationToken.ThrowIfCancellationRequested(); - if (IndexedFileStatReuse.TryGetReusableUnchangedFile( - reusableIndexedFileStats!, - target.FilePath, - target.IndexPath, - target.Language, - target.GeneratedExtractionSuppressed) == null) - { - invalidatedCSharpFileIndexes.Add(fileIndex); - } - } - - if (invalidatedCSharpFileIndexes.Count > 0) - { - csharpWorkspace = BuildStableFullScanCSharpWorkspace( - projectRoot, - csharpPrepassTargets, - out csharpWorkspaceFileSnapshots, - () => - CSharpStaticInterfacePrepass.BuildWorkspaceSymbols( - writer, - indexer, - csharpPrepassTargets, - includeExistingSymbols: csharpIndexedProjectRootCompatible && !options.Rebuild && !startedWithNoIndexedFiles, - canReuseExistingSymbolsWithoutRead: null, - parallelism: extractionParallelism, - excludedExistingFileIds: staleFilePurgePlan.FileIds, - isExistingSymbolPathExcluded: IsExistingCSharpSymbolPathNowNonCSharp, - cancellationToken: cancellationToken), - cancellationToken); - preservePriorPositiveCSharpSourceNoOp = false; - if (!csharpWorkspace.SourceContractEvidenceComplete) + var finalCSharpRevalidation = + RevalidateFinalFullScanCSharpNoOp( + new FullScanCSharpFinalRevalidationContext { - var incompleteSourcePaths = csharpWorkspace.IncompleteSourcePaths; - DeferCSharpMutationsForIncompleteWorkspace(csharpWorkspace); - csharpSourceEvidenceForStamp = false; - csharpSourceEvidenceComplete = false; - csharpWorkspace = new CSharpStaticInterfaceWorkspaceSymbols( - [], - false, - SourceContractEvidenceComplete: false, - IncompleteSourcePaths: incompleteSourcePaths); - useFtsBulkLoad = false; - } - else - { - var requiresFullCSharpRefresh = - priorCSharpStaticInterfaceSourceEvidence == true - || csharpWorkspace.HasStaticInterfaceContracts; - forceFullCSharpRefreshFromInvalidatedNoOp = requiresFullCSharpRefresh; - csharpSourceEvidenceForStamp = csharpWorkspace.HasSourceStaticInterfaceContracts; - csharpSourceEvidenceComplete = true; - IReadOnlyList csharpFileIndexesToRefresh; - if (requiresFullCSharpRefresh) - { - csharpWorkspace = csharpWorkspace with { HasStaticInterfaceContracts = true }; - var allCSharpFileIndexes = new List(csharpPrepassTargets.Count); - for (var fileIndex = 0; fileIndex < fileTargets.Length; fileIndex++) - { - if (fileTargets[fileIndex].Language == "csharp") - allCSharpFileIndexes.Add(fileIndex); - } - csharpFileIndexesToRefresh = allCSharpFileIndexes; - } - else - { - // A previously authoritative negative workspace only needs the - // stat-invalidated files when the raw fallback is still negative. - // prior negative のraw fallbackもnegativeなら変更fileだけを更新する。 - csharpFileIndexesToRefresh = invalidatedCSharpFileIndexes; - } - - skipped -= csharpFileIndexesToRefresh.Count; - processed -= csharpFileIndexesToRefresh.Count; - if (csharpFileIndexesToRefresh.Count == csharpPrepassTargets.Count) - { - skippedSymbolExtractorLanguages?.Remove("csharp"); - reusedHotspotFamilyLanguages?.Remove("csharp"); - } - foreach (var fileIndex in csharpFileIndexesToRefresh) - extractionFileIndexes!.Add(fileIndex); - extractionFileIndexes!.Sort(); - extractionWorkItemCount = extractionFileIndexes.Count; - useFtsBulkLoad = false; - } - } + Writer = writer, + Indexer = indexer, + Options = options, + ProjectRoot = projectRoot, + FileTargets = fileTargets, + CSharpPrepassTargets = csharpPrepassTargets, + StaleFilePurgePlan = staleFilePurgePlan, + StartedWithNoIndexedFiles = + startedWithNoIndexedFiles, + CSharpIndexedProjectRootCompatible = + csharpIndexedProjectRootCompatible, + ExtractionParallelism = extractionParallelism, + PriorCSharpStaticInterfaceSourceEvidence = + priorCSharpStaticInterfaceSourceEvidence, + ReusableIndexedFileStats = + reusableIndexedFileStats!, + ExtractionFileIndexes = + extractionFileIndexes, + ExtractionWorkItemCount = + extractionWorkItemCount, + UseFtsBulkLoad = useFtsBulkLoad, + CSharpWorkspace = csharpWorkspace, + CSharpWorkspaceFileSnapshots = + csharpWorkspaceFileSnapshots, + ForceFullCSharpRefreshFromInvalidatedNoOp = + forceFullCSharpRefreshFromInvalidatedNoOp, + PreservePriorPositiveCSharpSourceNoOp = + preservePriorPositiveCSharpSourceNoOp, + CSharpSourceEvidenceForStamp = + csharpSourceEvidenceForStamp, + CSharpSourceEvidenceComplete = + csharpSourceEvidenceComplete, + CancellationToken = cancellationToken, + DeferCSharpMutationsForIncompleteWorkspace = + DeferCSharpMutationsForIncompleteWorkspace, + IsExistingCSharpSymbolPathNowNonCSharp = + IsExistingCSharpSymbolPathNowNonCSharp, + }); + extractionFileIndexes = + finalCSharpRevalidation.ExtractionFileIndexes; + extractionWorkItemCount = + finalCSharpRevalidation.ExtractionWorkItemCount; + useFtsBulkLoad = finalCSharpRevalidation.UseFtsBulkLoad; + csharpWorkspace = finalCSharpRevalidation.CSharpWorkspace; + csharpWorkspaceFileSnapshots = + finalCSharpRevalidation.CSharpWorkspaceFileSnapshots; + forceFullCSharpRefreshFromInvalidatedNoOp = + finalCSharpRevalidation + .ForceFullCSharpRefreshFromInvalidatedNoOp; + preservePriorPositiveCSharpSourceNoOp = + finalCSharpRevalidation + .PreservePriorPositiveCSharpSourceNoOp; + csharpSourceEvidenceForStamp = + finalCSharpRevalidation.CSharpSourceEvidenceForStamp; + csharpSourceEvidenceComplete = + finalCSharpRevalidation.CSharpSourceEvidenceComplete; + skipped -= finalCSharpRevalidation.PromotedCSharpTargetCount; + processed -= finalCSharpRevalidation.PromotedCSharpTargetCount; + if (finalCSharpRevalidation.PromotedAllCSharpTargets) + { + skippedSymbolExtractorLanguages?.Remove("csharp"); + reusedHotspotFamilyLanguages?.Remove("csharp"); } if (discovery.InputSnapshot != null) From d3a81535f69903c313ba01d46ad980046ae28c17 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 01:58:43 +0900 Subject: [PATCH 099/101] Separate full scan extraction pipeline --- ...mmandRunner.FullScan.ExtractionPipeline.cs | 344 ++++++++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 283 ++++++-------- 2 files changed, 447 insertions(+), 180 deletions(-) create mode 100644 src/CodeIndex/Cli/IndexCommandRunner.FullScan.ExtractionPipeline.cs diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.ExtractionPipeline.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.ExtractionPipeline.cs new file mode 100644 index 000000000..af51c364b --- /dev/null +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.ExtractionPipeline.cs @@ -0,0 +1,344 @@ +using System.Collections.Concurrent; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; +using CodeIndex.Indexer.Hooks; +using CodeIndex.Models; + +namespace CodeIndex.Cli; + +public static partial class IndexCommandRunner +{ + private sealed class FullScanExtractionPipelineContext + { + internal required DbWriter Writer { get; init; } + internal required FileIndexer Indexer { get; init; } + internal required IndexCommandOptions Options { get; init; } + internal required string ProjectRoot { get; init; } + internal required FullScanFileTarget[] FileTargets { get; init; } + internal List? ExtractionFileIndexes { get; init; } + internal required int ExtractionWorkItemCount { get; init; } + internal required int ExtractionParallelism { get; init; } + internal required int FilesCount { get; init; } + internal required bool ForceExtractorRefresh { get; init; } + internal required bool StartedWithNoIndexedFiles { get; init; } + internal required bool PriorSymbolsOnlyGraphOmitted { get; init; } + internal required bool SymbolKindFilterMatchesPrior { get; init; } + internal required bool CSharpIndexedProjectRootCompatible + { + get; + init; + } + + internal required bool CSharpSymbolNameContractMatchesCurrent + { + get; + init; + } + + internal required bool SqlGraphContractMatchesCurrent { get; init; } + internal required bool HdlGraphContractMatchesCurrent { get; init; } + internal required ReadableFileByteTracker ReadableFileBytes + { + get; + init; + } + + internal required IndexProgressReporter IndexProgress { get; init; } + internal required FullScanProgressSession FullScanProgress { get; init; } + internal required CancellationToken CancellationToken { get; init; } + internal required Func GetProcessedCount { get; init; } + internal required Action PublishProcessedCount { get; init; } + internal required Action ThrowIfFullScanCancelled + { + get; + init; + } + + internal required Action SetIndexProgressVisible { get; init; } + internal required Action + SetActiveExtractionPhases + { get; init; } + internal required Action SetCurrentJsonIndexFile { get; init; } + internal required Func GetCurrentJsonIndexFile { get; init; } + internal required Func + GetDeferCSharpMutationsForIncompleteScan + { get; init; } + internal required Func GetFtsMutated { get; init; } + internal required Func + GetCSharpWorkspace + { get; init; } + internal required Func?> + GetCSharpWorkspaceFileSnapshots + { get; init; } + internal required Action + DeferCSharpMutationsForLoadedSnapshotDrift + { get; init; } + internal required Func + TargetRequiresJavaScriptTypeScriptRefresh + { get; init; } + internal required Func + AllowReuseWithCurrentHotspotFamilyTrust + { get; init; } + internal required Action RequireTypeScriptAugmentationRefresh + { + get; + init; + } + + internal required Action WriteProjectRootOnce { get; init; } + internal required Action> + InsertIssuesForIndexedFile + { get; init; } + internal required Action CountFreshInsertedRows + { + get; + init; + } + + internal required FullScanExtractionConsumerState ConsumerState + { + get; + init; + } + } + + private sealed record FullScanExtractionPipelineResult( + PostExtractionHookRunner? PostExtractionHooks, + FullScanExtractionConsumerState? ConsumerState); + + private readonly record struct FullScanExtractionScheduling( + bool Parallelize, + string? Reason); + + private static FullScanExtractionPipelineResult + RunFullScanExtractionPipeline( + FullScanExtractionPipelineContext context) + { + if (context.ExtractionWorkItemCount == 0) + { + FullScanExtractionSchedulingForTesting?.Invoke(false, null); + return new FullScanExtractionPipelineResult(null, null); + } + + var postExtractionHooks = PostExtractionHookRunner.DiscoverDefault( + context.Options.MaxFileSizeBytes, + maxSymbolCount: context.Options.MaxSymbolsPerFile + 1, + maxReferenceCount: context.Options.MaxReferencesPerFile + 1); + var scheduling = ResolveFullScanExtractionScheduling( + context, + postExtractionHooks); + FullScanExtractionSchedulingForTesting?.Invoke( + scheduling.Parallelize, + scheduling.Reason); + context.FullScanProgress.EnsureIndexingActivityVisible(); + context.FullScanProgress.StartJsonHeartbeatIfNeeded(); + try + { + var consumerState = ExecuteFullScanExtractionPipeline( + context, + postExtractionHooks, + scheduling.Parallelize); + return new FullScanExtractionPipelineResult( + postExtractionHooks, + consumerState); + } + finally + { + context.SetCurrentJsonIndexFile(null); + context.FullScanProgress.StopJsonHeartbeat(); + postExtractionHooks.Dispose(); + } + } + + private static FullScanExtractionScheduling + ResolveFullScanExtractionScheduling( + FullScanExtractionPipelineContext context, + PostExtractionHookRunner postExtractionHooks) + { + var parallelize = !context.Options.SymbolKindFilter.IsActive + && postExtractionHooks.Hooks.Count == 0; + var reason = !parallelize + ? null + : context.Options.Rebuild + ? "rebuild" + : context.StartedWithNoIndexedFiles + ? "empty_index" + : "incremental_changes"; + return new FullScanExtractionScheduling(parallelize, reason); + } + + private static FullScanExtractionConsumerState + ExecuteFullScanExtractionPipeline( + FullScanExtractionPipelineContext context, + PostExtractionHookRunner postExtractionHooks, + bool parallelizeExtraction) + { + PrepareFullScanExtractionProgress(context); + FullScanExtractionWorkStartedForTesting?.Invoke(); + var extractionWorkerCount = Math.Min( + context.ExtractionParallelism, + context.ExtractionWorkItemCount); + var activeExtractionPhases = + new ActiveExtractionPhase?[extractionWorkerCount]; + context.SetActiveExtractionPhases(activeExtractionPhases); + var extractionQueueCapacity = parallelizeExtraction + ? Math.Max(1, extractionWorkerCount * 2) + : 1; + FullScanExtractionQueueCapacityForTesting?.Invoke( + extractionQueueCapacity); + + using var extractionResults = + new BlockingCollection( + extractionQueueCapacity); + using var extractionStallCts = + CancellationTokenSource.CreateLinkedTokenSource( + context.CancellationToken); + using var mainSymbolExtractionWorker = + new LazyDisposable( + () => new SymbolExtractionWorkerClient( + context.Options.MaxFileSizeBytes)); + var workers = StartFullScanExtractionWorkers( + new FullScanExtractionWorkerContext + { + Indexer = context.Indexer, + Options = context.Options, + ProjectRoot = context.ProjectRoot, + FileTargets = context.FileTargets, + ExtractionFileIndexes = + context.ExtractionFileIndexes, + ExtractionWorkItemCount = + context.ExtractionWorkItemCount, + ExtractionWorkerCount = extractionWorkerCount, + ParallelizeExtraction = parallelizeExtraction, + CSharpWorkspace = context.GetCSharpWorkspace(), + CSharpWorkspaceFileSnapshots = + context.GetCSharpWorkspaceFileSnapshots(), + PostExtractionHooks = postExtractionHooks, + ActiveExtractionPhases = activeExtractionPhases, + ExtractionResults = extractionResults, + ExtractionCancellationToken = + extractionStallCts.Token, + CancellationToken = context.CancellationToken, + }); + CompleteFullScanExtractionQueueWhenWorkersFinish( + workers, + extractionResults); + + var processedBeforeExtraction = context.GetProcessedCount(); + var consumerContext = CreateFullScanExtractionConsumerContext( + context, + postExtractionHooks, + mainSymbolExtractionWorker.Value, + extractionResults, + extractionStallCts, + workers, + activeExtractionPhases, + processedBeforeExtraction); + var consumerState = + ConsumeFullScanExtractionResults(consumerContext); + context.PublishProcessedCount( + processedBeforeExtraction + consumerState.Processed); + return consumerState; + } + + private static void PrepareFullScanExtractionProgress( + FullScanExtractionPipelineContext context) + { + if (context.Options.Json || context.Options.Quiet) + return; + + context.IndexProgress.Pause(); + context.SetIndexProgressVisible(true); + ConsoleUi.PrintProgress(0, context.FilesCount); + } + + private static void CompleteFullScanExtractionQueueWhenWorkersFinish( + Task[] workers, + BlockingCollection extractionResults) + { + _ = Task.WhenAll(workers).ContinueWith( + _ => extractionResults.CompleteAdding(), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private static FullScanExtractionConsumerContext + CreateFullScanExtractionConsumerContext( + FullScanExtractionPipelineContext context, + PostExtractionHookRunner postExtractionHooks, + SymbolExtractionWorkerClient symbolExtractionWorker, + BlockingCollection extractionResults, + CancellationTokenSource extractionStallCts, + Task[] workers, + ActiveExtractionPhase?[] activeExtractionPhases, + int processedBeforeExtraction) + => new() + { + Writer = context.Writer, + Indexer = context.Indexer, + Options = context.Options, + ProjectRoot = context.ProjectRoot, + FileTargets = context.FileTargets, + FilesCount = context.FilesCount, + ProcessedBeforeExtraction = processedBeforeExtraction, + ForceExtractorRefresh = context.ForceExtractorRefresh, + StartedWithNoIndexedFiles = + context.StartedWithNoIndexedFiles, + PriorSymbolsOnlyGraphOmitted = + context.PriorSymbolsOnlyGraphOmitted, + SymbolKindFilterMatchesPrior = + context.SymbolKindFilterMatchesPrior, + CSharpIndexedProjectRootCompatible = + context.CSharpIndexedProjectRootCompatible, + CSharpSymbolNameContractMatchesCurrent = + context.CSharpSymbolNameContractMatchesCurrent, + SqlGraphContractMatchesCurrent = + context.SqlGraphContractMatchesCurrent, + HdlGraphContractMatchesCurrent = + context.HdlGraphContractMatchesCurrent, + ReadableFileBytes = context.ReadableFileBytes, + PostExtractionHooks = postExtractionHooks, + SymbolExtractionWorker = symbolExtractionWorker, + IndexProgress = context.IndexProgress, + ExtractionResults = extractionResults, + Workers = workers, + ExtractionStallTimeout = + IndexExtractionStallTimeoutForTesting?.Invoke() + ?? IndexExtractionStallTimeout, + ActiveExtractionPhases = activeExtractionPhases, + CancellationToken = context.CancellationToken, + CancelExtraction = extractionStallCts.Cancel, + EnsureIndexingActivityVisible = + context.FullScanProgress.EnsureIndexingActivityVisible, + ReportJsonIndexProgressIfNeeded = + context.FullScanProgress.ReportJsonIndexProgressIfNeeded, + ThrowIfFullScanCancelled = + context.ThrowIfFullScanCancelled, + PublishProcessedCount = context.PublishProcessedCount, + SetCurrentJsonIndexFile = context.SetCurrentJsonIndexFile, + GetCurrentJsonIndexFile = context.GetCurrentJsonIndexFile, + GetDeferCSharpMutationsForIncompleteScan = + context.GetDeferCSharpMutationsForIncompleteScan, + GetFtsMutated = context.GetFtsMutated, + GetCSharpWorkspace = context.GetCSharpWorkspace, + GetCSharpWorkspaceFileSnapshots = + context.GetCSharpWorkspaceFileSnapshots, + DeferCSharpMutationsForLoadedSnapshotDrift = + context.DeferCSharpMutationsForLoadedSnapshotDrift, + TargetRequiresJavaScriptTypeScriptRefresh = + context.TargetRequiresJavaScriptTypeScriptRefresh, + AllowReuseWithCurrentHotspotFamilyTrust = + context.AllowReuseWithCurrentHotspotFamilyTrust, + RequireTypeScriptAugmentationRefresh = + context.RequireTypeScriptAugmentationRefresh, + WriteProjectRootOnce = context.WriteProjectRootOnce, + InsertIssuesForIndexedFile = + context.InsertIssuesForIndexedFile, + CountFreshInsertedRows = context.CountFreshInsertedRows, + State = context.ConsumerState, + }; +} diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 9468b048e..b64bb2947 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -908,188 +908,111 @@ void RecordFullScanTargetStatSkip(int fileIndex, IndexedFileStatReuseResult exis fullScanProgress.ReportJsonIndexProgressIfNeeded(); - PostExtractionHookRunner? postExtractionHooks = null; - if (extractionWorkItemCount == 0) - { - FullScanExtractionSchedulingForTesting?.Invoke(false, null); - } - else - { - postExtractionHooks = PostExtractionHookRunner.DiscoverDefault( - options.MaxFileSizeBytes, - maxSymbolCount: options.MaxSymbolsPerFile + 1, - maxReferenceCount: options.MaxReferencesPerFile + 1); - var hasPostExtractionHooks = postExtractionHooks.Hooks.Count > 0; - var parallelizeExtraction = !options.SymbolKindFilter.IsActive - && !hasPostExtractionHooks; - var parallelizeExtractionReason = parallelizeExtraction - ? options.Rebuild - ? "rebuild" - : startedWithNoIndexedFiles - ? "empty_index" - : "incremental_changes" - : null; - FullScanExtractionSchedulingForTesting?.Invoke( - parallelizeExtraction, - parallelizeExtractionReason); - - fullScanProgress.EnsureIndexingActivityVisible(); - fullScanProgress.StartJsonHeartbeatIfNeeded(); - - try + var extractionPipeline = RunFullScanExtractionPipeline( + new FullScanExtractionPipelineContext { - if (!options.Json && !options.Quiet) - { - indexProgress.Pause(); - indexProgressVisible = true; - ConsoleUi.PrintProgress(0, files.Count); - } - - FullScanExtractionWorkStartedForTesting?.Invoke(); - var extractionWorkerCount = Math.Min(extractionParallelism, extractionWorkItemCount); - activeExtractionPhases = new ActiveExtractionPhase?[extractionWorkerCount]; - var extractionQueueCapacity = parallelizeExtraction - ? Math.Max(1, extractionWorkerCount * 2) - : 1; - FullScanExtractionQueueCapacityForTesting?.Invoke(extractionQueueCapacity); - using var extractionResults = new BlockingCollection(extractionQueueCapacity); - using var extractionStallCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - using var mainSymbolExtractionWorker = new LazyDisposable( - () => new SymbolExtractionWorkerClient(options.MaxFileSizeBytes)); - var extractionCancellationToken = extractionStallCts.Token; - var workers = StartFullScanExtractionWorkers(new FullScanExtractionWorkerContext + Writer = writer, + Indexer = indexer, + Options = options, + ProjectRoot = projectRoot, + FileTargets = fileTargets, + ExtractionFileIndexes = extractionFileIndexes, + ExtractionWorkItemCount = extractionWorkItemCount, + ExtractionParallelism = extractionParallelism, + FilesCount = files.Count, + ForceExtractorRefresh = forceExtractorRefresh, + StartedWithNoIndexedFiles = startedWithNoIndexedFiles, + PriorSymbolsOnlyGraphOmitted = + priorSymbolsOnlyGraphOmitted, + SymbolKindFilterMatchesPrior = + symbolKindFilterMatchesPrior, + CSharpIndexedProjectRootCompatible = + csharpIndexedProjectRootCompatible, + CSharpSymbolNameContractMatchesCurrent = + csharpSymbolNameContractMatchesCurrent, + SqlGraphContractMatchesCurrent = + sqlGraphContractMatchesCurrent, + HdlGraphContractMatchesCurrent = + hdlGraphContractMatchesCurrent, + ReadableFileBytes = readableFileBytes, + IndexProgress = indexProgress, + FullScanProgress = fullScanProgress, + CancellationToken = cancellationToken, + GetProcessedCount = () => processed, + PublishProcessedCount = value => processed = value, + ThrowIfFullScanCancelled = + ThrowIfFullScanCancelled, + SetIndexProgressVisible = + value => indexProgressVisible = value, + SetActiveExtractionPhases = + phases => activeExtractionPhases = phases, + SetCurrentJsonIndexFile = + path => currentJsonIndexFile = path, + GetCurrentJsonIndexFile = + () => currentJsonIndexFile, + GetDeferCSharpMutationsForIncompleteScan = + () => deferCSharpMutationsForIncompleteScan, + GetFtsMutated = () => ftsMutated, + GetCSharpWorkspace = () => csharpWorkspace, + GetCSharpWorkspaceFileSnapshots = + () => csharpWorkspaceFileSnapshots, + DeferCSharpMutationsForLoadedSnapshotDrift = + DeferCSharpMutationsForLoadedSnapshotDrift, + TargetRequiresJavaScriptTypeScriptRefresh = + TargetRequiresJavaScriptTypeScriptRefresh, + AllowReuseWithCurrentHotspotFamilyTrust = language => + AllowReuseWithCurrentHotspotFamilyTrust( + language, + hotspotFamilyTrustMatchesCurrent), + RequireTypeScriptAugmentationRefresh = + RequireTypeScriptAugmentationRefresh, + WriteProjectRootOnce = WriteProjectRootOnce, + InsertIssuesForIndexedFile = + InsertIssuesForIndexedFile, + CountFreshInsertedRows = CountFreshInsertedRows, + ConsumerState = new FullScanExtractionConsumerState { - Indexer = indexer, - Options = options, - ProjectRoot = projectRoot, - FileTargets = fileTargets, - ExtractionFileIndexes = extractionFileIndexes, - ExtractionWorkItemCount = extractionWorkItemCount, - ExtractionWorkerCount = extractionWorkerCount, - ParallelizeExtraction = parallelizeExtraction, - CSharpWorkspace = csharpWorkspace, - CSharpWorkspaceFileSnapshots = csharpWorkspaceFileSnapshots, - PostExtractionHooks = postExtractionHooks, - ActiveExtractionPhases = activeExtractionPhases, - ExtractionResults = extractionResults, - ExtractionCancellationToken = extractionCancellationToken, - CancellationToken = cancellationToken, - }); - - _ = Task.WhenAll(workers).ContinueWith( - task => - { - extractionResults.CompleteAdding(); - }, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - - var processedBeforeExtraction = processed; - var extractionState = ConsumeFullScanExtractionResults( - new FullScanExtractionConsumerContext - { - Writer = writer, - Indexer = indexer, - Options = options, - ProjectRoot = projectRoot, - FileTargets = fileTargets, - FilesCount = files.Count, - ProcessedBeforeExtraction = processedBeforeExtraction, - ForceExtractorRefresh = forceExtractorRefresh, - StartedWithNoIndexedFiles = startedWithNoIndexedFiles, - PriorSymbolsOnlyGraphOmitted = priorSymbolsOnlyGraphOmitted, - SymbolKindFilterMatchesPrior = symbolKindFilterMatchesPrior, - CSharpIndexedProjectRootCompatible = csharpIndexedProjectRootCompatible, - CSharpSymbolNameContractMatchesCurrent = - csharpSymbolNameContractMatchesCurrent, - SqlGraphContractMatchesCurrent = sqlGraphContractMatchesCurrent, - HdlGraphContractMatchesCurrent = hdlGraphContractMatchesCurrent, - ReadableFileBytes = readableFileBytes, - PostExtractionHooks = postExtractionHooks, - SymbolExtractionWorker = mainSymbolExtractionWorker.Value, - IndexProgress = indexProgress, - ExtractionResults = extractionResults, - Workers = workers, - ExtractionStallTimeout = - IndexExtractionStallTimeoutForTesting?.Invoke() - ?? IndexExtractionStallTimeout, - ActiveExtractionPhases = activeExtractionPhases, - CancellationToken = cancellationToken, - CancelExtraction = extractionStallCts.Cancel, - EnsureIndexingActivityVisible = - fullScanProgress.EnsureIndexingActivityVisible, - ReportJsonIndexProgressIfNeeded = - fullScanProgress.ReportJsonIndexProgressIfNeeded, - ThrowIfFullScanCancelled = ThrowIfFullScanCancelled, - PublishProcessedCount = value => processed = value, - SetCurrentJsonIndexFile = path => currentJsonIndexFile = path, - GetCurrentJsonIndexFile = () => currentJsonIndexFile, - GetDeferCSharpMutationsForIncompleteScan = - () => deferCSharpMutationsForIncompleteScan, - GetFtsMutated = () => ftsMutated, - GetCSharpWorkspace = () => csharpWorkspace, - GetCSharpWorkspaceFileSnapshots = - () => csharpWorkspaceFileSnapshots, - DeferCSharpMutationsForLoadedSnapshotDrift = - DeferCSharpMutationsForLoadedSnapshotDrift, - TargetRequiresJavaScriptTypeScriptRefresh = - TargetRequiresJavaScriptTypeScriptRefresh, - AllowReuseWithCurrentHotspotFamilyTrust = language => - AllowReuseWithCurrentHotspotFamilyTrust( - language, - hotspotFamilyTrustMatchesCurrent), - RequireTypeScriptAugmentationRefresh = - RequireTypeScriptAugmentationRefresh, - WriteProjectRootOnce = WriteProjectRootOnce, - InsertIssuesForIndexedFile = InsertIssuesForIndexedFile, - CountFreshInsertedRows = CountFreshInsertedRows, - State = new FullScanExtractionConsumerState - { - FtsMutated = ftsMutated, - MutualRecursionRefreshNeeded = - mutualRecursionRefreshNeeded, - CSharpMetadataTargetsNeedRefresh = - csharpMetadataTargetsNeedRefresh, - SymbolsDroppedByKindFilter = - symbolsDroppedByKindFilter, - ReusedHotspotFamilyLanguages = - reusedHotspotFamilyLanguages, - SkippedSymbolExtractorLanguages = - skippedSymbolExtractorLanguages, - IndexedSymbolExtractorLanguages = - indexedSymbolExtractorLanguages, - ErrorList = errorList, - FileErrorList = fileErrorList, - WarningList = warningList, - }, - }); - processed = processedBeforeExtraction + extractionState.Processed; - skipped += extractionState.Skipped; - warnings += extractionState.Warnings; - errors += extractionState.ErrorsAdded; - ftsMutated = extractionState.FtsMutated; - mutualRecursionRefreshNeeded = - extractionState.MutualRecursionRefreshNeeded; - csharpMetadataTargetsNeedRefresh = - extractionState.CSharpMetadataTargetsNeedRefresh; - symbolsDroppedByKindFilter = - extractionState.SymbolsDroppedByKindFilter; - extractedFiles += extractionState.ExtractedFiles; - extractedChunks += extractionState.ExtractedChunks; - extractedSymbols += extractionState.ExtractedSymbols; - extractedReferences += extractionState.ExtractedReferences; - reusedHotspotFamilyLanguages = - extractionState.ReusedHotspotFamilyLanguages; - skippedSymbolExtractorLanguages = - extractionState.SkippedSymbolExtractorLanguages; - } - finally - { - currentJsonIndexFile = null; - fullScanProgress.StopJsonHeartbeat(); - postExtractionHooks?.Dispose(); - } + FtsMutated = ftsMutated, + MutualRecursionRefreshNeeded = + mutualRecursionRefreshNeeded, + CSharpMetadataTargetsNeedRefresh = + csharpMetadataTargetsNeedRefresh, + SymbolsDroppedByKindFilter = + symbolsDroppedByKindFilter, + ReusedHotspotFamilyLanguages = + reusedHotspotFamilyLanguages, + SkippedSymbolExtractorLanguages = + skippedSymbolExtractorLanguages, + IndexedSymbolExtractorLanguages = + indexedSymbolExtractorLanguages, + ErrorList = errorList, + FileErrorList = fileErrorList, + WarningList = warningList, + }, + }); + var postExtractionHooks = + extractionPipeline.PostExtractionHooks; + var extractionState = extractionPipeline.ConsumerState; + if (extractionState != null) + { + skipped += extractionState.Skipped; + warnings += extractionState.Warnings; + errors += extractionState.ErrorsAdded; + ftsMutated = extractionState.FtsMutated; + mutualRecursionRefreshNeeded = + extractionState.MutualRecursionRefreshNeeded; + csharpMetadataTargetsNeedRefresh = + extractionState.CSharpMetadataTargetsNeedRefresh; + symbolsDroppedByKindFilter = + extractionState.SymbolsDroppedByKindFilter; + extractedFiles += extractionState.ExtractedFiles; + extractedChunks += extractionState.ExtractedChunks; + extractedSymbols += extractionState.ExtractedSymbols; + extractedReferences += extractionState.ExtractedReferences; + reusedHotspotFamilyLanguages = + extractionState.ReusedHotspotFamilyLanguages; + skippedSymbolExtractorLanguages = + extractionState.SkippedSymbolExtractorLanguages; } indexProgress.Pause(); From 61b5ca39992d1ef66b7a85596af870a3c7c08a8d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 02:38:56 +0900 Subject: [PATCH 100/101] Preserve reference extraction allocation budgets --- .../ReferenceExtractor.CoreCallReferences.cs | 189 ++++++++++++------ ...ferenceExtractor.CoreDocumentationLines.cs | 4 +- .../ReferenceExtractor.CoreLanguageLines.cs | 97 ++++++--- .../ReferenceExtractor.CoreReferenceLoop.cs | 106 +++++----- ...ReferenceExtractor.CoreSpecializedLines.cs | 2 +- 5 files changed, 249 insertions(+), 149 deletions(-) diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs index ef1129850..8a7251069 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallReferences.cs @@ -22,9 +22,64 @@ private readonly record struct CoreCallReferenceContext( HashSet<(int LineNumber, int ColumnIndex)>? SqlWindowFunctionCallSiteSuppressions, CoreLineDefinitionState Definitions); + private static Action CreateCallLikeReferenceEmitter( + CoreCallReferenceContext call) => + (name, callIndex) => + { + var line = call.Line; + _ = TryAddCoreCallLikeReference( + call, + name, + callIndex, + ScientificNativeReferenceExtractor.Supports(line.Language) + ? ScientificNativeReferenceExtractor.GetParenthesizedCallTargetQualifier( + line.Language, + line.PreparedLine, + callIndex) + : null); + }; + + private static Action CreatePowerShellParameterReferenceEmitter( + CoreReferenceLineContext line) => + (name, callIndex) => + { + var callContainer = line.ResolveContainerForCall(callIndex); + AddReference( + line.References, + line.Seen, + line.FileId, + name, + callIndex, + "parameter", + line.Context, + line.LineNumber, + callContainer, + line.Language); + }; + + private static Action CreateGradleDslReferenceEmitter( + CoreReferenceLineContext line) => + (name, callIndex) => + { + var normalizedName = NormalizeAtPrefixedIdentifier(name); + var callContainer = line.ResolveContainerForCall(callIndex); + AddReference( + line.References, + line.Seen, + line.FileId, + normalizedName, + callIndex, + "call", + line.Context, + line.LineNumber, + callContainer, + line.Language); + }; + private static void EmitCoreCallReferences(CoreCallReferenceContext call) { var line = call.Line; + Action? addCallLikeReference = null; if (line.Language is "javascript" or "typescript") { JavaScriptReferenceExtractor.EmitOptionalMemberChainReferences( @@ -58,33 +113,6 @@ private static void EmitCoreCallReferences(CoreCallReferenceContext call) line.ResolveContainerForCall); } - void AddCallLikeReference(string name, int callIndex) => - _ = TryAddCallLikeReference( - name, - callIndex, - ScientificNativeReferenceExtractor.Supports(line.Language) - ? ScientificNativeReferenceExtractor.GetParenthesizedCallTargetQualifier( - line.Language, - line.PreparedLine, - callIndex) - : null); - - void AddPowerShellParameterReference(string name, int callIndex) - { - var callContainer = line.ResolveContainerForCall(callIndex); - AddReference(line.References, line.Seen, line.FileId, name, callIndex, "parameter", line.Context, line.LineNumber, callContainer, line.Language); - } - - bool TryAddCallLikeReference( - string name, - int callIndex, - string? targetQualifier = null) => - TryAddCoreCallLikeReference( - call, - name, - callIndex, - targetQualifier); - if (line.Language is "batch") BatchReferenceExtractor.EmitJumpTargetReferences( line.OriginalLine, @@ -107,7 +135,6 @@ bool TryAddCallLikeReference( line.ResolveContainerForCall); HashSet? matchedCallIndices = null; - HashSet GetMatchedCallIndices() => matchedCallIndices ??= []; var callScanLine = call.DynamicDeclarativeState?.GetCallScanLine( line.Language, line.LineNumber, @@ -128,12 +155,14 @@ bool TryAddCallLikeReference( } else if (line.Language is "powershell") { - PowerShellReferenceExtractor.EmitCallReferences(line.PreparedLine, AddCallLikeReference); + PowerShellReferenceExtractor.EmitCallReferences( + line.PreparedLine, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call)); PowerShellReferenceExtractor.EmitSplatParameterReferences( line.PreparedLine, call.Lookups.GetPowerShellSplatAssignments, line.LineNumber, - AddPowerShellParameterReference); + CreatePowerShellParameterReferenceEmitter(line)); } else if (line.Language is "shell") { @@ -148,7 +177,7 @@ bool TryAddCallLikeReference( call.ShellCallableNames, call.ShellGlobalAliasNames, line.ResolveContainerForCall, - AddCallLikeReference); + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call)); } else if (line.Language is "assembly") { @@ -170,7 +199,7 @@ bool TryAddCallLikeReference( line.Context, line.LineNumber, line.ResolveContainerForCall, - AddCallLikeReference, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call), call.ScientificNativeDependencyLimit, call.ReportDiagnostic); } @@ -218,8 +247,9 @@ bool TryAddCallLikeReference( { continue; } - GetMatchedCallIndices().Add(callIndex); - if (TryAddCallLikeReference( + (matchedCallIndices ??= []).Add(callIndex); + if (TryAddCoreCallLikeReference( + call, name, callIndex, ScientificNativeReferenceExtractor.Supports(line.Language) @@ -265,8 +295,8 @@ bool TryAddCallLikeReference( line.Context, line.LineNumber, line.ResolveContainerForCall, - GetMatchedCallIndices(), - AddCallLikeReference); + matchedCallIndices ??= [], + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call)); } else if (line.Language is "perl" or "ambiguous_pl") { @@ -279,7 +309,7 @@ bool TryAddCallLikeReference( line.Context, line.LineNumber, line.ResolveContainerForCall, - AddCallLikeReference, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call), emitArrowCallReferences: line.Language != "ambiguous_pl" || call.DynamicDeclarativeState?.HasPrologContainer(line.LineNumber) != true); } @@ -297,36 +327,42 @@ bool TryAddCallLikeReference( line.Context, line.LineNumber, line.ResolveContainerForCall, - AddCallLikeReference); + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call)); } if (line.Language == "go") - LanguageReferenceExtractionSupport.EmitGoBranchLabelReferences(line.PreparedLine, AddCallLikeReference); + LanguageReferenceExtractionSupport.EmitGoBranchLabelReferences( + line.PreparedLine, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call)); if (line.Language == "swift") - SwiftReferenceExtractor.EmitTrailingClosureReferences(line.PreparedLine, AddCallLikeReference); + SwiftReferenceExtractor.EmitTrailingClosureReferences( + line.PreparedLine, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call)); else if (line.Language == "kotlin") { KotlinReferenceExtractor.EmitInfixCallReferences( line.PreparedLine, line.OriginalLine, call.KotlinInfixFunctionNames!, - AddCallLikeReference); - KotlinReferenceExtractor.EmitTrailingLambdaReferences(line.PreparedLine, AddCallLikeReference); + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call)); + KotlinReferenceExtractor.EmitTrailingLambdaReferences( + line.PreparedLine, + addCallLikeReference); } if (line.Language == "fsharp") { FSharpReferenceExtractor.EmitAdditionalCallReferences( line.PreparedLine, - AddCallLikeReference); + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call)); } if (line.Language == "scala") { ScalaReferenceExtractor.EmitTrailingBlockCallReferences( line.PreparedLine, - AddCallLikeReference); + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call)); ScalaReferenceExtractor.EmitAdditionalReferences( line.PreparedLine, line.References, @@ -335,42 +371,66 @@ bool TryAddCallLikeReference( line.Context, line.LineNumber, line.ResolveContainerForCall, - AddCallLikeReference); + addCallLikeReference); } else if (line.Language == "gradle") { - void AddGradleDslReference(string name, int callIndex) - { - var normalizedName = NormalizeAtPrefixedIdentifier(name); - var callContainer = line.ResolveContainerForCall(callIndex); - AddReference(line.References, line.Seen, line.FileId, normalizedName, callIndex, "call", line.Context, line.LineNumber, callContainer, line.Language); - } - GradleReferenceExtractor.EmitDslCallReferences( line.PreparedLine, - AddGradleDslReference); + CreateGradleDslReferenceEmitter(line)); } if (line.Language == "fortran") - FortranReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference); + FortranReferenceExtractor.EmitAdditionalCallReferences( + line.PreparedLine, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call)); else if (line.Language == "pascal") - PascalReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference, line.DefinitionNames); + PascalReferenceExtractor.EmitAdditionalCallReferences( + line.PreparedLine, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call), + line.DefinitionNames); else if (line.Language == "objc") - ObjectiveCReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.ResolveContainerForCall); + ObjectiveCReferenceExtractor.EmitAdditionalCallReferences( + line.PreparedLine, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call), + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall); else if (line.Language == "haskell") - HaskellReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference, line.DefinitionNames); + HaskellReferenceExtractor.EmitAdditionalCallReferences( + line.PreparedLine, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call), + line.DefinitionNames); else if (line.Language == "elixir") - ElixirReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference, line.DefinitionNames); + ElixirReferenceExtractor.EmitAdditionalCallReferences( + line.PreparedLine, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call), + line.DefinitionNames); else if (line.Language == "lua") - LuaReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference, line.References, line.Seen, line.FileId, line.Context, line.LineNumber, line.ResolveContainerForCall, line.DefinitionNames); + LuaReferenceExtractor.EmitAdditionalCallReferences( + line.PreparedLine, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call), + line.References, + line.Seen, + line.FileId, + line.Context, + line.LineNumber, + line.ResolveContainerForCall, + line.DefinitionNames); else if (line.Language == "smalltalk") - SmalltalkReferenceExtractor.EmitAdditionalCallReferences(line.PreparedLine, AddCallLikeReference, line.DefinitionNames); + SmalltalkReferenceExtractor.EmitAdditionalCallReferences( + line.PreparedLine, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call), + line.DefinitionNames); else if (line.Language == "vb") LanguageReferenceExtractionSupport.EmitAdditionalCallReferences( "vb", line.PreparedLine, line.OriginalLine, - AddCallLikeReference, + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call), line.References, line.Seen, line.FileId, @@ -390,7 +450,10 @@ void AddGradleDslReference(string name, int callIndex) { foreach (var candidate in EnumerateNestedGenericCallCandidates(line.PreparedLine, matchedCallIndices ?? EmptyMatchedIndices)) { - if (TryAddCallLikeReference(candidate.Name, candidate.NameIndex)) + if (TryAddCoreCallLikeReference( + call, + candidate.Name, + candidate.NameIndex)) { EmitGenericInvocationTypeArgumentReferences( line.Language, @@ -411,7 +474,7 @@ void AddGradleDslReference(string name, int callIndex) { RustReferenceExtractor.EmitAdditionalCallReferences( line.PreparedLine, - AddCallLikeReference); + addCallLikeReference ??= CreateCallLikeReferenceEmitter(call)); RustReferenceExtractor.EmitAttributeReferences( line.PreparedLine, line.References, diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreDocumentationLines.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreDocumentationLines.cs index b85d66d74..8df7c39ba 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreDocumentationLines.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreDocumentationLines.cs @@ -23,7 +23,7 @@ private readonly record struct CoreDocumentationLineContext( bool[]? CSharpLinesInsideBlockComment, List<(int start, int end)>? CSharpAttributeRangesOnLine, List<(int start, int end)>?[]? CSharpAttributeRanges, - Func GetPhpLineContainer); + Func? GetPhpLineContainer); private static void EmitCoreDocumentationReferences( CoreDocumentationLineContext line, @@ -158,7 +158,7 @@ private static void EmitCoreDocumentationReferences( line.Seen, line.FileId, line.LineNumber, - line.GetPhpLineContainer, + line.GetPhpLineContainer!, ref phpInDocblock, ref phpDocblockContainer, ref phpDocblockPropertyNames); diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLanguageLines.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLanguageLines.cs index 04feb081f..c66c19324 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLanguageLines.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLanguageLines.cs @@ -19,7 +19,8 @@ private readonly record struct CoreReferenceLineContext( ReferenceDedupeSet Seen, SymbolRecord? Container, HashSet? DefinitionNames, - Func ResolveContainerForCall); + Func ResolveContainerForCall, + Func IsIgnoredCallName); private static void EmitJavaScriptTaggedTemplateReferences( CoreReferenceLineContext line, @@ -146,10 +147,34 @@ private static void EmitMetadataLineReferences( } } - private static void EmitPythonLineReferences( + private static Func CreatePythonDefinitionContainerResolver( CoreReferenceLineContext line, CoreExtractionLookups lookups, - Func resolvePythonDefinitionContainer) + SymbolRecord? headerContainer, + string definitionKind) => + column => + { + if (headerContainer != null) + return headerContainer; + + var container = line.ResolveContainerForCall(column); + if (container != null) + return container; + + var definitionContainers = + lookups.GetPythonDefinitionContainersByLineAndKind(); + if (definitionContainers == null) + return null; + return definitionContainers.TryGetValue( + (line.LineNumber, definitionKind), + out var symbol) + ? symbol + : null; + }; + + private static void EmitPythonLineReferences( + CoreReferenceLineContext line, + CoreExtractionLookups lookups) { var pythonPreparedLine = line.PreparedLine; @@ -177,6 +202,22 @@ private static void EmitPythonLineReferences( } } var pythonHeaderContainer = pythonHeaderSymbol ?? line.Container; + var resolvePythonClassContainer = + pythonPreparedLine.IndexOf("class", StringComparison.Ordinal) >= 0 + ? CreatePythonDefinitionContainerResolver( + line, + lookups, + pythonHeaderContainer, + "class") + : line.ResolveContainerForCall; + var resolvePythonFunctionContainer = + pythonPreparedLine.IndexOf("def", StringComparison.Ordinal) >= 0 + ? CreatePythonDefinitionContainerResolver( + line, + lookups, + pythonHeaderContainer, + "function") + : line.ResolveContainerForCall; var pythonReferenceStart = line.References.Count; PythonReferenceExtractor.EmitDecoratorReferences( @@ -188,7 +229,7 @@ private static void EmitPythonLineReferences( line.LineNumber, line.Container, line.DefinitionNames, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitRaiseReferences( line.PreparedLine, line.References, @@ -197,7 +238,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitExceptReferences( line.PreparedLine, line.References, @@ -206,7 +247,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitIsInstanceReferences( line.PreparedLine, line.References, @@ -215,7 +256,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitIsSubclassReferences( line.PreparedLine, line.References, @@ -224,7 +265,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitCastReferences( line.PreparedLine, line.References, @@ -233,7 +274,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitAssertTypeReferences( line.PreparedLine, line.References, @@ -242,7 +283,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitClassBaseReferences( pythonPreparedLine, line.References, @@ -251,8 +292,8 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, pythonHeaderContainer, - index => pythonHeaderContainer ?? line.ResolveContainerForCall(index) ?? resolvePythonDefinitionContainer(line.LineNumber, "class"), - name => IsIgnoredCallName(line.Language, name)); + resolvePythonClassContainer, + line.IsIgnoredCallName); PythonReferenceExtractor.EmitFunctionReturnReferences( pythonPreparedLine, line.References, @@ -261,8 +302,8 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, pythonHeaderContainer, - index => pythonHeaderContainer ?? line.ResolveContainerForCall(index) ?? resolvePythonDefinitionContainer(line.LineNumber, "function"), - name => IsIgnoredCallName(line.Language, name)); + resolvePythonFunctionContainer, + line.IsIgnoredCallName); PythonReferenceExtractor.EmitFunctionParameterReferences( pythonPreparedLine, line.References, @@ -271,8 +312,8 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, pythonHeaderContainer, - index => pythonHeaderContainer ?? line.ResolveContainerForCall(index) ?? resolvePythonDefinitionContainer(line.LineNumber, "function"), - name => IsIgnoredCallName(line.Language, name)); + resolvePythonFunctionContainer, + line.IsIgnoredCallName); PythonReferenceExtractor.EmitVariableAnnotationReferences( line.PreparedLine, line.References, @@ -281,7 +322,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitTypeAliasReferences( line.PreparedLine, line.References, @@ -290,7 +331,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitNewTypeReferences( line.PreparedLine, line.References, @@ -299,7 +340,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); var pythonTypeFactoryReferenceStart = line.References.Count; PythonReferenceExtractor.EmitTypeVarBoundReferences( pythonTypeFactoryLine, @@ -309,7 +350,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitTypeVarConstraintReferences( pythonTypeFactoryLine, line.References, @@ -318,7 +359,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitGetTypeHintsReferences( line.PreparedLine, line.References, @@ -327,7 +368,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitDataclassesFieldsReferences( line.PreparedLine, line.References, @@ -336,7 +377,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitDataclassFieldReferences( line.PreparedLines, line.Lines, @@ -345,7 +386,7 @@ private static void EmitPythonLineReferences( line.Seen, line.FileId, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitAttrsFieldsReferences( line.PreparedLine, line.References, @@ -354,7 +395,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitPydanticTypeAdapterReferences( line.PreparedLine, line.References, @@ -363,7 +404,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitPytestRaisesReferences( line.PreparedLine, line.References, @@ -372,7 +413,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); PythonReferenceExtractor.EmitContextlibSuppressReferences( line.PreparedLine, line.References, @@ -381,7 +422,7 @@ private static void EmitPythonLineReferences( line.Context, line.LineNumber, line.Container, - name => IsIgnoredCallName(line.Language, name)); + line.IsIgnoredCallName); if (pythonTypeFactoryMap.HasValue) RemapPythonLogicalHeaderReferences(line.References, pythonTypeFactoryReferenceStart, pythonTypeFactoryMap.Value, line.Lines); diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreReferenceLoop.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreReferenceLoop.cs index a05f73e6c..a15022802 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreReferenceLoop.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreReferenceLoop.cs @@ -99,6 +99,8 @@ private static CSharpMultiLineTypePatternState EmitCoreReferenceLines( var csharpUsingAliases = loop.CSharpUsingAliases; var csharpUsingStatics = loop.CSharpUsingStatics; var dynamicDeclarativeState = loop.DynamicDeclarativeState; + Func isIgnoredCallName = + name => IsIgnoredCallName(language, name); var pendingCSharpMultiLineTypePattern = default(CSharpMultiLineTypePatternState); var pendingCSharpWhereConstraint = language == "csharp" @@ -310,21 +312,6 @@ private static CSharpMultiLineTypePatternState EmitCoreReferenceLines( container) ?? container; } - SymbolRecord? ResolvePythonDefinitionContainer( - int lineNumberCandidate, - string kind) - { - var pythonDefinitionContainersByLineAndKind = - lookups.GetPythonDefinitionContainersByLineAndKind(); - if (pythonDefinitionContainersByLineAndKind == null) - return null; - return pythonDefinitionContainersByLineAndKind.TryGetValue( - (lineNumberCandidate, kind), - out var symbol) - ? symbol - : null; - } - SymbolRecord? ResolveSwiftPropertyContainerForCall(int column) { if (loop.SwiftPropertyDefinitionsByLine != null @@ -356,7 +343,8 @@ private static CSharpMultiLineTypePatternState EmitCoreReferenceLines( seen, container, definitionNames, - ResolveContainerForCall); + ResolveContainerForCall, + isIgnoredCallName); if (shaderState is not null) { @@ -486,8 +474,7 @@ private static CSharpMultiLineTypePatternState EmitCoreReferenceLines( { EmitPythonLineReferences( lineContext, - lookups, - ResolvePythonDefinitionContainer); + lookups); } if (language == "r") EmitRLineReferences(lineContext); @@ -546,49 +533,58 @@ private static bool EmitCoreDocumentationAndSpecialLineReferences( var request = loop.Request; var input = loop.Preparation; var lineNumber = lineIndex + 1; - SymbolRecord? phpLineContainer = null; - var phpLineContainerResolved = false; - - SymbolRecord? GetPhpLineContainer() + if (request.Language is "csharp" or "java" or "kotlin" or "r" or "php") { - if (!phpLineContainerResolved) + Func? getPhpLineContainer = null; + if (request.Language == "php") { - phpLineContainer = - loop.ContainerResolver.Find(lineNumber); - phpLineContainerResolved = true; + SymbolRecord? phpLineContainer = null; + var phpLineContainerResolved = false; + + SymbolRecord? GetPhpLineContainer() + { + if (!phpLineContainerResolved) + { + phpLineContainer = + loop.ContainerResolver.Find(lineNumber); + phpLineContainerResolved = true; + } + + return phpLineContainer; + } + + getPhpLineContainer = GetPhpLineContainer; } - return phpLineContainer; + var documentationLine = new CoreDocumentationLineContext( + request.FileId, + request.Language, + input.Lines, + input.PreparedLines, + input.StructuralLines, + lineIndex, + lineNumber, + originalLine, + preparedLine, + loop.References, + loop.Seen, + loop.ContainerCandidates, + loop.ContainerResolver, + loop.Lookups, + input.CSharpLinesInsideMultilineStringContent, + input.CSharpLinesInsideBlockComment, + csharpAttributeRangesOnLine, + loop.CSharpAttributeRanges, + getPhpLineContainer); + EmitCoreDocumentationReferences( + documentationLine, + ref state.CSharpInDelimitedDocComment, + ref state.JvmInDelimitedDocComment, + ref state.PhpInDocblock, + ref state.PhpDocblockContainer, + ref state.PhpDocblockPropertyNames); } - var documentationLine = new CoreDocumentationLineContext( - request.FileId, - request.Language, - input.Lines, - input.PreparedLines, - input.StructuralLines, - lineIndex, - lineNumber, - originalLine, - preparedLine, - loop.References, - loop.Seen, - loop.ContainerCandidates, - loop.ContainerResolver, - loop.Lookups, - input.CSharpLinesInsideMultilineStringContent, - input.CSharpLinesInsideBlockComment, - csharpAttributeRangesOnLine, - loop.CSharpAttributeRanges, - GetPhpLineContainer); - EmitCoreDocumentationReferences( - documentationLine, - ref state.CSharpInDelimitedDocComment, - ref state.JvmInDelimitedDocComment, - ref state.PhpInDocblock, - ref state.PhpDocblockContainer, - ref state.PhpDocblockPropertyNames); - sourceContext = originalLine.Trim(); if (request.Language is "cmake" or "justfile" or "makefile" or "msbuild" diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreSpecializedLines.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreSpecializedLines.cs index 3725dd262..a578bbbe0 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreSpecializedLines.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreSpecializedLines.cs @@ -145,7 +145,7 @@ private static void EmitInfrastructureLineReferences( line.FileId, sqlState!, line.ResolveContainerForCall, - name => IsIgnoredCallName(line.Language, name), + line.IsIgnoredCallName, (resolvedName, callIndex) => definitionState.ShouldSuppressDefinitionCall( resolvedName, From b4746a2915a71ad4394e38ab7b157f324d584694 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 26 Jul 2026 03:35:14 +0900 Subject: [PATCH 101/101] Restore MCP method guidance contract --- src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs | 2 +- tests/CodeIndex.Tests/McpServerTests.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs index 536c5a8fd..2375d27db 100644 --- a/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs +++ b/src/CodeIndex/Mcp/McpServer.MessageDispatch.Single.cs @@ -314,7 +314,7 @@ private Task DispatchRequestMethodAsync( code: -32601, message: $"Method not found: {method}", category: McpErrorEnvelope.CategoryMethodNotFound, - suggestion: "Supported methods: initialize, tools/list, tools/call, resources/list, resources/templates/list, resources/read, prompts/list, prompts/get, logging/setLevel, ping, notifications/initialized, notifications/cancelled, notifications/roots/list_changed, notifications/shutdown.", + suggestion: "Supported methods: initialize, tools/list, tools/call, resources/list, resources/templates/list, resources/read, prompts/list, prompts/get, logging/setLevel, ping, notifications/initialized, notifications/cancelled, notifications/shutdown.", retrySafe: false)), }; } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index f73e9d34b..3a36b310d 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -7927,7 +7927,6 @@ public void UnknownMethod_ReturnsMethodNotFound() Assert.Contains("Method not found", response["error"]!["message"]!.GetValue()); var suggestion = response["error"]!["data"]!["suggestion"]!.GetValue(); Assert.Contains("notifications/cancelled", suggestion, StringComparison.Ordinal); - Assert.Contains("notifications/roots/list_changed", suggestion, StringComparison.Ordinal); } [Fact]