From cf8632547c6c515f1021826e6787d8f8e1f82608 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Fri, 7 Aug 2026 18:05:12 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=AC=EB=B3=BC=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=EC=B0=BE=EA=B8=B0=20(find=5Fsymbol=20+=20Ctrl+T)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+T 가 TypeScript 프로젝트에서 늘 빈 목록이었음. lspClient 만 물어보는데 TS/JS 는 LSP 세션이 아니라 Monaco 내장 워커가 맡아서. 이 저장소가 딱 그랬다. getNavigateToItems 는 Monaco 워커가 노출 안 함(Missing requestHandler or method). getNavigationTree 는 되니까 파일별 트리를 모아서 만듦. 에이전트용 find_symbol 도구도 같은 소스. 색인이 아예 없는 경우와 찾았는데 없는 경우를 구분해서 답한다 — 안 그러면 "그런 심볼 없음" 이 "그 언어 지원 안 함" 을 덮어버림. 트리에서 걸러낸 것 두 가지. import 로 끌어온 이름(alias)과 describe("이름", ...) 블록. 둘 다 "정의가 어디" 의 답이 아님. 앞은 빼고 뒤는 뒤로 민다. 0.4.0 계획서에 실측 결과와 2단계(자체 인덱스) 접는 근거 반영. --- docs/PLAN-0.4.md | 115 +++++++++++++++++++++++++++ ide/src/App.tsx | 32 +++++++- ide/src/ai/claude.ts | 15 ++++ ide/src/editor/lspClient.ts | 8 ++ ide/src/editor/projectModels.ts | 3 + ide/src/editor/symbolIndex.ts | 137 ++++++++++++++++++++++++++++++++ ide/src/engine/navTree.test.ts | 134 +++++++++++++++++++++++++++++++ ide/src/engine/navTree.ts | 85 ++++++++++++++++++++ ide/src/i18n/dict/sc2.ts | 1 + 9 files changed, 528 insertions(+), 2 deletions(-) create mode 100644 docs/PLAN-0.4.md create mode 100644 ide/src/editor/symbolIndex.ts create mode 100644 ide/src/engine/navTree.test.ts create mode 100644 ide/src/engine/navTree.ts diff --git a/docs/PLAN-0.4.md b/docs/PLAN-0.4.md new file mode 100644 index 0000000..4cbd8b9 --- /dev/null +++ b/docs/PLAN-0.4.md @@ -0,0 +1,115 @@ +# 0.4.0 계획서 — 코드베이스 인덱싱 (프리뷰) + +작성일: 2026-08-07 · 기준: `develop`(0.3.0 직후) · 상태: 초안 + +--- + +## 1. 왜 이것인가 + +에이전트가 저장소를 보는 눈은 `list_files` 와 `search_files` 둘뿐이고, 둘 다 **매번 디스크를 훑어서** 답한다. 0.3.0 에서 그 구조에서 나온 결함을 세 건 고쳤다. + +| 무엇 | 증상 | +|---|---| +| 깊이 상한 8 | 8단계보다 깊은 파일이 트리·검색·치환 **어디에도** 없었다 | +| 점 폴더 일괄 제외 | `.vscode/`·`.claude/` 안이 통째로 안 보였다 | +| 잘렸다고 말 안 함 | 상한에 걸린 목록을 "전부" 로 내밀어, 있는 파일을 "없다" 로 답했다 | + +셋 다 막았지만 **구조는 그대로다.** 매번 훑는 방식에는 상한이 필요하고, 상한이 있으면 "안 찾아본 것" 과 "없는 것" 이 계속 같은 답으로 나온다. 그리고 이름으로만 찾으므로 "인증을 어디서 하지" 같은 질문에는 애초에 답할 수 없다. + +## 2. 이미 있는 것 + +새로 만들기 전에 무엇이 있는지부터. **인덱스의 절반은 이미 돌고 있다.** + +- **TypeScript** — Monaco 의 TS 워커가 프로젝트를 통째로 들고 있다. `projectModels.preload` 가 최대 500개 모델을 미리 세워 두므로, 안 연 파일도 워커가 안다. +- **LSP** — `lspClient.workspaceSymbols(query)` 가 살아 있는 모든 세션에 `workspace/symbol` 을 던져 합친다. `Ctrl+T` 가 이걸 쓴다(App.tsx:5409). +- 즉 **심볼 질의 통로는 이미 있고**, 에이전트만 그걸 못 쓴다. `search_files` 는 텍스트 grep 이라 심볼을 모른다. + +이 사실이 이 판의 첫 단계를 정한다 — 인덱스를 처음부터 짓기 전에, **있는 것을 에이전트에게 연결**하는 것이 먼저다. + +## 3. 단계 + +### 1단계 — 심볼 질의를 에이전트에게 준다 *(끝남, 2026-08-07)* + +`find_symbol` 도구를 붙인다. 안은 `lspClient.workspaceSymbols` 그대로다. + +- 모델이 "이 함수 어디 있어" 를 grep 대신 심볼로 묻는다. +- 얻는 것: 정확도(주석·문자열 오탐 없음), 컨테이너 정보, 종류(class/function/…). +- **한계를 그대로 말한다** — LSP 세션이 없는 언어는 답이 없다. 그때 "없다" 가 아니라 "이 언어는 심볼 색인이 없다" 로 답해야 한다. 0.3.0 내내 지킨 선이 여기도 그대로다. + +이 단계만으로 인덱싱의 값어치를 실측할 수 있다. 2단계를 지을지 말지를 여기서 나온 결과로 정한다. + +#### 만들면서 알게 된 것 + +- **`Ctrl+T` 는 TypeScript 프로젝트에서 늘 빈 목록이었다.** `lspClient` 만 물어보는데 TS/JS 는 LSP 세션이 아니라 Monaco 내장 워커가 맡는다. 이 저장소 자체가 그랬다. +- **`getNavigateToItems` 는 Monaco 워커가 노출하지 않는다.** `Missing requestHandler or method` 가 난다. 대신 파일별 `getNavigationTree` 는 주므로, 그것을 모아 워크스페이스 심볼을 만든다. +- TS 트리는 **import 로 끌어온 이름(`alias`)** 과 **`describe("이름", …)` 블록**도 심볼로 준다. 둘 다 "정의가 어디" 의 답이 아니라, 앞은 빼고 뒤는 뒤로 민다. + +#### 실측 (이 저장소, 모델 207개) + +| 이름 | 심볼 | ms | grep | ms | 심볼의 첫 결과 | +|---|---:|---:|---:|---:|---| +| `applyProposal` | 5 | 200 | 27 | 36 | `engine/editApply.ts:41` | +| `flattenNavTree` | 2 | 171 | 25 | 36 | `engine/navTree.ts:38` | +| `writeAtomic` | 1 | 283 | 4 | 43 | `electron/checkpoints.cjs:66` | +| `notifyActiveEditor` | 1 | 150 | 3 | 31 | `ext/extHost.ts:90` | +| `planFileOps` | 6 | 155 | 33 | 29 | `ext/fileOps.ts:55` | +| `makeTouchSet` | 4 | 172 | 13 | 26 | `electron/touchSet.cjs:39` | + +**grep 이 5.5배 많이 준다(105 대 19).** 심볼이 4~6배 느리지만 150~280ms 라 라운드 하나에서는 차이가 없다. 여섯 개 모두 첫 결과가 진짜 정의였다. + +### 2단계 — 우리 인덱스 *(짓지 않기로)* + +1단계 실측을 보고 접는다. 이유는 셋이다. + +- **정확도 문제는 이미 풀렸다.** grep 대비 5.5배 좁고 첫 결과가 정의다. +- **TS/JS 는 Monaco 워커가, 나머지는 LSP 가 이미 덮는다.** 우리가 표를 또 만들면 같은 것을 두 벌 들고 갱신 문제만 새로 떠안는다. +- 남는 구멍은 **LSP 도 TS 도 없는 언어**인데, 그건 인덱스를 짓는 문제가 아니라 **그 언어 서버를 붙이는 문제**다. 값도 그쪽이 크다(정의·참조·진단이 한꺼번에 온다). + +그래서 4절의 갱신 설계도 이번 판에서는 필요 없어진다 — 색인을 우리가 안 들고 있으면 낡을 것도 없다. **워커와 LSP 가 모델을 따라가고, 모델은 이미 앱이 관리한다.** 아래 4절은 나중에 정말 우리 표를 만들게 될 때를 위해 남긴다. + +### 3단계 — 의미 검색은 하지 않는다(이번 판에서는) + +임베딩은 모델·저장소·비용·프라이버시(로컬 임베딩이냐)가 전부 딸려 온다. 심볼 인덱스만으로 어디까지 되는지 본 뒤에 판단한다. **이 판의 범위 밖으로 명시한다.** + +--- + +## 4. 진짜 문제는 갱신이다 + +인덱스는 낡는 순간 거짓말을 시작하고, **그건 지금 상태보다 나쁘다.** 지금은 느려도 늘 사실을 답한다. + +0.3.0 에서 확인한 것 하나가 여기 그대로 걸린다 — **워처는 이름을 다 주지 못한다.** 파일 2500개를 한 번에 만들었더니 `fs.watch` 가 180개만 알려 줬다(윈도우 recursive 워처의 커널 버퍼가 넘친다). 즉 **"워처 알림으로 증분 갱신" 은 그 자체로 못 믿는다.** + +선택지와 값: + +| 방법 | 정확도 | 비용 | 비고 | +|---|---|---|---| +| 워처 증분만 | 낮음 | 싸다 | 위 이유로 단독 불가 | +| 트리 비교(readTree 전후) | 만들어짐·지워짐은 정확 | 중간 | **내용 변경은 못 잡는다** | +| 크기+mtime 비교 | 내용 변경까지 근사 | 중간 | mtime 이 같고 내용이 다른 경우가 드물게 있다 | +| 내용 해시 | 정확 | 비싸다 | 전체 재해싱을 언제 할지가 문제 | + +**초안: 셋을 겹친다.** 워처를 1차 신호로 쓰되 믿지 않고, 트리 비교로 생성·삭제를 확정하고, 열린 파일과 최근 변경분만 mtime·크기로 검증한다. 그리고 **인덱스가 언제 만들어졌는지와 무엇을 못 봤는지를 화면에 남긴다.** + +## 5. 프리뷰인 이유 + +기본은 꺼 둔다. 켠 사람에게는: + +- 인덱스가 마지막으로 갱신된 시각 +- 이번 색인에서 건너뛴 것(깊이·크기·인코딩·언어 미지원) +- 낡았을 가능성이 있으면 그렇다고 + +**모르는 것을 아는 척하지 않는다** — 0.3.0 을 관통한 규칙이고, 인덱스에는 더 중요하다. 인덱스가 틀리면 에이전트가 조용히 잘못된 답을 만들고, 그건 찾을 단서가 없다. + +## 6. 이번 판에 안 하는 것 + +- 임베딩·의미 검색 (위 3단계) +- 참조 그래프 — 1단계 결과를 보고 정한다 +- 인덱스를 디스크에 저장 — 먼저 메모리로 만들고, 다시 켤 때 다시 짓는 비용을 재 본 뒤에 정한다 + +## 7. 남은 것 + +1단계가 끝나고 2단계를 접었으므로, 0.4.0 의 남은 후보는 이렇게 바뀐다. + +- **언어 서버를 더 붙인다** — Go·Rust·Java 등. 심볼·정의·참조·진단이 한꺼번에 따라온다. 지금 구멍이 정확히 여기다. +- **참조 찾기(누가 부르나)** — TS 워커의 `getReferencesAtPosition` 이 있는지부터 확인한다. `getNavigateToItems` 처럼 없을 수 있다. +- **의미 검색** — 3단계 그대로 미룬다. diff --git a/ide/src/App.tsx b/ide/src/App.tsx index 705e1f3..6848467 100644 --- a/ide/src/App.tsx +++ b/ide/src/App.tsx @@ -70,6 +70,7 @@ import { ImagePane, MarkdownPane, isImage, mdToHtml } from "./editor/MediaPane"; import monaco, { languageOf, applyTsPaths, revalidateTs } from "./editor/monacoSetup"; import * as projectModels from "./editor/projectModels"; import * as proposalDeco from "./editor/proposalDeco"; +import * as symbolIndex from "./editor/symbolIndex"; import { typeEdit, reducedMotion } from "./editor/editAnimator"; import * as lspClient from "./editor/lspClient"; import * as lspConv from "./editor/lspConverters"; @@ -3467,6 +3468,25 @@ export class App extends React.Component<{ playOpening?: boolean }, S> { if (!all.length) return (glob ? `(${glob} 에 맞는 파일 없음)` : "(빈 워크스페이스)") + partial; return shown.join("\n") + (cut ? `\n\n… ${cut}개 더 있음(전체 ${all.length}). glob 으로 좁혀서 다시 부르세요.` : "") + partial; } + if (call.name === "find_symbol") { + const query = String(call.input?.query ?? "").trim(); + this.addTool(toolId, agentId, t("sc2.verbSymbol"), query); + if (!query) { + this.setTool(toolId, { st: "done", note: t("sc2.noteError") }); + return "오류: query 가 비었습니다."; + } + const r = await symbolIndex.findSymbols(query, 60); + this.setTool(toolId, { st: "done", note: t("sc2.noteHits", { n: r.hits.length }) }); + // 색인이 아예 없는 것과 찾아봤는데 없는 것은 다르다. 섞으면 모델이 "그런 심볼은 + // 없다" 고 단정하고 넘어간다. + if (!r.sources.length) { + return "이 워크스페이스에는 심볼 색인이 없습니다(TypeScript 프로젝트가 아니거나 해당 언어 서버가 없음). search_files 로 찾으세요."; + } + if (!r.hits.length) { + return `"${query}" 로 찾은 심볼 없음. 색인은 있습니다(${r.sources.join(", ")}) — 이름이 다르거나 색인이 없는 언어의 파일일 수 있으니 search_files 도 해 보세요.`; + } + return r.hits.map(h => `${h.rel}:${h.line}:${h.column} ${h.container ? h.container + "." : ""}${h.name}`).join(String.fromCharCode(10)); + } if (call.name === "search_files") { const query = String(call.input?.query ?? ""); this.addTool(toolId, agentId, t("sc2.verbSearch"), query); @@ -5414,8 +5434,16 @@ ${(r.output || "").slice(0, 2000)}`; this.setState({ symLoading: true }); this._symTimer = setTimeout(async () => { try { - const raw = await lspClient.workspaceSymbols(q); - const results = lspConv.toWorkspaceSymbols(raw).slice(0, 200); + // 예전엔 lspClient 만 물어봤다. TS/JS 는 LSP 세션이 아니라 Monaco 내장 워커가 + // 맡으므로, TypeScript 프로젝트에서는 Ctrl+T 가 늘 빈 목록이었다(이 저장소가 그렇다). + const r = await symbolIndex.findSymbols(q, 200); + // uri 자리에는 진짜 파일 uri 를 넣는다 — 아래 jumpToSymbol·경로 표시가 그걸 판다. + const wsRoot = (this.state.workspace?.root ?? "").replace(/\\/g, "/").replace(/\/+$/, ""); + const results = r.hits.map(h => ({ + name: h.name, container: h.container, kind: h.kind, + uri: monaco.Uri.file(wsRoot + "/" + h.rel).toString(), + range: { startLineNumber: h.line, startColumn: h.column, endLineNumber: h.line, endColumn: h.column }, + })); // 현재 쿼리와 여전히 일치할 때만 반영 if (this.state.symQuery.trim() === q) this.setState({ symResults: results, symLoading: false }); } catch { this.setState({ symResults: [], symLoading: false }); } diff --git a/ide/src/ai/claude.ts b/ide/src/ai/claude.ts index 6724659..94f994b 100644 --- a/ide/src/ai/claude.ts +++ b/ide/src/ai/claude.ts @@ -304,6 +304,21 @@ export const WORKSPACE_TOOLS: ToolDef[] = [ required: ["query"], }, }, + { + name: "find_symbol", + description: + "이름으로 심볼(함수·클래스·타입·변수)의 정의 자리를 찾는다. " + + "search_files 는 텍스트를 찾으므로 주석·문자열·호출부까지 다 걸리지만, 이것은 정의만 준다. " + + "'X 가 어디 정의돼 있지' 류의 질문에는 이쪽이 정확하고 빠르다. " + + "언어에 색인이 없으면 결과 대신 그렇다고 알려 준다 — 그때는 search_files 로 가라.", + input_schema: { + type: "object", + properties: { + query: { type: "string", description: "심볼 이름 또는 그 일부" }, + }, + required: ["query"], + }, + }, { name: "read_file", description: diff --git a/ide/src/editor/lspClient.ts b/ide/src/editor/lspClient.ts index 4b431e2..00d1730 100644 --- a/ide/src/editor/lspClient.ts +++ b/ide/src/editor/lspClient.ts @@ -177,6 +177,14 @@ export async function executeCommand(languageId: string, command: string, args: } /** 살아있는 모든 세션에 workspace/symbol 질의 후 병합 (Ctrl+T) — 새 서버는 안 띄움 */ +/** 살아 있는 세션 수. 0 이면 이 워크스페이스에는 LSP 색인이 없다 — + * "심볼 없음" 과 "찾아볼 곳이 없음" 을 가르는 데 쓴다. */ +export function liveSessionCount(): number { + let n = 0; + for (const [, s] of sessions) if (!s.dead) n++; + return n; +} + export async function workspaceSymbols(query: string): Promise { const out: any[] = []; for (const [, s] of sessions) { diff --git a/ide/src/editor/projectModels.ts b/ide/src/editor/projectModels.ts index 1a1f176..3681ddf 100644 --- a/ide/src/editor/projectModels.ts +++ b/ide/src/editor/projectModels.ts @@ -23,6 +23,9 @@ function uriFor(root: string, rel: string): monaco.Uri { return monaco.Uri.file(base + "/" + rel); } +/** 지금 열린 워크스페이스 루트. 모델이 없는 uri 를 상대 경로로 뗄 때 필요하다. */ +export function currentRootPath(): string | null { return currentRoot; } + export function relFor(uriString: string): string | null { if (!currentRoot) return null; for (const [rel, u] of relIndex) if (u === uriString) return rel; diff --git a/ide/src/editor/symbolIndex.ts b/ide/src/editor/symbolIndex.ts new file mode 100644 index 0000000..9ebc0c3 --- /dev/null +++ b/ide/src/editor/symbolIndex.ts @@ -0,0 +1,137 @@ +// 워크스페이스 심볼 찾기 — 이름으로 정의 자리를 찾는다. +// +// 두 군데서 온다. +// 1. LSP 세션 (pyright 등) — workspace/symbol +// 2. Monaco 의 TS 워커 — 파일별 navigation tree 를 모아서 +// +// 2번이 빠져 있었다. Ctrl+T 는 lspClient 만 물어봤는데 TS/JS 는 LSP 세션이 아니라 +// Monaco 내장 워커가 맡는다. 그래서 TypeScript 프로젝트에서 Ctrl+T 가 늘 빈 목록이었다. +// 이 저장소 자체가 그렇다. +// +// 색인이 아예 없어서 못 찾은 것과 찾아봤는데 없는 것은 다르다. 그 둘을 갈라서 낸다 — +// 안 그러면 "그런 심볼 없음" 이 "그 언어는 지원 안 함" 을 덮어 버린다. + +import monaco from "./monacoSetup"; +import * as lspClient from "./lspClient"; +import * as projectModels from "./projectModels"; +import { flattenNavTree, isTestPath } from "../engine/navTree"; + +export interface SymbolHit { + name: string; + /** 클래스명 등 담고 있는 것. 없으면 빈 문자열. */ + container: string; + /** LSP SymbolKind 번호. 모르면 0. */ + kind: number; + rel: string; + line: number; + column: number; + /** 어디서 왔나 — 한계를 설명할 때 쓴다. */ + from: "ts" | "lsp"; +} + +export interface SymbolAnswer { + hits: SymbolHit[]; + /** 실제로 물어본 곳. 비어 있으면 이 워크스페이스에는 심볼 색인이 없다. */ + sources: ("ts" | "lsp")[]; + /** 모델이 너무 많아 다 훑지 못했다 — "없다" 를 단정하면 안 되는 경우. */ + capped: boolean; +} + +/** 워커가 주는 파일 이름(모델 uri) → 워크스페이스 상대 경로 */ +function relOfUri(uri: string): string | null { + return projectModels.relFor(uri); +} + +/** 한 번에 훑을 모델 수. 넘으면 훑다 만 것이므로 그렇다고 말한다. */ +export const TS_MODEL_CAP = 400; + +async function fromTypescript(query: string, max: number): Promise<{ hits: SymbolHit[]; available: boolean; capped: boolean }> { + const ts: any = (monaco.languages as any).typescript; + if (!ts?.getTypeScriptWorker) return { hits: [], available: false, capped: false }; + const models = monaco.editor.getModels() + .filter(m => !m.isDisposed() && /typescript|javascript/.test(m.getLanguageId()) && projectModels.relFor(m.uri.toString())); + if (!models.length) return { hits: [], available: false, capped: false }; + + // Monaco 의 워커 프록시는 getNavigateToItems 를 노출하지 않는다(실측: "Missing + // requestHandler or method"). 대신 파일별 navigation tree 를 주므로, 그걸 모아 + // 워크스페이스 심볼을 만든다. 모델은 preload 가 세워 두어 안 연 파일도 들어온다. + let getWorker: any; + try { getWorker = await ts.getTypeScriptWorker(); } catch { return { hits: [], available: true, capped: false }; } + + const q = query; + const scan = models.slice(0, TS_MODEL_CAP); + const out: SymbolHit[] = []; + await Promise.all(scan.map(async (m) => { + if (out.length >= max) return; + const rel = projectModels.relFor(m.uri.toString()); + if (!rel) return; + try { + const client = await getWorker(m.uri); + const tree = await client.getNavigationTree(m.uri.toString()); + for (const f of flattenNavTree(tree, q)) { + const pos = m.getPositionAt(f.offset); + out.push({ name: f.name, container: f.container, kind: f.kind, rel, line: pos.lineNumber, column: pos.column, from: "ts" }); + } + } catch { /* 이 파일은 건너뛴다 — 워커가 아직 모르거나 파싱 실패 */ } + })); + return { hits: out, available: true, capped: models.length > scan.length }; +} + +async function fromLsp(query: string): Promise<{ hits: SymbolHit[]; available: boolean }> { + const raw = await lspClient.workspaceSymbols(query).catch(() => []); + const hits: SymbolHit[] = []; + for (const s of raw ?? []) { + const loc = s?.location ?? s; + const uri = String(loc?.uri ?? ""); + const rel = relOfUri(uri) ?? uriToRel(uri); + if (!rel) continue; + const start = loc?.range?.start ?? { line: 0, character: 0 }; + hits.push({ + name: String(s?.name ?? ""), + container: String(s?.containerName ?? ""), + kind: Number(s?.kind ?? 0), + rel, + line: Number(start.line ?? 0) + 1, + column: Number(start.character ?? 0) + 1, + from: "lsp", + }); + } + return { hits, available: lspClient.liveSessionCount() > 0 }; +} + +/** LSP 는 파일 uri 를 그대로 준다 — 모델이 없을 수도 있어 경로에서 직접 뗀다. */ +function uriToRel(uri: string): string | null { + const root = projectModels.currentRootPath(); + if (!root) return null; + let p = String(uri || ""); + if (/^file:\/\//i.test(p)) { + try { p = decodeURIComponent(p.replace(/^file:\/\/\/?/i, "")); } catch { /* 망가진 인코딩 */ } + } + const a = p.replace(/\\/g, "/").toLowerCase(); + const b = root.replace(/\\/g, "/").toLowerCase(); + if (!a.startsWith(b)) return null; + return p.replace(/\\/g, "/").slice(root.length).replace(/^\/+/, "") || null; +} + +/** 이름으로 심볼을 찾는다. 두 통로에 모두 물어보고 합친다. */ +export async function findSymbols(query: string, max = 100): Promise { + const q = String(query ?? "").trim(); + if (!q) return { hits: [], sources: [], capped: false }; + const [ts, lsp] = await Promise.all([fromTypescript(q, max), fromLsp(q)]); + const sources: ("ts" | "lsp")[] = []; + if (ts.available) sources.push("ts"); + if (lsp.available) sources.push("lsp"); + + // 같은 자리를 두 통로가 다 주면 하나만 남긴다. + const seen = new Set(); + const hits: SymbolHit[] = []; + for (const h of [...ts.hits, ...lsp.hits]) { + const key = h.rel + ":" + h.line + ":" + h.name; + if (seen.has(key)) continue; + seen.add(key); + hits.push(h); + } + // 테스트 파일은 뒤로. describe("이름", …) 이 심볼로 잡혀 첫 답이 테스트가 되곤 했다. + const ordered = [...hits.filter(h => !isTestPath(h.rel)), ...hits.filter(h => isTestPath(h.rel))]; + return { hits: ordered.slice(0, max), sources, capped: ts.capped }; +} diff --git a/ide/src/engine/navTree.test.ts b/ide/src/engine/navTree.test.ts new file mode 100644 index 0000000..8bc3d39 --- /dev/null +++ b/ide/src/engine/navTree.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { flattenNavTree, isTestPath, TS_KIND } from "./navTree"; + +/** 실제 워커가 주는 모양 그대로 — 맨 위는 파일 자신이고 이름에 따옴표가 붙는다. */ +const TREE = { + text: '"auth"', kind: "module", spans: [{ start: 0, length: 200 }], + childItems: [ + { + text: "TokenVault", kind: "class", spans: [{ start: 7, length: 120 }], + childItems: [ + { text: "secret", kind: "property", spans: [{ start: 40, length: 20 }] }, + { text: "verifyToken", kind: "method", spans: [{ start: 66, length: 50 }] }, + ], + }, + { text: "refreshSession", kind: "function", spans: [{ start: 130, length: 40 }] }, + { text: "SessionInfo", kind: "interface", spans: [{ start: 175, length: 25 }] }, + ], +}; + +describe("flattenNavTree", () => { + it("파일 노드 자신은 심볼이 아니다", () => { + expect(flattenNavTree(TREE, "").some(s => s.name === '"auth"')).toBe(false); + }); + + // 이 케이스 때문에 이 파일이 있다. 파일 노드가 부모를 안 남기니 그 자식들도 + // 부모가 0개가 되는데, 그걸로 최상위를 판정하면 전부 걸러져 결과가 늘 빈다. + it("최상위 심볼이 파일 노드로 오해받아 사라지지 않는다", () => { + const names = flattenNavTree(TREE, "").map(s => s.name); + expect(names).toContain("TokenVault"); + expect(names).toContain("refreshSession"); + expect(names).toContain("SessionInfo"); + }); + + it("중첩된 심볼도 낸다", () => { + expect(flattenNavTree(TREE, "").map(s => s.name)).toContain("verifyToken"); + }); + + it("담고 있는 것을 점으로 잇는다 — 파일 이름은 빼고", () => { + const m = flattenNavTree(TREE, "verifyToken")[0]!; + expect(m.container).toBe("TokenVault"); + }); + + it("최상위 심볼의 container 는 비어 있다", () => { + expect(flattenNavTree(TREE, "refreshSession")[0]!.container).toBe(""); + }); + + it("이름으로 거른다(대소문자 무시)", () => { + expect(flattenNavTree(TREE, "verifytoken").map(s => s.name)).toEqual(["verifyToken"]); + expect(flattenNavTree(TREE, "VERIFY").map(s => s.name)).toEqual(["verifyToken"]); + }); + + it("일부만 맞아도 걸린다 — 앞에서 맞는 쪽이 먼저다", () => { + expect(flattenNavTree(TREE, "Session").map(s => s.name)).toEqual(["SessionInfo", "refreshSession"]); + }); + + it("빈 질의는 전부 낸다", () => { + expect(flattenNavTree(TREE, "")).toHaveLength(5); + }); + + it("오프셋을 그대로 전한다", () => { + expect(flattenNavTree(TREE, "verifyToken")[0]!.offset).toBe(66); + }); + + it("kind 를 LSP 번호로 옮긴다", () => { + const by = (n: string) => flattenNavTree(TREE, n)[0]!.kind; + expect(by("TokenVault")).toBe(TS_KIND.class); + expect(by("verifyToken")).toBe(TS_KIND.method); + expect(by("SessionInfo")).toBe(TS_KIND.interface); + }); + + it("모르는 kind 는 0", () => { + const t = { text: "f", kind: "x", childItems: [{ text: "weird", kind: "무엇", spans: [{ start: 1 }] }] }; + expect(flattenNavTree(t, "weird")[0]!.kind).toBe(0); + }); + + it("spans 가 없어도 버리지 않는다", () => { + const t = { text: "f", childItems: [{ text: "noSpan", kind: "const" }] }; + const r = flattenNavTree(t, "noSpan"); + expect(r).toHaveLength(1); + expect(r[0]!.offset).toBe(0); + }); + + // "X 가 어디 정의돼 있지" 에 import 줄을 내미는 것은 답이 아니다. + it("import 로 끌어온 이름(alias)은 정의가 아니라 뺀다", () => { + const t = { + text: '"app"', kind: "module", + childItems: [ + { text: "applyProposal", kind: "alias", spans: [{ start: 9 }] }, + { text: "applyProposal", kind: "function", spans: [{ start: 300 }] }, + ], + }; + const r = flattenNavTree(t, "applyProposal"); + expect(r).toHaveLength(1); + expect(r[0]!.offset).toBe(300); + }); + + it("정확히 같은 이름을 먼저 낸다", () => { + const t = { + text: "f", + childItems: [ + { text: "findSymbolsLater", kind: "function", spans: [{ start: 10 }] }, + { text: "xxfindSymbols", kind: "function", spans: [{ start: 20 }] }, + { text: "findSymbols", kind: "function", spans: [{ start: 30 }] }, + ], + }; + expect(flattenNavTree(t, "findSymbols").map(s => s.name)) + .toEqual(["findSymbols", "findSymbolsLater", "xxfindSymbols"]); + }); + + it("깨진 입력에 터지지 않는다", () => { + expect(flattenNavTree(null, "x")).toEqual([]); + expect(flattenNavTree(undefined, "x")).toEqual([]); + expect(flattenNavTree("문자열", "x")).toEqual([]); + expect(flattenNavTree({ text: "f", childItems: "배열아님" }, "x")).toEqual([]); + }); + + it("이름 없는 노드는 건너뛰되 그 자식은 본다", () => { + const t = { text: "f", childItems: [{ text: "", kind: "module", childItems: [{ text: "inner", kind: "const", spans: [{ start: 5 }] }] }] }; + expect(flattenNavTree(t, "inner").map(s => s.name)).toEqual(["inner"]); + }); +}); + +describe("isTestPath", () => { + it("테스트 파일을 알아본다", () => { + for (const p of ["src/a.test.ts", "src/a.spec.tsx", "a.test.js", "src/__tests__/a.ts", "test/a.ts", "tests/deep/a.ts"]) { + expect(isTestPath(p)).toBe(true); + } + }); + it("아닌 것을 테스트로 보지 않는다", () => { + for (const p of ["src/a.ts", "src/latest.ts", "src/contest.ts", "src/protest/a.ts", "src/testing.ts"]) { + expect(isTestPath(p)).toBe(false); + } + }); +}); diff --git a/ide/src/engine/navTree.ts b/ide/src/engine/navTree.ts new file mode 100644 index 0000000..1f3a309 --- /dev/null +++ b/ide/src/engine/navTree.ts @@ -0,0 +1,85 @@ +// TypeScript 언어 서비스가 주는 navigation tree 를 심볼 목록으로 편다. +// +// Monaco 의 워커 프록시는 `getNavigateToItems`(이름으로 찾기)를 노출하지 않는다. +// 실측하면 "Missing requestHandler or method" 가 난다. 대신 파일별 navigation tree 는 +// 주므로, 그것을 모아 워크스페이스 심볼을 만든다. +// +// 여기는 트리를 펴는 일만 한다 — monaco 도 워커도 모른다. 줄·칸 변환은 모델을 쥔 +// 쪽이 하고, 이 파일은 오프셋까지만 낸다. + +/** TS navigation kind 문자열 → LSP SymbolKind 번호(대략). 화면 아이콘이 그 표를 쓴다. */ +export const TS_KIND: Readonly> = { + class: 5, interface: 11, enum: 10, function: 12, method: 6, property: 7, + variable: 13, const: 14, module: 2, type: 26, alias: 26, constructor: 9, parameter: 13, + getter: 6, setter: 6, "enum member": 22, "local function": 12, "local class": 5, +}; + +/** 정의가 아닌 것들. import 로 끌어온 이름은 TS 가 "alias" 로 준다 — + * "X 가 어디 정의돼 있지" 에 import 줄을 내미는 것은 답이 아니다. + * (실측: applyProposal 을 찾으면 editApply.test.ts 의 import 줄이 첫 결과로 나왔다.) */ +const NOT_A_DEFINITION = new Set(["alias", "import"]); + +export interface FlatSymbol { + name: string; + /** 담고 있는 것들을 점으로 이은 것. 최상위면 빈 문자열. */ + container: string; + kind: number; + /** 파일 안 오프셋. 부르는 쪽이 줄·칸으로 바꾼다. */ + offset: number; +} + +/** + * 트리를 펴서 이름이 `query` 를 품은 심볼만 낸다(대소문자 무시). + * + * 맨 위 노드는 파일 자신이라(`"app"` / module) 심볼이 아니다. 그 판정을 **부모 개수로 + * 하면 안 된다** — 파일 노드가 부모를 안 남기므로 그 자식들도 부모가 0개가 되어 + * 전부 "맨 위" 로 걸러진다. 실제로 그렇게 만들었다가 결과가 늘 비었다. 깊이로 센다. + */ +export function flattenNavTree(root: unknown, query: string): FlatSymbol[] { + const q = String(query ?? "").toLowerCase(); + const out: FlatSymbol[] = []; + walk(root, [], 0); + // 이름이 정확히 같은 것을 먼저. 부분 일치가 위에 오면 정작 찾던 것을 스크롤해야 한다. + if (q) out.sort((a, b) => rank(a.name) - rank(b.name)); + return out; + + function rank(name: string): number { + const n = name.toLowerCase(); + if (n === q) return 0; + if (n.startsWith(q)) return 1; + return 2; + } + + function walk(node: any, parents: string[], depth: number): void { + if (!node || typeof node !== "object") return; + const name = String(node.text ?? ""); + const isFile = depth === 0; + const kindStr = String(node.kind ?? ""); + if (!isFile && name && !NOT_A_DEFINITION.has(kindStr) && (!q || name.toLowerCase().includes(q))) { + out.push({ + name, + container: parents.join("."), + kind: TS_KIND[kindStr] ?? 0, + offset: firstOffset(node), + }); + } + const next = isFile ? parents : [...parents, name]; + const kids = Array.isArray(node.childItems) ? node.childItems : []; + for (const c of kids) walk(c, next, depth + 1); + } +} + +/** 테스트 파일인가 — 정의를 찾는 사람은 보통 테스트를 찾는 게 아니다. + * + * TS 의 navigation tree 는 `describe("applyProposal", …)` 같은 블록도 그 문자열을 + * 이름으로 삼아 심볼로 준다. 그래서 "applyProposal 어디 있어" 의 첫 답이 테스트 + * 파일이 되곤 했다(실측). 지우지는 않는다 — 테스트를 찾는 경우도 있으니 뒤로만 민다. */ +export function isTestPath(rel: string): boolean { + return /(^|[\/.])(test|spec)\.[cm]?[jt]sx?$/i.test(rel) || /(^|\/)(__tests__|tests?)\//i.test(rel); +} + +/** 심볼이 시작하는 자리. spans 가 없으면 0 — 그래도 파일은 맞으니 버리지 않는다. */ +function firstOffset(node: any): number { + const n = Number(node?.spans?.[0]?.start); + return Number.isFinite(n) && n >= 0 ? n : 0; +} diff --git a/ide/src/i18n/dict/sc2.ts b/ide/src/i18n/dict/sc2.ts index e8c6fbc..bb23f97 100644 --- a/ide/src/i18n/dict/sc2.ts +++ b/ide/src/i18n/dict/sc2.ts @@ -42,6 +42,7 @@ export const dict: Record