Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions src/main/kotlin/com/codingtestkit/service/BuildOutputPublisher.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
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<Diagnostic> = 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)
// 인터프리터 언어: 컴파일 단계는 없지만 문법 에러(시작 실패)는 컴파일 에러의
// 대응물이므로 파싱해 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<Diagnostic> {
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<Diagnostic> {
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++) */
private fun parseByPattern(output: String, pattern: Regex, colGroup: Int?): List<Diagnostic> {
val diags = mutableListOf<Diagnostic>()
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<Diagnostic> {
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<Diagnostic> {
val head = Regex("""^(error|warning)(?:\[\w+])?:\s*(.+)$""")
val loc = Regex("""^\s*-->\s*(.+\.rs):(\d+):(\d+)""")
val diags = mutableListOf<Diagnostic>()
var pending: Pair<MessageEvent.Kind, String>? = 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
}
}
18 changes: 10 additions & 8 deletions src/main/kotlin/com/codingtestkit/service/CodeRunner.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
)

/**
Expand DownExpand Up@@ -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)
}
Expand All@@ -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)
}
Expand All@@ -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)
Expand DownExpand Up@@ -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)
Expand All@@ -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)
Expand DownExpand Up@@ -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)
Expand All@@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/main/kotlin/com/codingtestkit/ui/TestPanel.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -426,6 +426,25 @@ 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).
* 백그라운드 스레드에서 호출 가능 (BuildViewManager 이벤트는 스레드 세이프).
*/
private fun publishCompileError(result: CodeRunner.RunResult, language: Language) {
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
Expand DownExpand Up@@ -479,8 +498,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 (!compileErrorPublished && shouldPublishDiagnostics(result.result, language)) {
compileErrorPublished = true
publishCompileError(result.result, language)
}
val idx = i
SwingUtilities.invokeLater { applyResultToCard(idx, tc, result) }
}
Expand All@@ -505,6 +530,7 @@ class TestPanel(private val project: Project) : JPanel(BorderLayout()) {
ApplicationManager.getApplication().executeOnPooledThread {
val tc = testCases[index]
val result = executeCase(code, language, tc)
if (shouldPublishDiagnostics(result.result, language)) publishCompileError(result.result, language)
SwingUtilities.invokeLater {
applyResultToCard(index, tc, result)
running = false
Expand Down
Loading
Loading