diff --git a/doc/history/2026-09-18-11차.md b/doc/history/2026-09-18-11차.md
index 4f74515..b0704ed 100644
--- a/doc/history/2026-09-18-11차.md
+++ b/doc/history/2026-09-18-11차.md
@@ -160,6 +160,7 @@ A-1 에서 값으로 확인한 것 — 지운 파일은 "삭제되어 건너뜀
- 개요 "로컬 커밋" 숫자(버전 14 는 129).
- 이 뒤 11차 항목(F 등)에서 바뀌는 화면 · 설정 · 숫자.
- F-1: 칸 이름 `LOCAL(Mine)` · `REMOTE(Theirs)`, 칸 머리 "어느 쪽 · 어느 파일"(MERGED · 두 칸 LOCAL 은 "(저장할 파일)", mergetool 은 임시 파일 이름), 긴 이름은 가운데 줄임 · 툴팁. 머지 주석 모양 `[사람] 날짜 출처 설명`(출처 = 로그의 말 — Theirs(…) · Mine(…) · REMOTE(…) · BASE(…)), **Take Mine 도 주석**, 두 칸 로그 `BASE(해시) 반영` · 브랜치 3-Way 로그 `REMOTE(브랜치) 반영`. 매뉴얼의 주석 예시 · 칸 설명 · 해결 기록이 남는 곳 표. 숫자 1,312건.
+- F-2: [이 메서드 반영](C# · C/C++, 클래스 멤버 통째, 단추 아래 한 줄에 잡은 범위 · 못 잡은 까닭), 덩어리 · 메서드 반영은 주석 한 줄 · 로그 한 줄 · Undo 한 번. 매뉴얼의 반영 단추 설명에. 숫자 23벌 1,395건.
## B-1. 배포 묶음을 다시 만들고 꼭 필요한 파일을 가림 (2026-09-22, 완료)
@@ -546,6 +547,46 @@ libgit2 의 다듬기(`PrettifyMessages`)를 켜면 **손대지 않은 뒤 커
- Take Mine 주석이 새로 들어가므로 같은 순서로 풀어도 결과 파일이 전보다 주석 줄 수만큼 길다(무시 규칙이 켜져 있으면 차이로 세지 않음). 코드 줄 · 줄끝 · BOM 은 그대로.
- 파일 단위 Accept(충돌 탭)는 여전히 소스에 주석을 남기지 않는다 — 병합 커밋 본문(8차 F-1)이 그 기록이다.
+## F-2. 메서드 단위로 골라 반영 · 덩어리 반영의 주석 한 줄 (2026-09-22, 완료)
+
+사용자 결정(2026-09-22): 언어는 **C# + C/C++**, "메서드" 는 **클래스 멤버 전부**(메서드 · 생성자 · 소멸자 · 속성 · 인덱서 · 이벤트 · 연산자 · 필드 · enum — 로컬 함수 · 람다 안이면 감싸는 멤버 통째), 덩어리 반영의 주석도 **첫 줄 위 한 줄로**.
+
+### 고치기 전 — 화면에서 (`f2-probe.ps1`)
+
+- 지시서에는 "덩어리를 반영하면 머지 주석은 덩어리 첫 줄 위에 한 줄" 이라고 적었지만 사실과 달랐다 — 이어진 두 줄 차이에 [이 덩어리 반영] → **주석이 줄마다 하나씩 두 줄**. `AcceptBlock` 이 줄마다 `Apply` 를 불러 줄마다 주석 · 로그 · 되돌리기 한 건씩이었다.
+- 메서드 단위 반영은 없었다.
+
+### 만든 것
+
+- **`MemberRanges`**(새 서비스, Roslyn 없음) — 파일 전체를 훑어 주석 · 문자열을 가리고(C# 축자 · 보간 · raw `"""`, C++ raw `R"x(...)x"` · 숫자 구분자 `1'000` · 문자 `'{'`), 중괄호 짝으로 멤버 범위(시작 · 이름 줄 · 끝)를 잡는다.
+ - `#if` 는 갈래(#elif · #else)마다 `#if` 자리의 상태에서 다시 읽고 `#endif` 뒤에는 첫 갈래 상태로 잇는다 — 여는 줄만 갈린 메서드도 짝이 맞고, 갈래마다 따로 있는 메서드는 따로 잡힌다.
+ - 멤버 몸통이 아닌 중괄호: 필드 초기화(`= new X { }`) · 람다 · switch 식(`=>` 뒤) · 속성 초기화(`{ get; set; } = 1;`) · C++ 생성자 초기화 목록(`items_{1, 2}`) · 특성 안.
+ - 시작은 바로 위 설명 주석(`///` · `//`) · 특성 · 감싸는 `#if` 까지. **머지 주석에서 멈춘다**(이력이라 반영으로 지우면 안 됨). 클래스 · 네임스페이스 선언 줄은 어느 멤버에도 들지 않는다. 짝이 틀어져 겹치면 뒤의 것을 버린다(틀린 범위 대신 없음).
+- **[이 메서드 반영]**(덩어리 반영 아래) — 캐럿이 놓인 멤버를 반영해 올 쪽(두 칸 BASE · 네 칸 REMOTE)에서 찾아, 그 범위의 행(사이의 내 쪽에만 있는 줄 포함)을 덩어리 반영과 같은 규칙으로 반영. 네 칸은 Take Theirs 와 같아 저쪽이 바꾼 줄 · 충돌 줄만, 내 쪽만 고친 줄은 그대로.
+ - 단추 아래 한 줄: 잡으면 `Target · BASE 7~17행 · 바뀐 줄 4`, 못 잡으면 까닭(`캐럿이 BASE 쪽 메서드 안에 있지 않습니다` · `C# · C/C++ 파일에서만` · `… 안에 가져올 차이가 없습니다`) — 단추는 꺼지고 툴팁도 같은 말.
+- **묶음 반영**(`ApplyGroup`) — 덩어리 · 메서드 모두: 머지 주석은 **한 줄**(메서드는 이름 줄 위, 덩어리는 첫 줄 위, 출처는 F-1 과 같은 말), 로그도 한 줄(`7~11행 메서드 Target BASE(left.cs) 반영 (4줄)` · `10~11행 덩어리 BASE(left.cs) 반영 (3줄)`), **Undo · Redo 한 번에 통째로**(편집에 묶음 번호). 주석만 든 편집(`NoteOnly`)은 "직접 수정" · "편집 N건" 에 세지 않는다. 머지 주석 줄은 건드리지 않는다.
+
+### 확인한 것
+
+- 하네스 **MemberTest**(새, 83건): C# 시험 파일 29개 멤버 · C++ 13개 멤버의 시작 · 이름 줄 · 끝이 표시 주석과 모두 같다(한 줄 메서드 · 중첩 중괄호 · 문자열 · 축자 · 보간 · raw 안 중괄호 · 주석 안 중괄호 · `#if` 로 여는 줄이 갈린 메서드 · 갈래마다 다른 메서드 · 로컬 함수 · 람다 필드 · switch 식 · 속성 초기화 · 이벤트 · 인덱서 · 연산자 · 머지 주석 위 멤버 · 설명 주석 · 중첩 클래스 · C++ 템플릿 · 초기화 목록 · `operator=` · raw 문자열 · 숫자 구분자 · `extern "C"` · `enum class` · `#ifdef`), 기대하지 않은 범위 없음 · 겹침 없음, 이름, 캐럿 행 → 멤버 · 멤버 → 화면 행, 짝이 틀어진 파일에서 멈추지 않음. 23벌 **1,395건**(1,312 → +83) · 플랫폼 24건 · 빌드 경고 0 · 오류 0.
+- 화면(`f2-verify.ps1`, `--diff` · `--merge` 로 C# 짝, BOM · CRLF):
+
+| 길 | 결과 |
+|---|---|
+| 두 칸 · 주석 켬 | 연 직후 단추 꺼짐 + `캐럿이 BASE 쪽 메서드 안에 있지 않습니다…`, [다음 차이] 뒤 `Target · BASE 7~17행 · 바뀐 줄 4` · 켜짐. 반영 → 머지 주석 **1줄**(설명 주석과 선언 줄 사이, 메서드 이름 위) · 범례 반영 4 · 직접 수정 0 · 편집 4건. **Undo 한 번** → 주석 0 · 남은 차이 5줄(처음과 같음), Redo 한 번 → 다시. 저장한 파일: Target 은 BASE 그대로, 다른 메서드 Other 는 내 쪽 그대로 |
+| 두 칸 · 주석 끔 | 메서드 반영 저장본 = **같은 네 줄을 하나씩 골라 Accept 한 저장본 = 기대(Target = BASE, 나머지 = 내 쪽, BOM · CRLF 그대로)** — SHA-256 같음 |
+| 두 칸 · 덩어리 | [이 덩어리 반영] 세 줄 → 머지 주석 **1줄**, Undo 한 번에 처음으로 |
+| 세 칸 `--merge` | `Target · REMOTE 7~18행 · 바뀐 줄 2` → 주석 `Theirs(Station_REMOTE_1.cs)` 한 줄, 저장본 Target = REMOTE(`limit = 150` · `Trace(power);`), 내가 고친 Other(`return 2;`)는 그대로 |
+
+- 회귀: 11차 E-1 병합 길(마우스 확인 B-1 · 스크린숏만 끔) — 누른 Take 18 = 로그 18, 병합 커밋 본문 26개, 줄끝 · BOM 바뀐 파일 0 · 충돌 표식 0 · class 검사 0.
+ - E-1 의 1) "15개 골라 Accept Theirs" 가 가끔 24 → 23 에서 멈췄다(F-1 · F-2 회차에 모두 12번 중 3번). 따로 재 보니(`f2-multisel.ps1`) 첫 Select 뒤 AddToSelection 이 하나도 안 먹은 것 — 충돌 목록에서 고를 때 앱은 단추 상태만 새로 고치고 F-1 · F-2 가 고친 파일 비교 쪽은 타지 않는다. F-1 전 빌드(`2f36f1b`, 임시 폴더에 꺼내 빌드) 5번 · 지금 빌드 5번 모두 15개 골라짐. 병합 직후 목록이 한 번 더 그려지며 시험 도구가 잡아 둔 항목이 낡은 것으로 보고, **시험 도구가 고른 수를 세어 모자라면 목록을 다시 잡아 다시 고르게** 고쳤다(`d7lib.ps1`, 제품 아님).
+
+### 남은 것 · 알아 둘 것
+
+- [전체 반영](두 칸) · [자동 병합](네 칸)은 지금도 줄마다 주석이 붙는다 — 이번에 정한 것은 덩어리 · 메서드. 같은 규칙(한 줄)으로 맞출지는 정하지 않았다.
+- 범위는 반영해 올 쪽(BASE · REMOTE) 글로 잡는다. 그쪽에 없는 메서드(내 쪽에서만 새로 만든 메서드)는 잡히지 않아 단추가 꺼진다.
+- XAML · 그 밖의 언어는 단추가 꺼지고 까닭을 말한다(사용자 결정: C# + C/C++).
+
## F-12. C# 코드 스타일 맞춤의 기본값을 AURORA 로 (2026-09-18, 완료)
사용자: "기본값은 원본형태를 따르고 C#인 경우에 AURORA 스타일로 하자" → 물어서 정한 것은 **제품 기본값 AURORA · C# 파일만 · 저장 동작은 지금 그대로**(켜져 있으면 저장도 그 스타일로 쓴다).
diff --git a/doc/history/changelog.md b/doc/history/changelog.md
index 9e75491..8f0af71 100644
--- a/doc/history/changelog.md
+++ b/doc/history/changelog.md
@@ -8,6 +8,7 @@
| 날짜 | 항목 | 커밋 |
|---|---|---|
+| 2026-09-22 | **11차 F-2 (추가 요청)** — **[이 메서드 반영]**: 캐럿이 놓인 클래스 멤버(메서드 · 생성자 · 속성 · 이벤트 · 연산자 · 필드 · enum, 로컬 함수 안이면 감싸는 멤버)를 통째로 반영(사용자 결정: C# + C/C++, 멤버 전부). Roslyn 없이 `MemberRanges` 가 주석 · 문자열(축자 · 보간 · raw · C++ raw · 숫자 구분자)을 가리고 `#if` 갈래마다 다시 읽어 중괄호 짝으로 범위를 잡는다 — 못 잡으면 단추를 끄고 단추 아래 한 줄에 까닭. 덩어리 · 메서드 반영은 머지 주석 한 줄(메서드 이름 위 · 덩어리 첫 줄 위) · 로그 한 줄 · Undo 한 번(묶음 번호) — [이 덩어리 반영] 이 줄마다 주석을 달던 것도 고침(사용자 결정). 메서드 반영 저장본 = 같은 줄 줄 단위 반영 저장본(바이트). 하네스 MemberTest 83건(23벌 1,395건) | 아래 |
| 2026-09-22 | **11차 F-1 (추가 요청)** — 칸 이름을 `LOCAL(Mine)` · `REMOTE(Theirs)` 로(사용자 결정: 표준 용어 + 영문 쪽 이름), 칸 머리마다 한 줄 "어느 쪽 · 어느 파일" — 파일 이름이 BASE 칸에만 있었고, mergetool 에서 Mine 칸이 MERGED 이름을 보였다. 좁으면 가운데를 줄이고 툴팁에 전체(`MiddleTrim`). 머지 주석에 출처(사용자 결정: 쪽 이름 + 브랜치/해시, 출처 먼저 설명 뒤) `[사람] 날짜 Theirs(feature/theirs)` — 로그와 같은 말이 되게 두 칸 로그 `BASE(해시) 반영` · 브랜치 3-Way 로그 `REMOTE(브랜치) 반영` 으로. Take Mine 도 주석을 남긴다(처음 한 번, 되돌리면 함께 빠짐). 무시 조건은 `[사람] 날짜` 뒤를 따지지 않아 코드 그대로 — 옛 · 새 · 출처만 다른 주석을 하네스로 굳힘. 네 길(병합 네 칸 · 두 칸 · `--merge` · git mergetool) 화면 확인, E-1 병합 길 회귀 없음. 하네스 +52(1,312건) | 아래 |
| 2026-09-22 | **11차 A-4** — 변경분을 폴더에 더해 **ZIP 으로도** 뽑는다(사용자 결정). 뽑은 폴더 옆에 같은 이름 `.zip`, 안에 폴더와 안내 파일을 나란히(풀면 폴더로 뽑은 것과 같은 모양). 바이트 · 경로 그대로, 한글 이름은 UTF-8 표시, 반쯤 된 ZIP 은 남기지 않음, 못 만들어도 추출은 성공. 같은 이름 ZIP 이 남아 있으면 새 이름을 고름. 아홉 경우를 다시 뽑아 ZIP 8/8 을 Windows tar 로 풀어 폴더와 경로 · 해시가 같음. 하네스 ReportTest +13(70건). .NET Framework 기본 기능이라 배포 묶음 파일은 그대로 | 아래 |
| 2026-09-22 | **11차 D-1** — 시험 도구 둘(제품 아님). 줄끝 검사 스크립트가 부모 하나인 커밋(rebase · cherry-pick · revert)에서 없는 둘째 부모로 `git merge-base` 를 불러 오류를 찍던 것 → 첫 부모와만 견준다고 적고 부르지 않음. 파일 비교 탭 요약을 읽는 함수가 제품의 "차이 없음" 을 못 읽어 커밋 뒤 요약이 빈칸이던 것 → 읽음. rebase 한 길을 다시 돌려 오류 줄 0 · 요약 "차이 없음". 제품 코드 변경 없음 | 아래 |
diff --git a/doc/진행상황.md b/doc/진행상황.md
index a11c4a1..1da8d90 100644
--- a/doc/진행상황.md
+++ b/doc/진행상황.md
@@ -371,7 +371,7 @@
| 상태 | 항목 |
|---|---|
| **완료** | F-1 칸 이름 `LOCAL(Mine)` · `REMOTE(Theirs)` 로 맞추고 칸마다 "어느 쪽 · 어느 파일"(MERGED · 두 칸 LOCAL 은 "저장할 파일", mergetool 은 임시 파일 이름 — Mine 칸이 MERGED 이름을 보이던 것 고침), 한 줄 · 가운데 줄이기 · 툴팁 전체 · **덧**: 머지 주석 `[사람] 날짜 출처 설명`, 출처는 로그와 같은 말(Theirs(…) · Mine(…) · REMOTE(…) · BASE(해시)), Take Mine 도 주석. 무시 조건은 원래 출처를 받는 모양이라 코드 그대로, 옛 · 새 · 출처만 다른 주석을 하네스로 굳힘. 네 길 화면 확인 · E-1 병합 길 회귀 없음 · 1,312건 |
-| 대기 | F-2 메서드 단위로 골라 반영 · 머지 주석은 메서드 이름 위 한 줄 (지금은 줄 · 덩어리 · 전체) |
+| **완료** | F-2 메서드 단위로 골라 반영 — [이 메서드 반영](C# + C/C++, 클래스 멤버 전부 · 사용자 결정), 캐럿이 놓인 멤버를 반영해 올 쪽에서 중괄호 짝으로 잡음(`MemberRanges` — 문자열 · 주석 · raw · `#if` 갈래에 안 속음, 못 잡으면 단추 끄고 까닭 한 줄). 덩어리 · 메서드 반영은 주석 한 줄(메서드 이름 위) · 로그 한 줄 · Undo 한 번 — 덩어리가 줄마다 주석을 달던 것도 고침. 메서드 반영 = 같은 줄 줄 단위 반영 바이트 같음. MemberTest 83건(C# 29 · C++ 13 멤버) · 1,395건 |
| 대기 | F-3 탐색기에서 폴더 두 개를 [보내기] 로 바로 비교 + 설치 파일 없이 되는지 확인 |
| 대기 | F-4 파일 비교 탭 툴박스(ACTIONS) 감추는 단추 (지금은 접어도 폭 그대로) |
| 대기 | F-5 글자 크기 조절 (지금은 Consolas 12 고정) |
diff --git a/src/MergeHub/Models/MergeLine.cs b/src/MergeHub/Models/MergeLine.cs
index c5e17ed..0494697 100644
--- a/src/MergeHub/Models/MergeLine.cs
+++ b/src/MergeHub/Models/MergeLine.cs
@@ -67,7 +67,12 @@ namespace MergeHub.Models
/// [>] 또는 Accept 로 BASE 를 가져옴.
TakeBase,
/// 사용자가 직접 타이핑함.
- Manual
+ Manual,
+ ///
+ /// 글은 그대로 두고 머지 주석 한 줄만 들고 있는 편집(11차 F-2). 덩어리 · 메서드를 반영할 때
+ /// 주석을 줄마다가 아니라 한 줄만 넣으므로, 그 주석을 되돌리기와 함께 넣고 빼려고 따로 둔다.
+ ///
+ NoteOnly
}
///
@@ -448,5 +453,11 @@ namespace MergeHub.Models
/// 되돌릴 때 본문만 되돌리고 주석을 남겨 두면 파일에 유령 주석이 쌓인다.
///
public MergeLine? InsertedNote { get; init; }
+
+ ///
+ /// 한 번 누른 것으로 함께 들어간 편집들의 번호(0 이면 혼자). 덩어리 · 메서드 반영은 여러 줄이지만
+ /// 되돌리기 한 번에 통째로 되돌린다(11차 F-2).
+ ///
+ public int Group { get; init; }
}
}
diff --git a/src/MergeHub/Services/MemberRanges.cs b/src/MergeHub/Services/MemberRanges.cs
new file mode 100644
index 0000000..b2f4f04
--- /dev/null
+++ b/src/MergeHub/Services/MemberRanges.cs
@@ -0,0 +1,823 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace MergeHub.Services
+{
+ /// 메서드 단위 반영을 할 수 있는 언어(11차 F-2).
+ public enum MemberLanguage { None, CSharp, Cpp }
+
+ ///
+ /// 클래스 멤버 하나가 차지하는 줄(0부터). 는 바로 위 설명 주석 · 특성 · 감싸는 #if 까지,
+ /// 은 이름이 있는 선언 줄(머지 주석이 들어갈 자리), 는 닫는 } 나 ; 줄.
+ ///
+ public sealed record MemberRange(int Start, int NameLine, int End, string Name)
+ {
+ public bool Contains(int line) => line >= Start && line <= End;
+ }
+
+ ///
+ /// C# · C/C++ 소스에서 클래스 멤버(메서드 · 생성자 · 소멸자 · 속성 · 인덱서 · 이벤트 · 연산자 · 필드 · enum)의
+ /// 줄 범위를 잡는다(11차 F-2). Roslyn 없이 중괄호 짝으로 센다.
+ ///
+ /// 속지 않아야 할 것:
+ /// · 문자열 · 문자 · 주석 속 중괄호 - 먼저 가린다(). C# 축자 · 보간 · raw("""),
+ /// C++ raw(R"x(...)x") · 숫자 구분자(1'000)까지.
+ /// · #if 로 갈린 줄 - 갈래(#elif · #else)마다 #if 자리의 상태에서 다시 읽고, #endif 뒤에는 첫 갈래의
+ /// 상태로 이어 간다. 여는 줄만 갈린 메서드(void A(int x) { / void A() {)도 짝이 맞는다.
+ /// · 필드 초기화 · 람다 · switch 식 · C++ 생성자 초기화 목록의 중괄호 - 멤버 몸통이 아니다.
+ ///
+ /// 로컬 함수 · 람다는 멤버가 아니다 - 그 안에 있으면 감싸는 멤버 통째다(사용자 결정 "클래스 멤버 전부").
+ /// 클래스 · 네임스페이스 선언 줄 자체는 어느 멤버에도 들지 않는다. 범위를 못 잡으면 없음으로 둔다 -
+ /// 틀린 범위를 반영하는 것보다 단추를 끄는 편이 낫다.
+ ///
+ public static class MemberRanges
+ {
+ public static MemberLanguage LanguageOf(string? path)
+ {
+ switch (Path.GetExtension(path ?? string.Empty).ToLowerInvariant())
+ {
+ case ".cs":
+ return MemberLanguage.CSharp;
+ case ".c":
+ case ".cc":
+ case ".cpp":
+ case ".cxx":
+ case ".c++":
+ case ".h":
+ case ".hh":
+ case ".hpp":
+ case ".hxx":
+ case ".inl":
+ return MemberLanguage.Cpp;
+ default:
+ return MemberLanguage.None;
+ }
+ }
+
+ /// 줄 목록에서 멤버 범위들(시작 줄 순).
+ public static IReadOnlyList Find(IReadOnlyList lines, MemberLanguage language)
+ {
+ if (language == MemberLanguage.None || lines.Count == 0) return Array.Empty();
+
+ var masked = Mask(lines, language);
+ var raw = new List();
+ new Scanner(masked, language, raw).Run();
+
+ // 같은 시작 줄이 두 갈래에서 두 번 닫힐 수 있다 - 늦게 닫힌 쪽(범위가 넓은 쪽)을 쓴다.
+ var members = raw
+ .GroupBy(r => r.DeclStart)
+ .Select(g => g.OrderByDescending(r => r.End).First())
+ .Select(r => Finish(r, lines, masked, language))
+ .OrderBy(m => m.Start)
+ .ToList();
+
+ // 겹치면 뒤의 것을 버린다(짝이 틀어진 파일). 겹친 범위로 반영하는 것보다 안 잡는 편이 낫다.
+ var result = new List(members.Count);
+ foreach (var m in members)
+ {
+ if (result.Count > 0 && m.Start <= result[result.Count - 1].End) continue;
+ result.Add(m);
+ }
+
+ return result;
+ }
+
+ /// 그 줄(0부터)을 품은 멤버.
+ public static MemberRange? At(IReadOnlyList members, int line) =>
+ members.FirstOrDefault(m => m.Contains(line));
+
+ ///
+ /// 화면 행의 줄 번호(반영해 올 쪽, 1부터 · 없으면 null)로 캐럿 행이 속한 멤버를 찾는다.
+ /// 그쪽에 없는 행(내 쪽에만 있는 줄)은 위아래로 가장 가까운 행이 같은 멤버일 때만 그 멤버다.
+ ///
+ public static MemberRange? AtRow(IReadOnlyList sourceNumbers, int row, IReadOnlyList members)
+ {
+ if (row < 0 || row >= sourceNumbers.Count) return null;
+ if (sourceNumbers[row] is int here) return At(members, here - 1);
+
+ int? up = null, down = null;
+ for (var r = row - 1; r >= 0; r--) if (sourceNumbers[r] is int n) { up = n; break; }
+ for (var r = row + 1; r < sourceNumbers.Count; r++) if (sourceNumbers[r] is int n) { down = n; break; }
+ if (up is null || down is null) return null;
+
+ var a = At(members, up.Value - 1);
+ var b = At(members, down.Value - 1);
+ return a is not null && ReferenceEquals(a, b) ? a : null;
+ }
+
+ /// 그 멤버가 차지하는 화면 행(처음 · 끝). 사이의 내 쪽에만 있는 행도 든다.
+ public static (int First, int Last)? RowsOf(IReadOnlyList sourceNumbers, MemberRange member)
+ {
+ int first = -1, last = -1;
+ for (var r = 0; r < sourceNumbers.Count; r++)
+ {
+ if (sourceNumbers[r] is not int n || n - 1 < member.Start || n - 1 > member.End) continue;
+ if (first < 0) first = r;
+ last = r;
+ }
+
+ return first < 0 ? null : (first, last);
+ }
+
+ // ================= 범위 다듬기 =================
+
+ private static MemberRange Finish(Raw r, IReadOnlyList lines, Masked masked, MemberLanguage language)
+ {
+ var nameLine = r.DeclStart;
+ for (var l = r.DeclStart; l <= r.End; l++)
+ {
+ if (masked.Preprocessor[l]) continue;
+ var code = masked.Code[l].Trim();
+ if (code.Length == 0 || IsAttributeOnly(code) || IsTemplateOnly(code)) continue;
+ nameLine = l;
+ break;
+ }
+
+ var start = r.DeclStart;
+ var notePath = language == MemberLanguage.CSharp ? "a.cs" : "a.cpp";
+ while (true)
+ {
+ var above = start - 1;
+ if (above < 0) break;
+
+ // 이 멤버를 감싸는 #if (짝 #endif 가 멤버 안) - 여는 줄만 갈린 메서드
+ if (masked.Preprocessor[above] && masked.IfEnd.TryGetValue(above, out var endif) && endif <= r.End)
+ {
+ start = above;
+ continue;
+ }
+
+ // 바로 위 주석 줄(설명 · /// 문서). 빈 줄 · 머지 주석에서 멈춘다 - 머지 주석은 이력이라 반영으로 지우면 안 된다.
+ if (!masked.Preprocessor[above] && masked.Code[above].Trim().Length == 0 &&
+ lines[above].Trim().Length > 0 && !MergeNote.LooksLikeNote(lines[above], notePath))
+ {
+ start = above;
+ continue;
+ }
+
+ break;
+ }
+
+ return new MemberRange(start, nameLine, r.End, NameOf(r.Header));
+ }
+
+ private static bool IsAttributeOnly(string code)
+ {
+ if (!code.StartsWith("[", StringComparison.Ordinal)) return false;
+ var depth = 0;
+ for (var i = 0; i < code.Length; i++)
+ {
+ if (code[i] == '[') depth++;
+ else if (code[i] == ']') depth--;
+ else if (depth == 0 && !char.IsWhiteSpace(code[i])) return false;
+ }
+
+ return true;
+ }
+
+ private static bool IsTemplateOnly(string code) =>
+ code.StartsWith("template", StringComparison.Ordinal) && code.EndsWith(">", StringComparison.Ordinal);
+
+ private static readonly Regex Operator = new Regex(@"\boperator\s*([^\s(]+|\(\))", RegexOptions.Compiled);
+ private static readonly Regex Ident = new Regex(@"[~A-Za-z_][\w:~]*$", RegexOptions.Compiled);
+ private static readonly Regex EnumName = new Regex(@"\benum\s+(?:class\s+|struct\s+)?([A-Za-z_]\w*)", RegexOptions.Compiled);
+
+ /// 토스트 · 로그에 쓸 이름. 틀려도 범위에는 영향이 없다.
+ public static string NameOf(string header)
+ {
+ var h = Regex.Replace(header ?? string.Empty, @"\s+", " ").Trim();
+
+ // 앞에 붙은 특성([Obsolete("...")] · [[nodiscard]])은 뗀다 - 그 안의 괄호를 메서드 괄호로 읽는다.
+ while (h.StartsWith("[", StringComparison.Ordinal))
+ {
+ var depth = 0;
+ var close = -1;
+ for (var k = 0; k < h.Length; k++)
+ {
+ if (h[k] == '[') depth++;
+ else if (h[k] == ']' && --depth == 0) { close = k; break; }
+ }
+
+ if (close < 0) break;
+ h = h.Substring(close + 1).TrimStart();
+ }
+
+ var en = EnumName.Match(h);
+ if (en.Success) return en.Groups[1].Value;
+
+ var op = Operator.Match(h);
+ if (op.Success) return "operator " + op.Groups[1].Value;
+
+ if (h.Contains("this[")) return "this[]";
+
+ var cut = h.Length;
+ foreach (var stop in new[] { "=>", "=", "(", "{", ";" })
+ {
+ var at = TopLevelIndex(h, stop);
+ if (at >= 0 && at < cut) cut = at;
+ }
+
+ var head = StripGenerics(h.Substring(0, cut).TrimEnd());
+ var m = Ident.Match(head);
+ return m.Success ? m.Value : head;
+ }
+
+ private static int TopLevelIndex(string s, string token)
+ {
+ var angle = 0;
+ for (var i = 0; i + token.Length <= s.Length; i++)
+ {
+ if (s[i] == '<') angle++;
+ else if (s[i] == '>' && angle > 0) angle--;
+ if (angle == 0 && string.CompareOrdinal(s, i, token, 0, token.Length) == 0) return i;
+ }
+
+ return -1;
+ }
+
+ private static string StripGenerics(string s)
+ {
+ while (s.EndsWith(">", StringComparison.Ordinal) || s.EndsWith("]", StringComparison.Ordinal))
+ {
+ var close = s[s.Length - 1];
+ var open = close == '>' ? '<' : '[';
+ var depth = 0;
+ var i = s.Length - 1;
+ for (; i >= 0; i--)
+ {
+ if (s[i] == close) depth++;
+ else if (s[i] == open && --depth == 0) break;
+ }
+
+ if (i <= 0) break;
+ s = s.Substring(0, i).TrimEnd();
+ }
+
+ return s;
+ }
+
+ // ================= 훑기 =================
+
+ private sealed class Raw
+ {
+ public int DeclStart;
+ public int End;
+ public string Header = string.Empty;
+ }
+
+ private enum FrameKind { Container, Member, Init }
+
+ private sealed class Frame
+ {
+ public FrameKind Kind;
+ public int Depth;
+ public int DeclStart;
+ public string Header = string.Empty;
+
+ public Frame Clone() => (Frame)MemberwiseClone();
+ }
+
+ /// 훑는 도중의 상태. #if 갈래마다 떠 놓고 되돌린다.
+ private sealed class State
+ {
+ public List Stack = new List { new Frame { Kind = FrameKind.Container } };
+ public int DeclStart = -1;
+ public StringBuilder Decl = new StringBuilder();
+ public int Paren;
+ public int Bracket;
+ public bool Assign;
+ public bool Arrow;
+ public bool InitList;
+ public char LastSignificant;
+ public Raw? Pending;
+
+ public State Clone()
+ {
+ var c = (State)MemberwiseClone();
+ c.Stack = Stack.Select(f => f.Clone()).ToList();
+ c.Decl = new StringBuilder(Decl.ToString());
+ return c;
+ }
+
+ public void ResetDecl()
+ {
+ DeclStart = -1;
+ Decl.Clear();
+ Paren = Bracket = 0;
+ Assign = Arrow = InitList = false;
+ LastSignificant = '\0';
+ }
+ }
+
+ private sealed class Branch
+ {
+ public State Snapshot = null!;
+ public State? FirstEnd;
+ }
+
+ private static readonly Regex AccessLabel = new Regex(@"^(public|private|protected)(\s+(slots|Q_SLOTS))?$|^(signals|Q_SIGNALS)$", RegexOptions.Compiled);
+ private static readonly Regex TypeWord = new Regex(@"\b(class|struct|interface|record|namespace|union)\b", RegexOptions.Compiled);
+ private static readonly Regex EnumWord = new Regex(@"\benum\b", RegexOptions.Compiled);
+ private static readonly Regex TemplatePrefix = new Regex(@"^\s*template\s*<", RegexOptions.Compiled);
+
+ private sealed class Scanner
+ {
+ private readonly Masked _m;
+ private readonly MemberLanguage _lang;
+ private readonly List _out;
+ private State _s = new State();
+ private readonly Stack _branches = new Stack();
+
+ public Scanner(Masked masked, MemberLanguage language, List output)
+ {
+ _m = masked;
+ _lang = language;
+ _out = output;
+ }
+
+ public void Run()
+ {
+ for (var line = 0; line < _m.Code.Length; line++)
+ {
+ if (_m.Preprocessor[line])
+ {
+ Directive(_m.Code[line]);
+ continue;
+ }
+
+ var text = _m.Code[line];
+ for (var i = 0; i < text.Length; i++) Step(text, i, line);
+ if (_s.DeclStart >= 0) _s.Decl.Append(' ');
+ }
+
+ if (_s.Pending is not null) Emit(_s.Pending);
+ }
+
+ private void Directive(string code)
+ {
+ var word = Regex.Match(code.TrimStart().TrimStart('#').TrimStart(), @"^[a-z]+").Value;
+ switch (word)
+ {
+ case "if":
+ case "ifdef":
+ case "ifndef":
+ _branches.Push(new Branch { Snapshot = _s.Clone() });
+ break;
+ case "elif":
+ case "elifdef":
+ case "elifndef":
+ case "else":
+ if (_branches.Count == 0) break;
+ var b = _branches.Peek();
+ b.FirstEnd ??= _s;
+ _s = b.Snapshot.Clone();
+ break;
+ case "endif":
+ if (_branches.Count == 0) break;
+ var done = _branches.Pop();
+ if (done.FirstEnd is not null)
+ {
+ // 다른 갈래에서 떠 있던 선언 · 끝난 몸통은 버리고 첫 갈래로 잇는다.
+ // 다른 갈래에서 온전히 닫힌 멤버는 이미 내보냈다.
+ if (_s.Pending is not null && done.FirstEnd.Pending?.DeclStart != _s.Pending.DeclStart) Emit(_s.Pending);
+ _s = done.FirstEnd;
+ }
+ break;
+ }
+ }
+
+ private Frame Top => _s.Stack[_s.Stack.Count - 1];
+
+ private void Step(string text, int i, int line)
+ {
+ var ch = text[i];
+ var top = Top;
+
+ if (top.Kind == FrameKind.Member)
+ {
+ if (ch == '{') top.Depth++;
+ else if (ch == '}' && --top.Depth == 0)
+ {
+ _s.Stack.RemoveAt(_s.Stack.Count - 1);
+ _s.Pending = new Raw { DeclStart = top.DeclStart, End = line, Header = top.Header };
+ }
+ return;
+ }
+
+ if (top.Kind == FrameKind.Init)
+ {
+ _s.Decl.Append(ch);
+ if (ch == '{') top.Depth++;
+ else if (ch == '}' && --top.Depth == 0)
+ {
+ _s.Stack.RemoveAt(_s.Stack.Count - 1);
+ _s.LastSignificant = '}';
+ }
+ return;
+ }
+
+ // ---- 멤버 자리(클래스 · 네임스페이스 · 파일 바로 안) ----
+ if (char.IsWhiteSpace(ch))
+ {
+ // 낱말 사이는 한 칸으로 남긴다 - "namespace weld" 가 붙으면 선언 낱말을 못 알아본다.
+ if (_s.DeclStart >= 0 && _s.Decl.Length > 0 && _s.Decl[_s.Decl.Length - 1] != ' ') _s.Decl.Append(' ');
+ return;
+ }
+
+ if (_s.Pending is not null)
+ {
+ // 속성 몸통 뒤 초기화: public int X { get; set; } = 1;
+ if (ch == '=' && Next(text, i) != '=')
+ {
+ var p = _s.Pending;
+ _s.Pending = null;
+ _s.ResetDecl();
+ _s.DeclStart = p.DeclStart;
+ _s.Decl.Append(p.Header).Append(" {} ");
+ _s.Assign = true;
+ _s.Decl.Append(ch);
+ _s.LastSignificant = ch;
+ return;
+ }
+
+ Emit(_s.Pending);
+ _s.Pending = null;
+ }
+
+ if (_s.DeclStart < 0) _s.DeclStart = line;
+
+ if (ch == '(') _s.Paren++;
+ else if (ch == ')') { if (_s.Paren > 0) _s.Paren--; }
+ else if (ch == '[') _s.Bracket++;
+ else if (ch == ']') { if (_s.Bracket > 0) _s.Bracket--; }
+
+ if (_s.Paren > 0 || _s.Bracket > 0 || ch == ')' || ch == ']')
+ {
+ _s.Decl.Append(ch);
+ _s.LastSignificant = ch;
+ return;
+ }
+
+ switch (ch)
+ {
+ case ';':
+ var header = _s.Decl.ToString();
+ if (!IsNotMember(header)) Emit(new Raw { DeclStart = _s.DeclStart, End = line, Header = header });
+ _s.ResetDecl();
+ return;
+
+ case ':':
+ if (_lang == MemberLanguage.Cpp && AccessLabel.IsMatch(_s.Decl.ToString().Trim()))
+ {
+ _s.ResetDecl();
+ return;
+ }
+ if (_lang == MemberLanguage.Cpp && Next(text, i) != ':' && Prev(text, i) != ':' && _s.Decl.ToString().Contains(")"))
+ _s.InitList = true;
+ break;
+
+ case '=':
+ if (_lang == MemberLanguage.CSharp && Next(text, i) == '>') _s.Arrow = true;
+ else if (IsAssignment(text, i)) _s.Assign = true;
+ break;
+
+ case '{':
+ Open(line);
+ return;
+
+ case '}':
+ // 멤버 자리에서 닫히는 중괄호는 감싸던 클래스 · 네임스페이스의 끝이다.
+ _s.ResetDecl();
+ if (_s.Stack.Count > 1) _s.Stack.RemoveAt(_s.Stack.Count - 1);
+ return;
+ }
+
+ _s.Decl.Append(ch);
+ _s.LastSignificant = ch;
+ }
+
+ private void Open(int line)
+ {
+ var header = _s.Decl.ToString();
+ var prev = _s.LastSignificant;
+
+ var initBrace = _s.Assign || _s.Arrow ||
+ (_lang == MemberLanguage.Cpp && _s.InitList && (char.IsLetterOrDigit(prev) || prev == '_' || prev == '>'));
+ if (initBrace)
+ {
+ _s.Decl.Append('{');
+ _s.Stack.Add(new Frame { Kind = FrameKind.Init, Depth = 1 });
+ return;
+ }
+
+ var words = WordsBeforeParen(header);
+ if (!EnumWord.IsMatch(words) && (TypeWord.IsMatch(words) || IsExternBlock(header)))
+ {
+ _s.ResetDecl();
+ _s.Stack.Add(new Frame { Kind = FrameKind.Container });
+ return;
+ }
+
+ _s.Stack.Add(new Frame { Kind = FrameKind.Member, Depth = 1, DeclStart = _s.DeclStart < 0 ? line : _s.DeclStart, Header = header });
+ _s.ResetDecl();
+ }
+
+ private void Emit(Raw raw) => _out.Add(raw);
+
+ private static char Next(string text, int i) => i + 1 < text.Length ? text[i + 1] : '\0';
+ private static char Prev(string text, int i) => i > 0 ? text[i - 1] : '\0';
+
+ /// 대입인지. == · != · <= · += · operator= 는 아니다.
+ private bool IsAssignment(string text, int i)
+ {
+ if (Next(text, i) == '=') return false;
+ var p = Prev(text, i);
+ if ("=!<>+-*/%&|^".IndexOf(p) >= 0) return false;
+ var before = _s.Decl.ToString().TrimEnd();
+ return !before.EndsWith("operator", StringComparison.Ordinal);
+ }
+
+ /// 선언이 아닌 것: using · namespace X; · typedef 는 반영 단위로 삼지 않는다.
+ private static bool IsNotMember(string header)
+ {
+ var h = header.Trim();
+ if (h.Length == 0) return true;
+ return Regex.IsMatch(h, @"^(using|namespace|global\s+using)\b");
+ }
+
+ private static bool IsExternBlock(string header) =>
+ Regex.IsMatch(header.Trim(), @"^extern\s+_+$");
+
+ /// 첫 ( 앞 낱말들. C++ template<...> 머리는 뗀다(template<class T> T Max(...)).
+ private static string WordsBeforeParen(string header)
+ {
+ var h = header;
+ if (TemplatePrefix.IsMatch(h))
+ {
+ var open = h.IndexOf('<');
+ var depth = 0;
+ for (var k = open; k < h.Length; k++)
+ {
+ if (h[k] == '<') depth++;
+ else if (h[k] == '>' && --depth == 0) { h = h.Substring(k + 1); break; }
+ }
+ }
+
+ var paren = h.IndexOf('(');
+ return paren < 0 ? h : h.Substring(0, paren);
+ }
+ }
+
+ // ================= 주석 · 문자열 가리기 =================
+
+ private sealed class Masked
+ {
+ public string[] Code = Array.Empty();
+ public bool[] Preprocessor = Array.Empty();
+
+ /// #if · #ifdef · #ifndef 줄 → 짝 #endif 줄.
+ public Dictionary IfEnd = new Dictionary();
+ }
+
+ private enum Mode { Code, LineComment, BlockComment, Str, Verbatim, Interp, InterpVerbatim, Char, RawStr }
+
+ ///
+ /// 줄마다 코드만 남긴다(주석은 공백, 문자열 속은 '_'). 길이는 원문과 같다.
+ /// 의 가리기와 같은 뜻이지만 raw 문자열 · C++ 까지 알아야 해서 따로 둔다 -
+ /// 스타일 맞춤(F-12 기본값)을 이 일로 흔들지 않는다.
+ ///
+ private static Masked Mask(IReadOnlyList lines, MemberLanguage language)
+ {
+ var cpp = language == MemberLanguage.Cpp;
+ var src = string.Join("\n", lines);
+ var mask = src.ToCharArray();
+ var n = src.Length;
+ var modes = new Stack();
+ modes.Push(Mode.Code);
+ var holes = new Stack();
+ string rawEnd = string.Empty;
+
+ var pre = new bool[lines.Count];
+ var line = 0;
+ var lineStart = true;
+ var continued = false; // C++ 지시문이 \ 로 다음 줄에 이어짐
+
+ for (var i = 0; i < n;)
+ {
+ var ch = src[i];
+ var next = i + 1 < n ? src[i + 1] : '\0';
+
+ if (ch == '\n')
+ {
+ var top = modes.Peek();
+ if (top is Mode.LineComment or Mode.Str or Mode.Char or Mode.Interp) { modes.Pop(); }
+ if (cpp && pre[line] && i > 0 && src[i - 1] == '\\') continued = true;
+ line++;
+ lineStart = true;
+ i++;
+ continue;
+ }
+
+ if (lineStart)
+ {
+ lineStart = false;
+ if (continued) { pre[line] = true; continued = false; }
+ else if (modes.Peek() == Mode.Code && holes.Count == 0)
+ {
+ var k = i;
+ while (k < n && src[k] != '\n' && char.IsWhiteSpace(src[k])) k++;
+ if (k < n && src[k] == '#') pre[line] = true;
+ }
+ }
+
+ switch (modes.Peek())
+ {
+ case Mode.Code:
+ if (ch == '/' && next == '/') { modes.Push(Mode.LineComment); Blank(mask, i, 2); i += 2; continue; }
+ if (ch == '/' && next == '*') { modes.Push(Mode.BlockComment); Blank(mask, i, 2); i += 2; continue; }
+
+ if (!cpp && TryRawCSharp(src, i, out var quotes, out var prefix))
+ {
+ rawEnd = new string('"', quotes);
+ modes.Push(Mode.RawStr);
+ Hide(mask, i, prefix + quotes);
+ i += prefix + quotes;
+ continue;
+ }
+
+ if (cpp && TryRawCpp(src, i, out var open, out var delimiter))
+ {
+ rawEnd = ")" + delimiter + "\"";
+ modes.Push(Mode.RawStr);
+ Hide(mask, i, open);
+ i += open;
+ continue;
+ }
+
+ if (!cpp)
+ {
+ if ((ch == '$' && next == '@' && At(src, i + 2) == '"') || (ch == '@' && next == '$' && At(src, i + 2) == '"'))
+ { modes.Push(Mode.InterpVerbatim); Hide(mask, i, 3); i += 3; continue; }
+ if (ch == '$' && next == '"') { modes.Push(Mode.Interp); Hide(mask, i, 2); i += 2; continue; }
+ if (ch == '@' && next == '"') { modes.Push(Mode.Verbatim); Hide(mask, i, 2); i += 2; continue; }
+ }
+
+ if (ch == '"') { modes.Push(Mode.Str); Hide(mask, i, 1); i++; continue; }
+
+ if (ch == '\'')
+ {
+ // C++14 숫자 구분자 1'000'000 - 숫자 속의 ' 는 문자 따옴표가 아니다.
+ if (cpp && InNumber(src, i)) { i++; continue; }
+ modes.Push(Mode.Char);
+ Hide(mask, i, 1);
+ i++;
+ continue;
+ }
+
+ if (holes.Count > 0)
+ {
+ if (ch == '{') holes.Push(holes.Pop() + 1);
+ else if (ch == '}')
+ {
+ var depth = holes.Pop();
+ if (depth == 0) { modes.Pop(); mask[i] = '_'; i++; continue; }
+ holes.Push(depth - 1);
+ }
+
+ mask[i] = '_';
+ }
+
+ i++;
+ continue;
+
+ case Mode.LineComment:
+ mask[i] = ' ';
+ i++;
+ continue;
+
+ case Mode.BlockComment:
+ if (ch == '*' && next == '/') { Blank(mask, i, 2); modes.Pop(); i += 2; continue; }
+ mask[i] = ' ';
+ i++;
+ continue;
+
+ case Mode.Str:
+ case Mode.Char:
+ var quote = modes.Peek() == Mode.Str ? '"' : '\'';
+ if (ch == '\\') { mask[i] = '_'; if (next != '\n' && i + 1 < n) mask[i + 1] = '_'; i += next == '\n' ? 1 : 2; continue; }
+ if (ch == quote) modes.Pop();
+ mask[i] = '_';
+ i++;
+ continue;
+
+ case Mode.Verbatim:
+ if (ch == '"' && next == '"') { Hide(mask, i, 2); i += 2; continue; }
+ if (ch == '"') modes.Pop();
+ mask[i] = '_';
+ i++;
+ continue;
+
+ case Mode.Interp:
+ case Mode.InterpVerbatim:
+ var verbatim = modes.Peek() == Mode.InterpVerbatim;
+ if ((ch == '{' && next == '{') || (ch == '}' && next == '}') || (verbatim && ch == '"' && next == '"'))
+ { Hide(mask, i, 2); i += 2; continue; }
+ if (!verbatim && ch == '\\') { mask[i] = '_'; if (next != '\n' && i + 1 < n) mask[i + 1] = '_'; i += next == '\n' ? 1 : 2; continue; }
+ if (ch == '"') { modes.Pop(); mask[i] = '_'; i++; continue; }
+ if (ch == '{') { modes.Push(Mode.Code); holes.Push(0); mask[i] = '_'; i++; continue; }
+ mask[i] = '_';
+ i++;
+ continue;
+
+ case Mode.RawStr:
+ if (string.CompareOrdinal(src, i, rawEnd, 0, rawEnd.Length) == 0)
+ {
+ Hide(mask, i, rawEnd.Length);
+ i += rawEnd.Length;
+ modes.Pop();
+ continue;
+ }
+ mask[i] = '_';
+ i++;
+ continue;
+ }
+ }
+
+ var code = new string(mask).Split('\n');
+ var result = new Masked { Code = code, Preprocessor = pre };
+
+ // #if 짝 맞추기(첫 갈래 끝이 아니라 #endif 까지)
+ var open2 = new Stack();
+ for (var l = 0; l < code.Length; l++)
+ {
+ if (!pre[l]) continue;
+ var word = Regex.Match(code[l].TrimStart().TrimStart('#').TrimStart(), @"^[a-z]+").Value;
+ if (word is "if" or "ifdef" or "ifndef") open2.Push(l);
+ else if (word == "endif" && open2.Count > 0) result.IfEnd[open2.Pop()] = l;
+ }
+
+ return result;
+ }
+
+ private static char At(string s, int i) => i < s.Length ? s[i] : '\0';
+
+ private static void Blank(char[] mask, int at, int count)
+ {
+ for (var k = 0; k < count && at + k < mask.Length; k++) mask[at + k] = ' ';
+ }
+
+ private static void Hide(char[] mask, int at, int count)
+ {
+ for (var k = 0; k < count && at + k < mask.Length; k++) mask[at + k] = '_';
+ }
+
+ /// C# 11 raw 문자열: """(세 개 이상), 앞에 $ 가 붙을 수 있다.
+ private static bool TryRawCSharp(string s, int i, out int quotes, out int prefix)
+ {
+ quotes = 0;
+ prefix = 0;
+ var k = i;
+ while (k < s.Length && s[k] == '$') k++;
+ prefix = k - i;
+ while (k < s.Length && s[k] == '"') { quotes++; k++; }
+ if (quotes >= 3) return true;
+ quotes = 0;
+ prefix = 0;
+ return false;
+ }
+
+ /// C++11 raw 문자열: R"구분자( … )구분자", 앞에 u8 · u · U · L 이 붙을 수 있다.
+ private static bool TryRawCpp(string s, int i, out int open, out string delimiter)
+ {
+ open = 0;
+ delimiter = string.Empty;
+ if (s[i] != 'R' || At(s, i + 1) != '"') return false;
+
+ var b = i - 1;
+ if (b >= 0 && s[b] == '8' && b >= 1 && s[b - 1] == 'u') b -= 2;
+ else if (b >= 0 && (s[b] == 'u' || s[b] == 'U' || s[b] == 'L')) b--;
+ if (b >= 0 && (char.IsLetterOrDigit(s[b]) || s[b] == '_')) return false;
+
+ var k = i + 2;
+ while (k < s.Length && s[k] != '(' && s[k] != '\n' && k - i < 20) k++;
+ if (k >= s.Length || s[k] != '(') return false;
+
+ delimiter = s.Substring(i + 2, k - (i + 2));
+ open = k - i + 1;
+ return true;
+ }
+
+ /// 이 ' 가 숫자 글자 사이의 구분자인지(앞 낱말이 숫자로 시작하고 뒤가 숫자 · 16진 글자).
+ private static bool InNumber(string s, int i)
+ {
+ if (i == 0 || i + 1 >= s.Length || !char.IsLetterOrDigit(s[i + 1])) return false;
+ var k = i - 1;
+ while (k >= 0 && (char.IsLetterOrDigit(s[k]) || s[k] == '\'' || s[k] == '.')) k--;
+ return k + 1 < i && char.IsDigit(s[k + 1]);
+ }
+ }
+}
diff --git a/src/MergeHub/ViewModels/HomeViewModel.cs b/src/MergeHub/ViewModels/HomeViewModel.cs
index 6af8b1b..ab56592 100644
--- a/src/MergeHub/ViewModels/HomeViewModel.cs
+++ b/src/MergeHub/ViewModels/HomeViewModel.cs
@@ -776,9 +776,17 @@ namespace MergeHub.ViewModels
{
if (_undo.Count == 0) return;
+ // 덩어리 · 메서드 반영은 여러 줄이 한 묶음이다 - 한 번에 되돌린다(11차 F-2).
var edit = _undo.Pop();
Restore(edit, toBefore: true);
_redo.Push(edit);
+ while (edit.Group != 0 && _undo.Count > 0 && _undo.Peek().Group == edit.Group)
+ {
+ var more = _undo.Pop();
+ Restore(more, toBefore: true);
+ _redo.Push(more);
+ }
+
AfterEdit();
}
@@ -792,6 +800,13 @@ namespace MergeHub.ViewModels
var edit = _redo.Pop();
Restore(edit, toBefore: false);
_undo.Push(edit);
+ while (edit.Group != 0 && _redo.Count > 0 && _redo.Peek().Group == edit.Group)
+ {
+ var more = _redo.Pop();
+ Restore(more, toBefore: false);
+ _undo.Push(more);
+ }
+
AfterEdit();
}
@@ -1121,6 +1136,8 @@ namespace MergeHub.ViewModels
AutoMergeCommand.NotifyCanExecuteChanged();
AcceptBlockCommand.NotifyCanExecuteChanged();
+ AcceptMemberCommand.NotifyCanExecuteChanged();
+ OnPropertyChanged(nameof(MemberHint));
NextConflictCommand.NotifyCanExecuteChanged();
PrevConflictCommand.NotifyCanExecuteChanged();
}
@@ -1734,7 +1751,10 @@ namespace MergeHub.ViewModels
/// BASE 내용을 LOCAL 로 가져온다.
/// BASE 에 없는 줄(LOCAL 에서 추가된 줄)에 대해서는 "그 줄을 지우는 것"이 BASE 를 따르는 것이다.
///
- private void Apply(MergeLine line, bool requery = true)
+ /// 덩어리 · 메서드로 함께 반영하는 묶음 번호. 0 이 아니면 줄마다 주석 · 로그를 남기지 않는다 -
+ /// 부르는 쪽이 주석 한 줄 · 로그 한 줄로 남긴다(11차 F-2).
+ /// 글이 바뀌었는지.
+ private bool Apply(MergeLine line, bool requery = true, int group = 0)
{
var before = line.LocalText;
var beforeHasLocal = line.HasLocal;
@@ -1747,7 +1767,7 @@ namespace MergeHub.ViewModels
{
// 반영한 줄에는 누가·언제 반영했는지 주석을 남긴다.
// 주석은 줄 끝이 아니라 그 줄 "위"에 따로 들어간다 - 아래 InsertNoteAbove 참고.
- note = _workspace.Settings.InsertMergeComment ? BuildMergeNote(MergeNames.BaseSource(_baseSourceName)) : null;
+ note = _workspace.Settings.InsertMergeComment && group == 0 ? BuildMergeNote(MergeNames.BaseSource(_baseSourceName)) : null;
after = line.BaseText;
afterHasLocal = true;
}
@@ -1758,7 +1778,7 @@ namespace MergeHub.ViewModels
}
if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal))
- return;
+ return false;
var description = MergeNames.TakeBaseLog(
(line.HasBase ? line.BaseNumber : line.LocalNumber)?.ToString(), _baseSourceName, line.HasBase);
@@ -1771,14 +1791,16 @@ namespace MergeHub.ViewModels
EditKind.TakeBase, description)
{
InsertedNote = InsertNoteAbove(line, note),
+ Group = group,
});
// 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다.
MarkApplied(line);
_redo.Clear();
- _workspace.AppendLog(description);
+ if (group == 0) _workspace.AppendLog(description);
if (requery) AfterEdit();
+ return true;
}
// ================= 3-Way (REMOTE = 사용자가 고른 브랜치) =================
@@ -2056,7 +2078,8 @@ namespace MergeHub.ViewModels
/// REMOTE 내용을 LOCAL 로 가져온다.
/// REMOTE 에 없는 줄이면 "그 줄을 지우는 것"이 저쪽을 따르는 것이다 - Apply 와 같은 규칙.
///
- private void ApplyRemote(MergeLine line, bool requery = true)
+ /// 와 같다.
+ private bool ApplyRemote(MergeLine line, bool requery = true, int group = 0)
{
var before = line.LocalText;
var beforeHasLocal = line.HasLocal;
@@ -2067,7 +2090,7 @@ namespace MergeHub.ViewModels
if (line.HasRemote)
{
- note = _workspace.Settings.InsertMergeComment
+ note = _workspace.Settings.InsertMergeComment && group == 0
? BuildMergeNote(MergeNames.TheirsSource(TheirsSideName, TakesMergeSides))
: null;
after = line.RemoteText;
@@ -2080,7 +2103,7 @@ namespace MergeHub.ViewModels
}
if (beforeHasLocal == afterHasLocal && string.Equals(before, after, StringComparison.Ordinal))
- return;
+ return false;
var description = MergeNames.TakeTheirsLog(
(line.HasRemote ? line.RemoteNumber : line.LocalNumber)?.ToString(),
@@ -2094,14 +2117,16 @@ namespace MergeHub.ViewModels
EditKind.TakeBase, description)
{
InsertedNote = InsertNoteAbove(line, note),
+ Group = group,
});
// 반영 표시는 되돌리기 스택 밖에도 남긴다 - 규칙을 바꾸면 스택이 비워지기 때문이다.
MarkApplied(line);
_redo.Clear();
- _workspace.AppendLog(description);
+ if (group == 0) _workspace.AppendLog(description);
if (requery) AfterEdit();
+ return true;
}
///
@@ -3135,7 +3160,11 @@ namespace MergeHub.ViewModels
private List DiffBlockStarts() => _diffStarts ??= ComputeDiffBlockStarts();
/// 줄 목록이나 줄 상태가 바뀌면 다시 세도록 표시만 해 둔다.
- private void InvalidateDiffBlocks() => _diffStarts = null;
+ private void InvalidateDiffBlocks()
+ {
+ _diffStarts = null;
+ _sourceMembers = null;
+ }
private List ComputeDiffBlockStarts()
{
@@ -3159,7 +3188,12 @@ namespace MergeHub.ViewModels
[ObservableProperty] private int _caretRow;
/// 줄을 옮기면 "이 덩어리 반영"이 켜지고 꺼진다.
- partial void OnCaretRowChanged(int value) => AcceptBlockCommand.NotifyCanExecuteChanged();
+ partial void OnCaretRowChanged(int value)
+ {
+ AcceptBlockCommand.NotifyCanExecuteChanged();
+ AcceptMemberCommand.NotifyCanExecuteChanged();
+ OnPropertyChanged(nameof(MemberHint));
+ }
/// 상태바에 띄우는 "차이 3 / 15".
[ObservableProperty] private string _diffPosition = "차이 -";
@@ -3507,6 +3541,9 @@ namespace MergeHub.ViewModels
// 스택은 최신이 먼저다. 같은 줄을 여러 번 고쳤으면 마지막 편집의 종류를 따른다.
if (applied.Contains(edit.Line) || edited.Contains(edit.Line)) continue;
+ // 주석만 든 편집은 그 줄을 바꾸지 않았다 - 반영도 직접 수정도 아니다(11차 F-2).
+ if (edit.Kind == EditKind.NoteOnly) continue;
+
if (edit.Kind == EditKind.TakeBase) applied.Add(edit.Line);
else edited.Add(edit.Line);
}
@@ -3563,9 +3600,12 @@ namespace MergeHub.ViewModels
work = resolved;
}
- EditSummary = _undo.Count == 0 && _redo.Count == 0
+ // 주석만 든 편집(덩어리 · 메서드 반영의 주석 한 줄, 11차 F-2)은 세지 않는다 - 편집 수는 바뀐 줄 수다.
+ var edits = _undo.Count(e => e.Kind != EditKind.NoteOnly);
+ var redos = _redo.Count(e => e.Kind != EditKind.NoteOnly);
+ EditSummary = edits == 0 && redos == 0
? work
- : $"{work} · 편집 {_undo.Count}건 · 되돌리기 {_redo.Count}건";
+ : $"{work} · 편집 {edits}건 · 되돌리기 {redos}건";
RefreshProblems();
}
@@ -3653,14 +3693,174 @@ namespace MergeHub.ViewModels
if (block.Count == 0) return;
// 2-Way 는 BASE 를, 3-Way 는 저쪽(REMOTE)을 가져온다. BlockAround 주석 참고.
- foreach (var line in block)
+ // 주석은 덩어리 첫 줄 위 한 줄, 되돌리기는 한 번에(11차 F-2 - 예전에는 줄마다 주석이 붙었다).
+ var count = ApplyGroup(block, anchor: null, what: "덩어리");
+ if (count > 0) MessageUtil.ShowSuccessToast($"{count}줄 반영");
+ }
+
+ private int _lastGroup;
+
+ ///
+ /// 여러 줄을 한 번 누른 것으로 반영한다(덩어리 · 메서드, 11차 F-2).
+ /// 머지 주석은 (없으면 반영 뒤 글이 남은 첫 줄) 위에 한 줄만, 로그도 한 줄,
+ /// 되돌리기는 묶음째. 머지 주석 줄은 건드리지 않는다 - 이력이다.
+ ///
+ /// 글이 바뀐 줄 수.
+ private int ApplyGroup(IReadOnlyList rows, MergeLine? anchor, string what)
+ {
+ var group = ++_lastGroup;
+ var changed = 0;
+ int? first = null, last = null;
+
+ foreach (var line in rows.ToList())
{
- if (IsThreeWay) ApplyRemote(line, requery: false);
- else Apply(line, requery: false);
+ if (line.IsMergeNote) continue;
+ var number = IsThreeWay ? line.RemoteNumber : line.BaseNumber;
+ var done = IsThreeWay
+ ? line.CanTakeRemote && ApplyRemote(line, requery: false, group: group)
+ : line.CanTakeBase && Apply(line, requery: false, group: group);
+ if (!done) continue;
+
+ changed++;
+ if (number is int n) { first ??= n; last = n; }
}
+ if (changed == 0)
+ {
+ AfterEdit();
+ return 0;
+ }
+
+ var source = IsThreeWay
+ ? MergeNames.TheirsSource(TheirsSideName, TakesMergeSides)
+ : MergeNames.BaseSource(_baseSourceName);
+ var where = first is null ? string.Empty : first == last ? $"{first}행 " : $"{first}~{last}행 ";
+ var description = $"{where}{what} {source} 반영 ({changed}줄)";
+
+ var at = anchor is { HasLocal: true } && Lines.Contains(anchor)
+ ? anchor
+ : rows.FirstOrDefault(r => r.HasLocal && !r.IsMergeNote && Lines.Contains(r));
+ if (at is not null && _workspace.Settings.InsertMergeComment)
+ {
+ var note = BuildMergeNote(source);
+ _undo.Push(new LineEdit(at, at.LocalText, at.HasLocal, at.LocalText, at.HasLocal,
+ EditKind.NoteOnly, description)
+ {
+ InsertedNote = InsertNoteAbove(at, note),
+ Group = group,
+ });
+ }
+
+ _redo.Clear();
+ _workspace.AppendLog(description);
AfterEdit();
- MessageUtil.ShowSuccessToast($"{block.Count}줄 반영");
+ return changed;
+ }
+
+ // ---- 메서드 단위 반영(11차 F-2) ----
+
+ private IReadOnlyList? _sourceMembers;
+
+ ///
+ /// 반영해 올 쪽(2-Way 는 BASE, 3-Way 는 REMOTE)의 멤버 범위. 그쪽 글은 파일을 연 뒤 바뀌지 않지만
+ /// 행이 다시 만들어지면 지운다().
+ ///
+ private IReadOnlyList SourceMembers(MemberLanguage language)
+ {
+ if (_sourceMembers is not null) return _sourceMembers;
+
+ var source = Lines
+ .Select(l => IsThreeWay ? (Number: l.RemoteNumber, Text: l.RemoteText) : (Number: l.BaseNumber, Text: l.BaseText))
+ .Where(x => x.Number is not null)
+ .OrderBy(x => x.Number)
+ .Select(x => x.Text ?? string.Empty)
+ .ToList();
+ return _sourceMembers = MemberRanges.Find(source, language);
+ }
+
+ private List SourceNumbers() =>
+ Lines.Select(l => IsThreeWay ? l.RemoteNumber : l.BaseNumber).ToList();
+
+ /// 캐럿이 놓인 멤버와 그 화면 행들. 못 잡으면 null 과 까닭.
+ private (MemberRange Member, List Rows, int Changes)? MemberAround(int row, out string why)
+ {
+ var side = IsThreeWay ? "REMOTE" : "BASE";
+ var language = MemberRanges.LanguageOf(CurrentFilePath);
+ if (language == MemberLanguage.None)
+ {
+ why = "메서드 반영은 C# · C/C++ 파일에서만 씁니다";
+ return null;
+ }
+
+ if (row < 0 || row >= Lines.Count)
+ {
+ why = "캐럿을 메서드 안에 두면 켜집니다";
+ return null;
+ }
+
+ var numbers = SourceNumbers();
+ var member = MemberRanges.AtRow(numbers, row, SourceMembers(language));
+ if (member is null)
+ {
+ why = $"캐럿이 {side} 쪽 메서드 안에 있지 않습니다(클래스 선언 · 빈 줄 · 범위를 못 잡은 곳)";
+ return null;
+ }
+
+ var span = MemberRanges.RowsOf(numbers, member);
+ if (span is null)
+ {
+ why = $"{member.Name} 의 범위를 화면에서 찾지 못했습니다";
+ return null;
+ }
+
+ var rows = new List();
+ for (var r = span.Value.First; r <= span.Value.Last; r++) rows.Add(Lines[r]);
+ var changes = rows.Count(l => !l.IsMergeNote && (IsThreeWay ? l.CanTakeRemote : l.CanTakeBase));
+ if (changes == 0)
+ {
+ why = $"{member.Name} 안에 가져올 차이가 없습니다";
+ return null;
+ }
+
+ why = string.Empty;
+ return (member, rows, changes);
+ }
+
+ /// 메서드 반영 단추 옆 한 줄 - 무엇을 잡았는지, 꺼졌으면 왜 꺼졌는지(지시서: 까닭을 알린다).
+ public string MemberHint
+ {
+ get
+ {
+ var found = MemberAround(CaretRow, out var why);
+ if (found is null) return why;
+ var m = found.Value.Member;
+ return $"{m.Name} · {(IsThreeWay ? "REMOTE" : "BASE")} {m.Start + 1}~{m.End + 1}행 · 바뀐 줄 {found.Value.Changes}";
+ }
+ }
+
+ private bool CanAcceptMember() => MemberAround(CaretRow, out _) is not null;
+
+ ///
+ /// 캐럿이 놓인 클래스 멤버(메서드 · 생성자 · 속성 · 이벤트 · 연산자 · 필드)를 통째로 반영한다(11차 F-2).
+ /// 2-Way 는 BASE 를, 3-Way 는 REMOTE 를 가져온다 - 덩어리 반영과 같은 규칙으로 줄마다 반영하므로
+ /// 결과는 같은 행들을 줄 단위로 반영한 것과 같다. 머지 주석은 메서드 이름 줄 위에 한 줄.
+ ///
+ [RelayCommand(CanExecute = nameof(CanAcceptMember))]
+ private void AcceptMember()
+ {
+ var found = MemberAround(CaretRow, out var why);
+ if (found is null)
+ {
+ MessageUtil.ShowErrorToast(why);
+ return;
+ }
+
+ var (member, rows, _) = found.Value;
+ var nameNumber = member.NameLine + 1;
+ var anchor = rows.FirstOrDefault(r => (IsThreeWay ? r.RemoteNumber : r.BaseNumber) == nameNumber);
+
+ var count = ApplyGroup(rows, anchor, $"메서드 {member.Name}");
+ if (count > 0) MessageUtil.ShowSuccessToast($"메서드 {member.Name} 반영 · {count}줄");
}
///
diff --git a/src/MergeHub/Views/HomeView.xaml b/src/MergeHub/Views/HomeView.xaml
index 6770c6a..23f2d0d 100644
--- a/src/MergeHub/Views/HomeView.xaml
+++ b/src/MergeHub/Views/HomeView.xaml
@@ -1509,6 +1509,14 @@
MinHeight="30" Margin="2,4,2,0" FontSize="11"
ToolTip="차이가 이어지는 구간을 한 번에 반영합니다 (Alt+Shift+Right)"/>
+
+
+
+