From 8d312ba01535cbc6d8323696306964d2b64e339d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B2=94=EC=88=98?= Date: Thu, 9 Jul 2026 04:25:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A7=84=EB=8B=A8=EC=9D=84=20IDE=20Build=20Output=20=EC=B0=BD?= =?UTF-8?q?=EC=97=90=20=ED=91=9C=EC=8B=9C=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunResult.compileError 플래그 (7개 컴파일 실패 지점) - BuildOutputPublisher: BuildViewManager로 진단 게시, 실패 시 Build 창 자동 표시 - 언어별 파서: javac/kotlinc/g++(file:line:col 공통), go, rustc(2줄 형식) - 클릭 소스 이동: stdin형 실행은 1:1 매핑으로 항상, 래퍼형은 진단 파일명이 사용자 파일명과 일치하는 원형 보존 케이스(Java Solution/Kotlin)만 - 전체 실행 시 동일 컴파일 에러는 첫 케이스만 1회 게시 - 파서 단위 테스트 6종 --- .../service/BuildOutputPublisher.kt | 162 ++++++++++++++++++ .../com/codingtestkit/service/CodeRunner.kt | 18 +- .../kotlin/com/codingtestkit/ui/TestPanel.kt | 22 +++ .../service/BuildOutputPublisherTest.kt | 88 ++++++++++ 4 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/com/codingtestkit/service/BuildOutputPublisher.kt create mode 100644 src/test/kotlin/com/codingtestkit/service/BuildOutputPublisherTest.kt diff --git a/src/main/kotlin/com/codingtestkit/service/BuildOutputPublisher.kt b/src/main/kotlin/com/codingtestkit/service/BuildOutputPublisher.kt new file mode 100644 index 0000000..5db4021 --- /dev/null +++ b/src/main/kotlin/com/codingtestkit/service/BuildOutputPublisher.kt @@ -0,0 +1,162 @@ +package com.codingtestkit.service + +import com.codingtestkit.model.Language +import com.intellij.build.BuildViewManager +import com.intellij.build.DefaultBuildDescriptor +import com.intellij.build.FilePosition +import com.intellij.build.events.MessageEvent +import com.intellij.build.events.impl.FailureResultImpl +import com.intellij.build.events.impl.FileMessageEventImpl +import com.intellij.build.events.impl.FinishBuildEventImpl +import com.intellij.build.events.impl.MessageEventImpl +import com.intellij.build.events.impl.OutputBuildEventImpl +import com.intellij.build.events.impl.StartBuildEventImpl +import com.intellij.openapi.project.Project +import java.io.File + +/** + * 컴파일 진단을 IDE Build Output 도구 창으로 전달 (이슈 #32). + * + * 테스트 패널의 텍스트 영역과 달리 Build 창은 진단 전용 UI라 가독성이 좋고, + * FilePosition을 붙이면 클릭으로 소스 위치 이동이 된다. + * + * 줄 번호 매핑 주의: 플러그인은 임시 디렉토리에 소스를 써서 컴파일하므로 + * 진단의 파일·줄은 임시 파일 기준이다. + * - stdin형 실행(코드포스/SWEA 등)은 사용자 코드를 원형 그대로 쓰므로 1:1 → 클릭 이동 가능 + * - 래퍼형(리트코드/프로그래머스)은 진단 파일명이 사용자 파일명과 일치하는 경우만 + * 이동시킨다 (Java는 Solution.java가 별도 파일로 원형 유지, Kotlin은 하네스가 + * 아래에만 붙어 사용자 코드 줄이 보존됨 — 둘 다 이름이 일치. 반면 python은 + * solution.py처럼 소문자 임시명이라 불일치 → 이동 없이 메시지만) + */ +object BuildOutputPublisher { + + fun publishCompileError( + project: Project, + language: Language, + rawError: String, + userFile: File?, + wrapperStyle: Boolean + ) { + val viewManager = project.getService(BuildViewManager::class.java) ?: return + val buildId = Any() + val title = "CodingTestKit: ${language.displayName} " + I18n.t("컴파일", "compile") + val descriptor = DefaultBuildDescriptor(buildId, title, project.basePath ?: "", System.currentTimeMillis()).apply { + isActivateToolWindowWhenFailed = true // 컴파일 실패 시 Build 창 자동 표시 + } + viewManager.onEvent(buildId, StartBuildEventImpl(descriptor, I18n.t("컴파일 중...", "Compiling..."))) + + // "컴파일 에러:\n" 프리픽스 제거 후 파싱 + val compilerOutput = rawError + .removePrefix(I18n.t("컴파일 에러", "Compile error") + ":\n") + .removePrefix("컴파일 에러:\n").removePrefix("Compile error:\n") + + val diags = parse(language, compilerOutput) + if (diags.isEmpty()) { + // 파싱 실패 시 원문 그대로 (여전히 Build 창에서 보는 게 낫다) + viewManager.onEvent(buildId, OutputBuildEventImpl(buildId, compilerOutput + "\n", true)) + } else { + for (d in diags) { + // 임시 파일 진단을 실제 소스로 매핑 가능한 경우에만 클릭 이동 부여 + val target = when { + userFile == null -> null + !wrapperStyle -> userFile // stdin형: 원형 그대로 → 1:1 + d.fileName == userFile.name -> userFile // 래퍼형: 파일명 일치(원형 보존) 케이스만 + else -> null + } + if (target != null && d.line > 0) { + viewManager.onEvent(buildId, FileMessageEventImpl( + buildId, d.severity, "Compiler", d.message, d.detail, + FilePosition(target, d.line - 1, (d.column - 1).coerceAtLeast(0)) + )) + } else { + viewManager.onEvent(buildId, MessageEventImpl( + buildId, d.severity, "Compiler", d.message, d.detail + )) + } + } + } + + viewManager.onEvent(buildId, FinishBuildEventImpl( + buildId, null, System.currentTimeMillis(), + I18n.t("컴파일 실패", "Compilation failed"), FailureResultImpl() + )) + } + + // ─── 언어별 컴파일러 출력 파싱 ─── + + data class Diagnostic( + val fileName: String?, // 진단의 파일명 (경로 제외 basename) + val line: Int, // 1-based, 불명이면 -1 + val column: Int, // 1-based, 불명이면 -1 + val severity: MessageEvent.Kind, + val message: String, + val detail: String? = null + ) + + private fun kindOf(word: String): MessageEvent.Kind = + if (word.contains("warn", ignoreCase = true)) MessageEvent.Kind.WARNING else MessageEvent.Kind.ERROR + + internal fun parse(language: Language, output: String): List = when (language) { + Language.JAVA -> parseByPattern(output, Regex("""^(.+\.java):(\d+):\s*(error|warning):\s*(.+)$"""), colGroup = null) + Language.KOTLIN -> parseByPattern(output, Regex("""^(.+\.kt):(\d+):(\d+):\s*(error|warning):\s*(.+)$"""), colGroup = 3) + Language.CPP -> parseByPattern(output, Regex("""^(.+\.(?:cpp|cc|cxx|h|hpp)):(\d+):(\d+):\s*(error|warning|note):\s*(.+)$"""), colGroup = 3) + Language.GO -> parseGo(output) + Language.RUST -> parseRustc(output) + // 인터프리터 언어는 컴파일 단계가 없음 (런타임 에러는 테스트 패널 담당) + Language.PYTHON, Language.JAVASCRIPT, Language.RUBY -> emptyList() + } + + /** "file:line[:col]: severity: message" 꼴 공통 파서 (javac/kotlinc/g++) */ + private fun parseByPattern(output: String, pattern: Regex, colGroup: Int?): List { + val diags = mutableListOf() + for (line in output.lineSequence()) { + val m = pattern.find(line.trim()) ?: continue + val g = m.groupValues + val sevIdx = if (colGroup != null) 4 else 3 + diags.add(Diagnostic( + fileName = File(g[1]).name, + line = g[2].toIntOrNull() ?: -1, + column = colGroup?.let { g[it].toIntOrNull() } ?: -1, + severity = kindOf(g[sevIdx]), + message = g[sevIdx + 1].trim() + )) + } + return diags + } + + /** go: "./solution.go:5:2: message" (severity 표기 없음 — 전부 에러) */ + private fun parseGo(output: String): List { + val pattern = Regex("""^(.+\.go):(\d+):(\d+):\s*(.+)$""") + return output.lineSequence().mapNotNull { line -> + val m = pattern.find(line.trim()) ?: return@mapNotNull null + val g = m.groupValues + Diagnostic(File(g[1]).name, g[2].toIntOrNull() ?: -1, g[3].toIntOrNull() ?: -1, + MessageEvent.Kind.ERROR, g[4].trim()) + }.toList() + } + + /** + * rustc: 메시지와 위치가 두 줄로 분리됨 + * error[E0308]: mismatched types + * --> solution.rs:5:9 + */ + private fun parseRustc(output: String): List { + val head = Regex("""^(error|warning)(?:\[\w+])?:\s*(.+)$""") + val loc = Regex("""^\s*-->\s*(.+\.rs):(\d+):(\d+)""") + val diags = mutableListOf() + var pending: Pair? = null + for (line in output.lineSequence()) { + head.find(line.trim())?.let { m -> + pending = kindOf(m.groupValues[1]) to m.groupValues[2].trim() + return@let + } + val lm = loc.find(line) ?: continue + val p = pending ?: continue + diags.add(Diagnostic(File(lm.groupValues[1]).name, + lm.groupValues[2].toIntOrNull() ?: -1, lm.groupValues[3].toIntOrNull() ?: -1, + p.first, p.second)) + pending = null + } + return diags + } +} diff --git a/src/main/kotlin/com/codingtestkit/service/CodeRunner.kt b/src/main/kotlin/com/codingtestkit/service/CodeRunner.kt index 44bd9cd..5020960 100644 --- a/src/main/kotlin/com/codingtestkit/service/CodeRunner.kt +++ b/src/main/kotlin/com/codingtestkit/service/CodeRunner.kt @@ -24,7 +24,9 @@ object CodeRunner { val exitCode: Int, val timedOut: Boolean = false, val executionTimeMs: Long = 0, - val peakMemoryKB: Long = 0 + val peakMemoryKB: Long = 0, + /** 컴파일 단계 실패 여부 (Build Output 창 게시용, 이슈 #32) */ + val compileError: Boolean = false ) /** @@ -843,7 +845,7 @@ end dir, "", COMPILE_TIMEOUT_SECONDS ) if (compile.exitCode != 0) { - return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode) + return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode, compileError = true) } return executeProcess(javaCommand("-cp", dir.absolutePath, "Main"), dir, input, timeout) } @@ -865,7 +867,7 @@ end dir, "", COMPILE_TIMEOUT_SECONDS ) if (compile.exitCode != 0) { - return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode) + return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode, compileError = true) } return executeProcess(javaCommand("-cp", dir.absolutePath, "Main"), dir, input, timeout) } @@ -875,7 +877,7 @@ end val compile = executeProcess(javacCommand(sourceFile), dir, "", COMPILE_TIMEOUT_SECONDS) if (compile.exitCode != 0) { - return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode) + return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode, compileError = true) } return executeProcess(javaCommand("-cp", dir.absolutePath, className), dir, input, timeout) @@ -964,7 +966,7 @@ end dir, "", COMPILE_TIMEOUT_SECONDS ) if (compile.exitCode != 0) { - return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode) + return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode, compileError = true) } return executeProcess(listOf(outputFile.absolutePath), dir, input, timeout) @@ -988,7 +990,7 @@ end dir, "", COMPILE_TIMEOUT_SECONDS ) if (compile.exitCode != 0) { - return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode) + return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode, compileError = true) } return executeProcess(javaCommand("-jar", jarFile.absolutePath), dir, input, timeout) @@ -1023,7 +1025,7 @@ end dir, "", COMPILE_TIMEOUT_SECONDS ) if (compile.exitCode != 0) { - return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode) + return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode, compileError = true) } return executeProcess(listOf(outputFile.absolutePath), dir, input, timeout) @@ -1045,7 +1047,7 @@ end dir, "", COMPILE_TIMEOUT_SECONDS ) if (compile.exitCode != 0) { - return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode) + return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode, compileError = true) } return executeProcess(listOf(outputFile.absolutePath), dir, input, timeout) diff --git a/src/main/kotlin/com/codingtestkit/ui/TestPanel.kt b/src/main/kotlin/com/codingtestkit/ui/TestPanel.kt index 2986c26..49dfae3 100644 --- a/src/main/kotlin/com/codingtestkit/ui/TestPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/TestPanel.kt @@ -426,6 +426,21 @@ class TestPanel(private val project: Project) : JPanel(BorderLayout()) { private fun isNeutralRan(tc: TestCase): Boolean = tc.passed == null && tc.actualOutput.isNotBlank() + /** + * 컴파일 에러를 IDE Build Output 창에 게시 (이슈 #32). + * 백그라운드 스레드에서 호출 가능 (BuildViewManager 이벤트는 스레드 세이프). + */ + private fun publishCompileError(result: CodeRunner.RunResult, language: Language) { + if (!result.compileError) return + val vf = com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project) + .selectedFiles.firstOrNull() + val wrapperStyle = problemSource == ProblemSource.PROGRAMMERS || problemSource == ProblemSource.LEETCODE + com.codingtestkit.service.BuildOutputPublisher.publishCompileError( + project, language, result.error, + vf?.let { java.io.File(it.path) }, wrapperStyle + ) + } + /** 실행 결과를 해당 카드에 반영 (EDT에서 호출할 것) */ private fun applyResultToCard(index: Int, tc: TestCase, outcome: ExecOutcome) { if (index >= cards.size) return @@ -479,8 +494,14 @@ class TestPanel(private val project: Project) : JPanel(BorderLayout()) { for (card in cards) card.setRunning() ApplicationManager.getApplication().executeOnPooledThread { + var compileErrorPublished = false for ((i, tc) in testCases.withIndex()) { val result = executeCase(code, language, tc) + // 컴파일 에러는 케이스마다 동일하므로 첫 실패만 Build 창에 게시 (이슈 #32) + if (result.result.compileError && !compileErrorPublished) { + compileErrorPublished = true + publishCompileError(result.result, language) + } val idx = i SwingUtilities.invokeLater { applyResultToCard(idx, tc, result) } } @@ -505,6 +526,7 @@ class TestPanel(private val project: Project) : JPanel(BorderLayout()) { ApplicationManager.getApplication().executeOnPooledThread { val tc = testCases[index] val result = executeCase(code, language, tc) + if (result.result.compileError) publishCompileError(result.result, language) SwingUtilities.invokeLater { applyResultToCard(index, tc, result) running = false diff --git a/src/test/kotlin/com/codingtestkit/service/BuildOutputPublisherTest.kt b/src/test/kotlin/com/codingtestkit/service/BuildOutputPublisherTest.kt new file mode 100644 index 0000000..add54aa --- /dev/null +++ b/src/test/kotlin/com/codingtestkit/service/BuildOutputPublisherTest.kt @@ -0,0 +1,88 @@ +package com.codingtestkit.service + +import com.codingtestkit.model.Language +import com.intellij.build.events.MessageEvent +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +/** 언어별 컴파일러 출력 파서 검증 (이슈 #32) */ +class BuildOutputPublisherTest { + + @Test + fun `parse javac diagnostics`() { + val out = """ + Main.java:9: error: method twoSum in class Solution cannot be applied to given types; + Object _result = sol.twoSum(new int[]{1,2}); + ^ + Main.java:12: warning: [deprecation] foo() in Bar has been deprecated + 2 errors + """.trimIndent() + val d = BuildOutputPublisher.parse(Language.JAVA, out) + assertEquals(2, d.size) + assertEquals("Main.java", d[0].fileName) + assertEquals(9, d[0].line) + assertEquals(MessageEvent.Kind.ERROR, d[0].severity) + assertTrue(d[0].message.startsWith("method twoSum")) + assertEquals(MessageEvent.Kind.WARNING, d[1].severity) + assertEquals(12, d[1].line) + } + + @Test + fun `parse kotlinc diagnostics`() { + val out = "Solution.kt:3:10: error: unresolved reference: foo" + val d = BuildOutputPublisher.parse(Language.KOTLIN, out) + assertEquals(1, d.size) + assertEquals("Solution.kt", d[0].fileName) + assertEquals(3, d[0].line) + assertEquals(10, d[0].column) + } + + @Test + fun `parse gcc diagnostics`() { + val out = """ + solution.cpp:5:10: error: 'cout' was not declared in this scope + solution.cpp:7:1: warning: control reaches end of non-void function [-Wreturn-type] + """.trimIndent() + val d = BuildOutputPublisher.parse(Language.CPP, out) + assertEquals(2, d.size) + assertEquals(5, d[0].line) + assertEquals(MessageEvent.Kind.WARNING, d[1].severity) + } + + @Test + fun `parse rustc two-line diagnostics`() { + val out = """ + error[E0308]: mismatched types + --> solution.rs:5:9 + | + 5 | let x: i32 = "hello"; + warning: unused variable: `y` + --> solution.rs:8:9 + """.trimIndent() + val d = BuildOutputPublisher.parse(Language.RUST, out) + assertEquals(2, d.size) + assertEquals("solution.rs", d[0].fileName) + assertEquals(5, d[0].line) + assertEquals(MessageEvent.Kind.ERROR, d[0].severity) + assertEquals("mismatched types", d[0].message) + assertEquals(MessageEvent.Kind.WARNING, d[1].severity) + assertEquals(8, d[1].line) + } + + @Test + fun `parse go diagnostics`() { + val out = "./solution.go:5:2: undefined: fmt.Printlnn" + val d = BuildOutputPublisher.parse(Language.GO, out) + assertEquals(1, d.size) + assertEquals("solution.go", d[0].fileName) + assertEquals(5, d[0].line) + assertEquals(2, d[0].column) + } + + @Test + fun `interpreted languages produce no compile diagnostics`() { + assertTrue(BuildOutputPublisher.parse(Language.PYTHON, "SyntaxError: x").isEmpty()) + assertTrue(BuildOutputPublisher.parse(Language.JAVASCRIPT, "x").isEmpty()) + assertTrue(BuildOutputPublisher.parse(Language.RUBY, "x").isEmpty()) + } +} From 4322055466fbd5b3bf4827ae125ed6176ca1b900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B2=94=EC=88=98?= Date: Thu, 9 Jul 2026 04:42:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=9D=B8=ED=84=B0=ED=94=84?= =?UTF-8?q?=EB=A6=AC=ED=84=B0=20=EC=96=B8=EC=96=B4=20=EB=AC=B8=EB=B2=95=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EB=8F=84=20Build=20Output=20=EC=B0=BD?= =?UTF-8?q?=EC=97=90=20=EA=B2=8C=EC=8B=9C=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python(SyntaxError/IndentationError/TabError)·JS(SyntaxError)·Ruby(syntax error)의 '프로그램 시작 실패' 진단을 컴파일 에러의 대응물로 Build 창에 게시. 케이스별 런타임 예외는 테스트 케이스 실패 정보이므로 기존대로 패널 담당. - 실제 인터프리터 출력 형식 기반 파서 3종 + 게이트(isStartupError) - kotlinc 실출력도 IDE 번들 컴파일러로 확증 (5개 컴파일 언어 전부 실검증) - 실출력 기반 단위 테스트 4종 추가 --- .../service/BuildOutputPublisher.kt | 69 ++++++++++++++++++- .../kotlin/com/codingtestkit/ui/TestPanel.kt | 14 ++-- .../service/BuildOutputPublisherTest.kt | 57 +++++++++++++-- 3 files changed, 129 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/com/codingtestkit/service/BuildOutputPublisher.kt b/src/main/kotlin/com/codingtestkit/service/BuildOutputPublisher.kt index 5db4021..9af4807 100644 --- a/src/main/kotlin/com/codingtestkit/service/BuildOutputPublisher.kt +++ b/src/main/kotlin/com/codingtestkit/service/BuildOutputPublisher.kt @@ -102,8 +102,73 @@ object BuildOutputPublisher { Language.CPP -> parseByPattern(output, Regex("""^(.+\.(?:cpp|cc|cxx|h|hpp)):(\d+):(\d+):\s*(error|warning|note):\s*(.+)$"""), colGroup = 3) Language.GO -> parseGo(output) Language.RUST -> parseRustc(output) - // 인터프리터 언어는 컴파일 단계가 없음 (런타임 에러는 테스트 패널 담당) - Language.PYTHON, Language.JAVASCRIPT, Language.RUBY -> emptyList() + // 인터프리터 언어: 컴파일 단계는 없지만 문법 에러(시작 실패)는 컴파일 에러의 + // 대응물이므로 파싱해 Build 창에 게시 (케이스별 런타임 예외는 테스트 패널 담당) + Language.PYTHON -> parsePython(output) + Language.JAVASCRIPT -> parseNode(output) + Language.RUBY -> output.lineSequence().mapNotNull { line -> + Regex("""^(.+\.rb):(\d+):\s*(syntax error.*)$""").find(line.trim())?.let { m -> + Diagnostic(File(m.groupValues[1]).name, m.groupValues[2].toIntOrNull() ?: -1, -1, + MessageEvent.Kind.ERROR, m.groupValues[3].trim()) + } + }.toList() + } + + /** + * 인터프리터 언어에서 '프로그램이 시작조차 못 한' 문법 에러인가 (이슈 #32). + * true면 Build 창에 게시할 가치가 있음. 케이스별 런타임 예외는 제외. + */ + fun isStartupError(language: Language, stderr: String): Boolean = when (language) { + Language.PYTHON -> listOf("SyntaxError", "IndentationError", "TabError").any { it in stderr } + Language.JAVASCRIPT -> "SyntaxError" in stderr + Language.RUBY -> "syntax error" in stderr + else -> false + } + + /** + * python: + * File "/tmp/.../solution.py", line 3 + * def foo(: + * SyntaxError: invalid syntax + */ + private fun parsePython(output: String): List { + val fileLine = Regex("""^\s*File "(.+)", line (\d+)""") + var lastFile: String? = null + var lastLine = -1 + var message = "" + for (line in output.lineSequence()) { + fileLine.find(line)?.let { m -> + lastFile = File(m.groupValues[1]).name + lastLine = m.groupValues[2].toIntOrNull() ?: -1 + } + if (Regex("""^\w*(Error|Warning):\s*.+""").matches(line.trim())) message = line.trim() + } + if (lastFile == null) return emptyList() + return listOf(Diagnostic(lastFile, lastLine, -1, MessageEvent.Kind.ERROR, + message.ifBlank { I18n.t("문법 에러", "Syntax error") })) + } + + /** + * node: + * /tmp/.../solution.js:3 + * def foo(: + * SyntaxError: Unexpected identifier + */ + private fun parseNode(output: String): List { + val header = Regex("""^(.+\.(?:js|mjs|cjs)):(\d+)\s*$""") + var file: String? = null + var lineNo = -1 + var message = "" + for (line in output.lineSequence()) { + if (file == null) header.find(line.trim())?.let { m -> + file = File(m.groupValues[1]).name + lineNo = m.groupValues[2].toIntOrNull() ?: -1 + } + if (message.isBlank() && Regex("""^\w*Error:\s*.+""").matches(line.trim())) message = line.trim() + } + if (file == null) return emptyList() + return listOf(Diagnostic(file, lineNo, -1, MessageEvent.Kind.ERROR, + message.ifBlank { I18n.t("문법 에러", "Syntax error") })) } /** "file:line[:col]: severity: message" 꼴 공통 파서 (javac/kotlinc/g++) */ diff --git a/src/main/kotlin/com/codingtestkit/ui/TestPanel.kt b/src/main/kotlin/com/codingtestkit/ui/TestPanel.kt index 49dfae3..f183868 100644 --- a/src/main/kotlin/com/codingtestkit/ui/TestPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/TestPanel.kt @@ -426,12 +426,16 @@ class TestPanel(private val project: Project) : JPanel(BorderLayout()) { private fun isNeutralRan(tc: TestCase): Boolean = tc.passed == null && tc.actualOutput.isNotBlank() + /** Build 창에 게시할 진단인가: 컴파일 에러 또는 인터프리터 문법 에러(시작 실패) */ + private fun shouldPublishDiagnostics(result: CodeRunner.RunResult, language: Language): Boolean = + result.compileError || + (result.exitCode != 0 && com.codingtestkit.service.BuildOutputPublisher.isStartupError(language, result.error)) + /** - * 컴파일 에러를 IDE Build Output 창에 게시 (이슈 #32). + * 컴파일/문법 진단을 IDE Build Output 창에 게시 (이슈 #32). * 백그라운드 스레드에서 호출 가능 (BuildViewManager 이벤트는 스레드 세이프). */ private fun publishCompileError(result: CodeRunner.RunResult, language: Language) { - if (!result.compileError) return val vf = com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project) .selectedFiles.firstOrNull() val wrapperStyle = problemSource == ProblemSource.PROGRAMMERS || problemSource == ProblemSource.LEETCODE @@ -497,8 +501,8 @@ class TestPanel(private val project: Project) : JPanel(BorderLayout()) { var compileErrorPublished = false for ((i, tc) in testCases.withIndex()) { val result = executeCase(code, language, tc) - // 컴파일 에러는 케이스마다 동일하므로 첫 실패만 Build 창에 게시 (이슈 #32) - if (result.result.compileError && !compileErrorPublished) { + // 컴파일/문법 에러는 케이스마다 동일하므로 첫 실패만 Build 창에 게시 (이슈 #32) + if (!compileErrorPublished && shouldPublishDiagnostics(result.result, language)) { compileErrorPublished = true publishCompileError(result.result, language) } @@ -526,7 +530,7 @@ class TestPanel(private val project: Project) : JPanel(BorderLayout()) { ApplicationManager.getApplication().executeOnPooledThread { val tc = testCases[index] val result = executeCase(code, language, tc) - if (result.result.compileError) publishCompileError(result.result, language) + if (shouldPublishDiagnostics(result.result, language)) publishCompileError(result.result, language) SwingUtilities.invokeLater { applyResultToCard(index, tc, result) running = false diff --git a/src/test/kotlin/com/codingtestkit/service/BuildOutputPublisherTest.kt b/src/test/kotlin/com/codingtestkit/service/BuildOutputPublisherTest.kt index add54aa..4a04727 100644 --- a/src/test/kotlin/com/codingtestkit/service/BuildOutputPublisherTest.kt +++ b/src/test/kotlin/com/codingtestkit/service/BuildOutputPublisherTest.kt @@ -80,9 +80,58 @@ class BuildOutputPublisherTest { } @Test - fun `interpreted languages produce no compile diagnostics`() { - assertTrue(BuildOutputPublisher.parse(Language.PYTHON, "SyntaxError: x").isEmpty()) - assertTrue(BuildOutputPublisher.parse(Language.JAVASCRIPT, "x").isEmpty()) - assertTrue(BuildOutputPublisher.parse(Language.RUBY, "x").isEmpty()) + fun `parse python syntax error`() { + // 실제 python3 출력 형식 + val out = """ + File "/tmp/ctk_run_1/solution.py", line 3 + def foo(: + ^ + SyntaxError: invalid syntax + """.trimIndent() + val d = BuildOutputPublisher.parse(Language.PYTHON, out) + assertEquals(1, d.size) + assertEquals("solution.py", d[0].fileName) + assertEquals(3, d[0].line) + assertEquals("SyntaxError: invalid syntax", d[0].message) + } + + @Test + fun `parse node syntax error`() { + // 실제 node 출력 형식 + val out = """ + /tmp/ctk_run_1/solution.js:3 + def foo(: + ^^^ + + SyntaxError: Unexpected identifier + """.trimIndent() + val d = BuildOutputPublisher.parse(Language.JAVASCRIPT, out) + assertEquals(1, d.size) + assertEquals("solution.js", d[0].fileName) + assertEquals(3, d[0].line) + assertTrue(d[0].message.startsWith("SyntaxError")) + } + + @Test + fun `parse ruby syntax error only`() { + // 실제 ruby 출력 형식 + val out = "solution.rb:3: syntax error, unexpected ':', expecting ')'" + val d = BuildOutputPublisher.parse(Language.RUBY, out) + assertEquals(1, d.size) + assertEquals(3, d[0].line) + assertTrue(d[0].message.startsWith("syntax error")) + // 런타임 에러 형식은 매칭되지 않아야 함 + assertTrue(BuildOutputPublisher.parse(Language.RUBY, "solution.rb:5:in '
': divided by 0 (ZeroDivisionError)").isEmpty()) + } + + @Test + fun `isStartupError gates interpreted languages`() { + assertTrue(BuildOutputPublisher.isStartupError(Language.PYTHON, "SyntaxError: invalid syntax")) + assertFalse(BuildOutputPublisher.isStartupError(Language.PYTHON, "IndexError: list index out of range")) + assertTrue(BuildOutputPublisher.isStartupError(Language.JAVASCRIPT, "SyntaxError: Unexpected token")) + assertFalse(BuildOutputPublisher.isStartupError(Language.JAVASCRIPT, "TypeError: undefined is not a function")) + assertTrue(BuildOutputPublisher.isStartupError(Language.RUBY, "solution.rb:1: syntax error, unexpected ':'")) + assertFalse(BuildOutputPublisher.isStartupError(Language.RUBY, "divided by 0 (ZeroDivisionError)")) + assertFalse(BuildOutputPublisher.isStartupError(Language.JAVA, "SyntaxError")) } }