From b663f2400994df3ffda58fbb7c80d6e8169e4086 Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 28 Sep 2026 16:07:46 +0900 Subject: [PATCH] TASK-058: outline for C++ with CRLF line endings, macros and #if blocks Co-Authored-By: Claude Fable 5.1 --- .../ubgitea/feature/codebrowser/CodePane.kt | 4 +- .../codebrowser/content/SymbolOutline.kt | 27 ++++++- .../codebrowser/content/SymbolOutlineTest.kt | 58 +++++++++++++++ docs/UBGitea_기술소개.pptx | Bin 725853 -> 725853 bytes docs/development-log.md | 11 +++ tasks/058-outline-for-windows-cpp.md | 69 ++++++++++++++++++ 6 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tasks/058-outline-for-windows-cpp.md diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt index 01a1a4b..7fa6add 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodePane.kt @@ -81,7 +81,9 @@ class CodeDocument( fun rememberCodeDocument(path: RepositoryPath, text: String): CodeDocument { val colors = rememberCodeSyntaxColors() val language = remember(path) { syntaxLanguageForPath(path) } - val lines = remember(text) { text.split("\n") } + // Files written on Windows end their lines with "\r\n". The "\r" is not part of the line: left + // in, it is an invisible last character that no declaration pattern expects (TASK-058). + val lines = remember(text) { text.split("\n").map { it.removeSuffix("\r") } } val plain = remember(path, lines) { CodeDocument(path, language, lines, lines.map(::AnnotatedString), emptyList(), lines.maxOfOrNull(String::length) ?: 0) } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt index 210be31..ae7b7b7 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutline.kt @@ -34,13 +34,27 @@ private val OUTLINE_LANGUAGES = setOf(SyntaxLanguage.CSHARP, SyntaxLanguage.CPP, */ fun extractSymbols(lines: List, language: SyntaxLanguage): List { if (!supportsOutline(language)) return emptyList() - val code = blankCommentsAndStrings(lines, language) + val source = lines.map { it.removeSuffix("\r") } + val code = blankCommentsAndStrings(source, language) val symbols = mutableListOf() val scopes = ArrayDeque() + // How deep the scopes were where each open `#if` began. + val conditionals = ArrayDeque() var awaitingBody: Pending? = null var linesAwaited = 0 code.forEachIndexed { index, line -> + // Only one branch of an `#if` is compiled, but all of them are read here. When each branch + // opens the same block (`#ifdef A` / `if (x) {` / `#else` / `if (y) {` / `#endif`), counting + // both braces would leave a scope that never closes and hide the rest of the file. So each + // further branch starts from the depth the first one started from. + when (directiveOf(source[index])) { + "if", "ifdef", "ifndef" -> conditionals.addLast(scopes.size) + "else", "elif", "elifdef", "elifndef" -> conditionals.lastOrNull()?.let { depth -> + while (scopes.size > depth) scopes.removeLast() + } + "endif" -> conditionals.removeLastOrNull() + } val enclosing = scopes.lastOrNull()?.kind val canDeclare = enclosing == null || enclosing == ScopeKind.NAMESPACE || enclosing == ScopeKind.TYPE if (awaitingBody != null && ++linesAwaited > MAX_LINES_AWAITING_BODY) awaitingBody = null @@ -142,6 +156,14 @@ private class Pending(val found: Found, val line: Int, val depth: Int) { private class Found(val name: String, val label: String, val kind: SymbolKind) +private val DIRECTIVE = Regex("^\\s*#\\s*([a-z]+)") + +private fun directiveOf(line: String): String? = DIRECTIVE.find(line)?.groupValues?.get(1) + +// `DECLARE_MESSAGE_MAP()`, `Q_OBJECT`-style: written like a call, named in capitals, and -- unlike +// a declaration -- often not followed by `;`. +private val MACRO_NAME = Regex("[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*") + private fun blankCommentsAndStrings(lines: List, language: SyntaxLanguage): List = tokenizeLines(lines, language).mapIndexed { index, tokens -> val characters = lines[index].toCharArray() @@ -225,6 +247,9 @@ private fun cppDeclaration(line: String, trimmed: String, firstWord: String, ins // `Name(...)` is a constructor or a macro-declared function. A free-standing call // (`DoThing(x);` at namespace level) is not valid C++, so it cannot be one. val isConstructorLike = insideType || "::" in qualified + // A macro invocation in a class body is not a member, and as it has no `;` it would + // also swallow the declaration on the next line while a body was waited for. + if (!hasReturnType && "::" !in qualified && name.length > 1 && MACRO_NAME.matches(name)) return null if (name !in STATEMENT_WORDS && (hasReturnType || isConstructorLike)) { return Found(name, name + parameterTypes(match.groupValues[2]), SymbolKind.METHOD) } diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutlineTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutlineTest.kt index 715750b..02591e5 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutlineTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SymbolOutlineTest.kt @@ -99,6 +99,64 @@ class SymbolOutlineTest { assertTrue(symbols.none { it.name == "public" || it.name == "private" }) } + @Test + fun `lines ending in a carriage return are read like any other`() { + val header = "class A\r\n{\r\npublic:\r\n\tvoid F(int a);\r\n};\r\n".split("\n") + val source = "void A::F(int a)\r\n{\r\n}\r\n".split("\n") + val sharp = "public class A\r\n{\r\n public void F()\r\n {\r\n }\r\n}\r\n".split("\n") + + assertEquals(listOf("A", "F(int)"), extractSymbols(header, SyntaxLanguage.CPP).map { it.label }) + assertEquals(listOf("F(int)"), extractSymbols(source, SyntaxLanguage.CPP).map { it.label }) + assertEquals(listOf("A", "F()"), extractSymbols(sharp, SyntaxLanguage.CSHARP).map { it.label }) + } + + @Test + fun `a macro in a class body is not a method and does not hide the declaration after it`() { + val lines = """ + class CMainDlg : public CDialogEx + { + DECLARE_DYNAMIC(CMainDlg) + public: + CMainDlg(CWnd* pParent = NULL); + virtual ~CMainDlg(); + DECLARE_MESSAGE_MAP() + public: + afx_msg void OnBnClickedOk(); + }; + """.trimIndent().lines() + + assertEquals( + listOf("CMainDlg", "CMainDlg(CWnd*)", "~CMainDlg()", "OnBnClickedOk()"), + extractSymbols(lines, SyntaxLanguage.CPP).map { it.label }, + ) + } + + @Test + fun `a block opened in both branches of a conditional is counted once`() { + val lines = """ + BOOL CMainDlg::PreTranslateMessage(MSG* pMsg) + { + #ifdef A + if (x) { + #else + if (y) { + #endif + } + return TRUE; + } + + int After(int a) + { + return a; + } + """.trimIndent().lines() + + val symbols = extractSymbols(lines, SyntaxLanguage.CPP) + + assertEquals(listOf("PreTranslateMessage(MSG*)", "After(int)"), symbols.map { it.label }) + assertEquals(9, symbols.first().endLine) + } + @Test fun `a forward declaration is not listed`() { val symbols = extractSymbols(listOf("class Foo;"), SyntaxLanguage.CPP) diff --git a/docs/UBGitea_기술소개.pptx b/docs/UBGitea_기술소개.pptx index e3b577ff0cb21c14e5a8c7bcdce22965684fa3a1..6eef4fc53daf3641e3a271659c87e895f387179d 100644 GIT binary patch delta 2953 zcmY*adsJ0r7U#SV-{EmD&Omk*ADKCLK}Znd;scyCLS+~=qEV#Ow51ElN-NN`(OTLV zG8g(kSED33_ih(s)&iA^__etd>4+lzP+VlY*nQL*7bM6dbYK`90%+E zsrI=yO8|g%biBfE+}6hSZnY8itk|ig)Pe^gl)TqB47RaIUx!} zoKi_qt}84o*Tu8X2(i9RJ0Elbu)VHYL>Ec5wN?C5k2u-)*8}@i-1;ChTjqgE(d}D( z=n|;-<~26mhet2oop_k%w+a(|iM^e8V{&r zbqI?ERB68dF9v$$wL^@V#a{_$z?PYIM%c3y5q>3YKl(7QC5Y(RYe9tw!* zrXy8+M(`#@tNhg=aUA5mKO_pp@bHg^B)ibZVoqM9LdOYKiT%r@S|Th9<{v~t^VCWS z`(YkjExE|9^X*xa$nN#`B&x3CghbW7(JWDQ@BT}QUju3#Q{)J=dnrL~gSMH6Q{+)1 zz9~CLUq4yCH7u-rD=%x3yqb}sIO6a%cO!JkzlwC7T9-FVr`DCs(PjEwA78Vg5=>1r zUp}B;C9y-@W;&RfY}PgC^;B8c;+#`p%56T?s9zym(o;OzEIzC^ID|A)Zq`S^KsHBz zp>t|%#yu5&;fSWYja6}O@PJb0Q1~Jwg(7_ z2)&ySM*u#*o59^d?o5c*=3Iyt=NRtjVK8lpIJbw=Ea@4yHe2jXY@*kt$%;cI(ox4xI<0qW;oHV0zzO^O?DlCyVj5=*zeP6@Jw zfZG;i4?_Gl$g&}h?PCiduIpoYsDl3oB||KhpYCIsaOCJ`*?5Hi2Pq@g(T0Ay*!q6k zML&B0pz{W7DE=QHv|)fHV|JcV@iBkA&&uu@VAF8xo`|Az9JS^Ql2*ka*=imnTaiN~ z@X!zm92>F)aL}WGbJ!NRdZI9c1Xc{QX<%pXFlBu|OsrE!*a8$D)%ol@1g#mdz09nr z{+hgWj*yr55M@0aA}>cm_D4fUGGv34nTQS%BdeX_*^woKwHpAD_W{>l6K>nWiH^lqh1<4pmLtKXa3UDPNUMaA))7Wth8cNzz@YP5!&rz{xUpqy z5*4dN*sM6Ik#%tdvF?qqZQ-289{3|{TR5iC*6c{?!Q+ueA#%fH=IxON{=}oA$kFmB za`a9VIg+DoZXCDBy(-$~#%YV(zG&MV4qG%QI1CTUz^vuJcaY5U4wC6`kjywI$vo|( a{Zmc@XTXFQT9?Jpx;4hYX%;=oSoDAYiE-Ni delta 2954 zcmY*aYgAO%73SVM_rbY5W-tq)qxgt176$|cQ9uQfXbCE%YLHZsR*h!qLSj=*0`ZY* zvNSPB2XY!>Bh->u!&2k8a;+(aHcefNh6EA~F)m+V+O*Mj21MRW@80|9TAd%W?>Xn& z-}mja@9eqquII|Tp0FR~Dh?I`pSMyf!=}jgcjCkFT`gS7d!oT5@t) zN|HA@ecAoz{prboZc3jT3Gv$}MxajL&CQ!}onN*@ve(`t<=?{gD=)245VrdkihTLY zT~Y*yxRm0;9Ct`aj+Da)Gl zg<+~zYrC{uLY1Z`UN_MzuNh{{D*95o2)3-0i_*cBi14c^N6?3LD^5nwR+~pA;or=* zy9)4ADROZ>!oO~qah~95uL^kVegXf!Lcm8W<<_;3*BX!+5?t!BF-TCp@xim`s5--B zJQ$EuEoZX)g5*tz)cCK5<%y8@hhbSNf`>morHDeGi8^_$*)pY8}lyVv3zt zM3Q5SjBm=mvA55a?FIkT{bW3{{Uils0<5AOR{Uk%A1lrBPOh54 zPpyzoSbc+fze`HIx3QQzKbALHE#by*VQN|>DFzuVPc^7_xeE;HU2&%2pda>`jcY5w z)fDTEqsC2AJKk-jf~#p(ZM{)PrFAXOJ`b)u)-M{28-&YTv3Qpp=r(M5*|^XP z|Aw7>m36=akMlDa!@S7PQXp3QSt7(OJ#0Mh^0RTU2>Mwn#L{kib47qnhQ*EmOTUk7 z3lI_!MmHf&0(@~dgS&&=`4H{RM33KL zAfCR%vLPP7!xlpP{to$^+RHMq%kUqSId*x4Vz6g7{4z%YrzepDls7xu4~t3jQG!53^`~p`WF}k#m4$;Sv5JB#qifTLjs320rn(77Yzz1{vjZ=evl<%cCK0e34gZV&h8mxGjVHPSm7m(TA4$nRX#+vnuf?$ z_%I1PHB17hhlK!6dK7St2!Wet3+9r*+7UJr>>L`QtnWsMb;c-Lg2H1uUwogS)uY18 z{PL==$V=xad5H~D)=z`vjD-*O?HnY+9Kif~A#q@ z=x2K$7;c`Coi{j@{(?t)CnBr$Xag@ zYo{StkHl5}m{_wzg)JQESZrmeu!X}M39f|_!FWbm#f-F0GV(IQ$jg%^o!6M=QoO=# z%@Y!+ST#(r;;2T}